From 119db6f91d92263de30b9bf1f0210659e695f84e Mon Sep 17 00:00:00 2001 From: kitos Date: Thu, 9 Jul 2026 17:08:53 +0200 Subject: [PATCH] fix(jira): wrap select-field values in {value: ...} for Jira REST API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jira Cloud's REST API rejects a bare string for select-type custom fields with 'Specify a valid id or name for ' — confirmed live against the real Jira instance. This silently broke Severity, Data Sensitivity, Attack success, Attack detected, and Attack contained on every ticket create/update, masked until now by the non-fatal exception handling around each call. --- backend/app/services/jira_service.py | 20 +++++++++++++++----- backend/tests/test_jira_service.py | 8 ++++---- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index b8917fc..b7f3642 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -706,12 +706,12 @@ def auto_create_test_issue( # customfield_10309 = Proof of Concept field (required by team's Jira config) "customfield_10309": f"{{code}}{poc}{{code}}", JIRA_FIELD_TTP: mitre_id, - JIRA_FIELD_SEVERITY: severity, + JIRA_FIELD_SEVERITY: _select_field(severity), } data_sensitivity = _DATA_CLASSIFICATION_TO_JIRA.get(_enum_value(test.data_classification)) if data_sensitivity: - fields[JIRA_FIELD_DATA_SENSITIVITY] = data_sensitivity + fields[JIRA_FIELD_DATA_SENSITIVITY] = _select_field(data_sensitivity) # Inherit campaign start date if available, otherwise use today from datetime import date as _date @@ -1027,6 +1027,16 @@ def _enum_value(v) -> Optional[str]: return v.value if hasattr(v, "value") else str(v) +def _select_field(value: str) -> dict: + """Wrap a value for a Jira single-select custom field. + + Jira's REST API rejects a bare string for select-type custom fields + ("Specify a valid 'id' or 'name' for ") — it must be posted as + ``{"value": ...}``. + """ + return {"value": value} + + def _compute_hours(start: Optional[datetime], end: Optional[datetime]) -> Optional[float]: """Return the elapsed time between two timestamps in hours, or None if unavailable.""" if not start or not end: @@ -1079,7 +1089,7 @@ def push_rt_submitted(db: Session, test: Test) -> None: fields: dict = {JIRA_FIELD_RT_END_DATE: datetime.utcnow().strftime("%Y-%m-%d")} attack_success = _enum_value(test.attack_success) if attack_success in _ATTACK_SUCCESS_TO_JIRA: - fields[JIRA_FIELD_ATTACK_SUCCESS] = _ATTACK_SUCCESS_TO_JIRA[attack_success] + fields[JIRA_FIELD_ATTACK_SUCCESS] = _select_field(_ATTACK_SUCCESS_TO_JIRA[attack_success]) _update_test_fields(db, test, fields) @@ -1096,11 +1106,11 @@ def push_bt_submitted(db: Session, test: Test) -> None: detection_result = _enum_value(test.detection_result) if detection_result in _DETECTION_TO_JIRA: - fields[JIRA_FIELD_ATTACK_DETECTED] = _DETECTION_TO_JIRA[detection_result] + fields[JIRA_FIELD_ATTACK_DETECTED] = _select_field(_DETECTION_TO_JIRA[detection_result]) containment_result = _enum_value(test.containment_result) if containment_result in _CONTAINMENT_TO_JIRA: - fields[JIRA_FIELD_ATTACK_CONTAINED] = _CONTAINMENT_TO_JIRA[containment_result] + fields[JIRA_FIELD_ATTACK_CONTAINED] = _select_field(_CONTAINMENT_TO_JIRA[containment_result]) time_to_detect = _compute_hours(test.execution_end_time, test.detection_time) if time_to_detect is not None: diff --git a/backend/tests/test_jira_service.py b/backend/tests/test_jira_service.py index 94dae06..8476533 100644 --- a/backend/tests/test_jira_service.py +++ b/backend/tests/test_jira_service.py @@ -200,7 +200,7 @@ def test_push_rt_submitted_includes_attack_success(mock_get_client, mock_configu fields = mock_jira.update_issue_field.call_args[1]["fields"] assert fields[jira_service.JIRA_FIELD_RT_END_DATE] - assert fields[jira_service.JIRA_FIELD_ATTACK_SUCCESS] == "Yes" + assert fields[jira_service.JIRA_FIELD_ATTACK_SUCCESS] == {"value": "Yes"} @patch("app.services.jira_service.has_admin_jira_configured", return_value=True) @@ -224,7 +224,7 @@ def test_push_bt_submitted_includes_detection_and_hours(mock_get_client, mock_co jira_service.push_bt_submitted(db, test) fields = mock_jira.update_issue_field.call_args[1]["fields"] - assert fields[jira_service.JIRA_FIELD_ATTACK_DETECTED] == "Yes" + assert fields[jira_service.JIRA_FIELD_ATTACK_DETECTED] == {"value": "Yes"} assert fields[jira_service.JIRA_FIELD_TIME_TO_DETECT] == 2.0 assert fields[jira_service.JIRA_FIELD_TIME_TO_CONTAIN] == 1.5 @@ -242,7 +242,7 @@ def test_push_bt_submitted_includes_attack_contained(mock_get_client, mock_confi jira_service.push_bt_submitted(db, test) fields = mock_jira.update_issue_field.call_args[1]["fields"] - assert fields[jira_service.JIRA_FIELD_ATTACK_CONTAINED] == "Partial" + assert fields[jira_service.JIRA_FIELD_ATTACK_CONTAINED] == {"value": "Partial"} def test_update_test_fields_noop_when_not_configured(db): @@ -380,7 +380,7 @@ def test_auto_create_test_issue_maps_data_classification_to_real_jira_field( jira_service.auto_create_test_issue(db, test, actor, technique=technique) fields = mock_jira.issue_create.call_args.kwargs["fields"] - assert fields["customfield_11814"] == expected_jira_value + assert fields["customfield_11814"] == {"value": expected_jira_value} def _make_user(**overrides):