fix(jira): wrap select-field values in {value: ...} for Jira REST API
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Jira Cloud's REST API rejects a bare string for select-type custom fields with 'Specify a valid id or name for <field>' — 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.
This commit is contained in:
@@ -706,12 +706,12 @@ def auto_create_test_issue(
|
|||||||
# customfield_10309 = Proof of Concept field (required by team's Jira config)
|
# customfield_10309 = Proof of Concept field (required by team's Jira config)
|
||||||
"customfield_10309": f"{{code}}{poc}{{code}}",
|
"customfield_10309": f"{{code}}{poc}{{code}}",
|
||||||
JIRA_FIELD_TTP: mitre_id,
|
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))
|
data_sensitivity = _DATA_CLASSIFICATION_TO_JIRA.get(_enum_value(test.data_classification))
|
||||||
if data_sensitivity:
|
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
|
# Inherit campaign start date if available, otherwise use today
|
||||||
from datetime import date as _date
|
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)
|
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 <field>") — it must be posted as
|
||||||
|
``{"value": ...}``.
|
||||||
|
"""
|
||||||
|
return {"value": value}
|
||||||
|
|
||||||
|
|
||||||
def _compute_hours(start: Optional[datetime], end: Optional[datetime]) -> Optional[float]:
|
def _compute_hours(start: Optional[datetime], end: Optional[datetime]) -> Optional[float]:
|
||||||
"""Return the elapsed time between two timestamps in hours, or None if unavailable."""
|
"""Return the elapsed time between two timestamps in hours, or None if unavailable."""
|
||||||
if not start or not end:
|
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")}
|
fields: dict = {JIRA_FIELD_RT_END_DATE: datetime.utcnow().strftime("%Y-%m-%d")}
|
||||||
attack_success = _enum_value(test.attack_success)
|
attack_success = _enum_value(test.attack_success)
|
||||||
if attack_success in _ATTACK_SUCCESS_TO_JIRA:
|
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)
|
_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)
|
detection_result = _enum_value(test.detection_result)
|
||||||
if detection_result in _DETECTION_TO_JIRA:
|
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)
|
containment_result = _enum_value(test.containment_result)
|
||||||
if containment_result in _CONTAINMENT_TO_JIRA:
|
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)
|
time_to_detect = _compute_hours(test.execution_end_time, test.detection_time)
|
||||||
if time_to_detect is not None:
|
if time_to_detect is not None:
|
||||||
|
|||||||
@@ -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"]
|
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_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)
|
@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)
|
jira_service.push_bt_submitted(db, test)
|
||||||
|
|
||||||
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
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_DETECT] == 2.0
|
||||||
assert fields[jira_service.JIRA_FIELD_TIME_TO_CONTAIN] == 1.5
|
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)
|
jira_service.push_bt_submitted(db, test)
|
||||||
|
|
||||||
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
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):
|
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)
|
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
|
||||||
|
|
||||||
fields = mock_jira.issue_create.call_args.kwargs["fields"]
|
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):
|
def _make_user(**overrides):
|
||||||
|
|||||||
Reference in New Issue
Block a user