Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/content/issue_tracking/jira/PRO__jira_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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..**
Expand Down
21 changes: 21 additions & 0 deletions dojo/db_migrations/0278_jira_project_transition_fields.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
11 changes: 6 additions & 5 deletions dojo/jira/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion dojo/jira/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
27 changes: 19 additions & 8 deletions dojo/jira/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,10 +518,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))
Expand Down Expand Up @@ -1154,7 +1157,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)
Expand Down Expand Up @@ -1250,11 +1253,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
Expand All @@ -1263,7 +1269,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
Expand All @@ -1272,7 +1279,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
Expand Down Expand Up @@ -1316,12 +1324,13 @@ def close_jira_issue_for_deleted_finding(finding, push_to_jira=None) -> tuple[bo
jira_issue.jira_id,
jira_project.jira_instance.id,
finding.id,
close_transition_fields=jira_project.close_transition_fields,
)
return True, f"Jira issue {jira_issue.jira_key} close queued."


@app.task
def close_deleted_finding_jira_issue(jira_id, jira_instance_id, finding_id, **_kwargs) -> tuple[bool | None, str]:
def close_deleted_finding_jira_issue(jira_id, jira_instance_id, finding_id, close_transition_fields=None, **_kwargs) -> tuple[bool | None, str]:
jira_instance = get_object_or_none(JIRA_Instance, id=jira_instance_id)
if not jira_instance:
message = f"JIRA instance {jira_instance_id} is not available for issue {jira_id}."
Expand All @@ -1345,7 +1354,7 @@ def close_deleted_finding_jira_issue(jira_id, jira_instance_id, finding_id, **_k
logger.debug("Jira issue %s is already resolved", jira_id)
return False, f"Jira issue {jira_id} is already resolved."

updated = jira_transition(jira, issue, jira_instance.close_status_key)
updated = jira_transition(jira, issue, jira_instance.close_status_key, fields=close_transition_fields)
if updated:
jira.add_comment(
jira_id,
Expand Down Expand Up @@ -1515,6 +1524,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),
Expand Down
6 changes: 6 additions & 0 deletions dojo/jira/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading