feat(jira): add send_finding method with specific finding fields (#8648)

This commit is contained in:
Pedro Martín
2025-09-05 12:25:53 +02:00
committed by GitHub
parent ae53b76d78
commit 0b7055e983
3 changed files with 368 additions and 23 deletions
+1
View File
@@ -12,6 +12,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `AdditionalUrls` field in CheckMetadata [(#8590)](https://github.com/prowler-cloud/prowler/pull/8590)
- Support color for MANUAL finidngs in Jira tickets [(#8642)](https://github.com/prowler-cloud/prowler/pull/8642)
- `--excluded-checks-file` flag [(#8301)](https://github.com/prowler-cloud/prowler/pull/8301)
- Send finding in Jira integration with the needed values [(#8648)](https://github.com/prowler-cloud/prowler/pull/8648)
### Changed
+242 -23
View File
@@ -818,29 +818,30 @@ class Jira:
@staticmethod
def get_adf_description(
check_id: str = None,
check_title: str = None,
severity: str = None,
severity_color: str = None,
status: str = None,
status_color: str = None,
status_extended: str = None,
provider: str = None,
region: str = None,
resource_uid: str = None,
resource_name: str = None,
risk: str = None,
recommendation_text: str = None,
recommendation_url: str = None,
remediation_code_native_iac: str = None,
remediation_code_terraform: str = None,
remediation_code_cli: str = None,
remediation_code_other: str = None,
resource_tags: dict = None,
compliance: dict = None,
finding_url: str = None,
tenant_info: str = None,
check_id: str = "",
check_title: str = "",
severity: str = "",
severity_color: str = "",
status: str = "",
status_color: str = "",
status_extended: str = "",
provider: str = "",
region: str = "",
resource_uid: str = "",
resource_name: str = "",
risk: str = "",
recommendation_text: str = "",
recommendation_url: str = "",
remediation_code_native_iac: str = "",
remediation_code_terraform: str = "",
remediation_code_cli: str = "",
remediation_code_other: str = "",
resource_tags: dict = "",
compliance: dict = "",
finding_url: str = "",
tenant_info: str = "",
) -> dict:
table_rows = [
{
"type": "tableRow",
@@ -1618,10 +1619,21 @@ class Jira:
finding_url=finding_url,
tenant_info=tenant_info,
)
summary_parts = ["[Prowler]"]
if finding.metadata.Severity.value:
summary_parts.append(finding.metadata.Severity.value.upper())
if finding.metadata.CheckID:
summary_parts.append(finding.metadata.CheckID)
if finding.resource_uid:
summary_parts.append(finding.resource_uid)
summary = " - ".join(summary_parts[1:])
summary = f"{summary_parts[0]} {summary}"
payload = {
"fields": {
"project": {"key": project_key},
"summary": f"[Prowler] {finding.metadata.Severity.value.upper()} - {finding.metadata.CheckID} - {finding.resource_uid}",
"summary": summary,
"description": adf_description,
"issuetype": {"name": issue_type},
}
@@ -1691,3 +1703,210 @@ class Jira:
message="Failed to create an issue in Jira",
file=os.path.basename(__file__),
)
def send_finding(
self,
check_id: str = "",
check_title: str = "",
severity: str = "",
status: str = "",
status_extended: str = "",
provider: str = "",
region: str = "",
resource_uid: str = "",
resource_name: str = "",
risk: str = "",
recommendation_text: str = "",
recommendation_url: str = "",
remediation_code_native_iac: str = "",
remediation_code_terraform: str = "",
remediation_code_cli: str = "",
remediation_code_other: str = "",
resource_tags: dict = "",
compliance: dict = "",
project_key: str = "",
issue_type: str = "",
issue_labels: list[str] = "",
finding_url: str = "",
tenant_info: str = "",
) -> bool:
"""
Send the finding to Jira
Args:
- check_id: The check ID
- check_title: The check title
- severity: The severity
- status: The status
- status_extended: The status extended
- provider: The provider
- region: The region
- resource_uid: The resource UID
- resource_name: The resource name
- risk: The risk
- recommendation_text: The recommendation text
- recommendation_url: The recommendation URL
- remediation_code_native_iac: The remediation code native IAC
- remediation_code_terraform: The remediation code terraform
- remediation_code_cli: The remediation code CLI
- remediation_code_other: The remediation code other
- resource_tags: The resource tags
- compliance: The compliance
- project_key: The project key
- issue_type: The issue type
- issue_labels: The issue labels
- finding_url: The finding URL
- tenant_info: The tenant info
Raises:
- JiraRefreshTokenError: Failed to refresh the access token
- JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200
- JiraCreateIssueError: Failed to create an issue in Jira
- JiraSendFindingsResponseError: Failed to send the finding to Jira
- JiraRequiredCustomFieldsError: Jira project requires custom fields that are not supported
Returns:
- True if the finding was sent successfully
- False if the finding was not sent successfully
"""
try:
access_token = self.get_access_token()
if not access_token:
raise JiraNoTokenError(
message="No token was found",
file=os.path.basename(__file__),
)
projects = self.get_projects()
if project_key not in projects:
logger.error("The project key is invalid")
raise JiraInvalidProjectKeyError(
message="The project key is invalid",
file=os.path.basename(__file__),
)
available_issue_types = self.get_available_issue_types(project_key)
if issue_type not in available_issue_types:
logger.error("The issue type is invalid")
raise JiraInvalidIssueTypeError(
message="The issue type is invalid", file=os.path.basename(__file__)
)
if self._using_basic_auth:
headers = {
"Authorization": f"Basic {access_token}",
"Content-Type": "application/json",
}
else:
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
status_color = self.get_color_from_status(status)
severity_color = self.get_severity_color(severity.lower())
adf_description = self.get_adf_description(
check_id=check_id,
check_title=check_title,
severity=severity.upper(),
severity_color=severity_color,
status=status,
status_color=status_color,
status_extended=status_extended,
provider=provider,
region=region,
resource_uid=resource_uid,
resource_name=resource_name,
risk=risk,
recommendation_text=recommendation_text,
recommendation_url=recommendation_url,
remediation_code_native_iac=remediation_code_native_iac,
remediation_code_terraform=remediation_code_terraform,
remediation_code_cli=remediation_code_cli,
remediation_code_other=remediation_code_other,
resource_tags=resource_tags,
compliance=compliance,
finding_url=finding_url,
tenant_info=tenant_info,
)
summary_parts = ["[Prowler]"]
if severity:
summary_parts.append(severity.upper())
if check_id:
summary_parts.append(check_id)
if resource_uid:
summary_parts.append(resource_uid)
summary = " - ".join(summary_parts[1:])
summary = f"{summary_parts[0]} {summary}"
payload = {
"fields": {
"project": {"key": project_key},
"summary": summary,
"description": adf_description,
"issuetype": {"name": issue_type},
}
}
if issue_labels:
payload["fields"]["labels"] = issue_labels
response = requests.post(
f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue",
json=payload,
headers=headers,
)
if response.status_code != 201:
try:
response_json = response.json()
except (ValueError, requests.exceptions.JSONDecodeError):
response_error = f"Failed to send finding: {response.status_code} - {response.text}"
logger.error(response_error)
return False
# Check if the error is due to required custom fields
if response.status_code == 400 and "errors" in response_json:
errors = response_json.get("errors", {})
# Look for custom field errors (fields starting with "customfield_")
custom_field_errors = {
k: v for k, v in errors.items() if k.startswith("customfield_")
}
if custom_field_errors:
custom_fields_formatted = ", ".join(
[f"'{k}': '{v}'" for k, v in custom_field_errors.items()]
)
logger.error(
f"Jira project requires custom fields that are not supported: {custom_fields_formatted}"
)
return False
response_error = (
f"Failed to send finding: {response.status_code} - {response_json}"
)
logger.error(response_error)
return False
else:
try:
response_json = response.json()
logger.info(f"Finding sent successfully: {response_json}")
except (ValueError, requests.exceptions.JSONDecodeError):
logger.info(
f"Finding sent successfully: Status {response.status_code}"
)
return True
except JiraRequiredCustomFieldsError as custom_fields_error:
logger.error(f"Custom fields error: {custom_fields_error}")
return False
except JiraRefreshTokenError as refresh_error:
logger.error(f"Token refresh error: {refresh_error}")
return False
except JiraRefreshTokenResponseError as response_error:
logger.error(f"Token response error: {response_error}")
return False
except Exception as e:
logger.error(f"Failed to send finding: {e}")
return False
+125
View File
@@ -1293,3 +1293,128 @@ class TestJiraIntegration:
}
assert projects_and_issue_types == expected_result
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
)
@patch.object(Jira, "get_projects", return_value={"TEST": {"name": "Test Project"}})
@patch.object(Jira, "get_available_issue_types", return_value=["Bug"])
@patch("prowler.lib.outputs.jira.jira.requests.post")
def test_send_finding_successful(
self,
mock_post,
mock_get_issue_types,
mock_get_projects,
mock_cloud_id,
mock_get_access_token,
):
"""Test that send_finding returns True when the finding is sent successfully."""
# To disable vulture
mock_cloud_id = mock_cloud_id
mock_get_access_token = mock_get_access_token
mock_get_projects = mock_get_projects
mock_get_issue_types = mock_get_issue_types
# Mock successful response
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = {"id": "ISSUE-123", "key": "TEST-123"}
mock_post.return_value = mock_response
result = self.jira_integration.send_finding(
check_id="test-check",
check_title="Test Finding",
severity="High",
status="FAIL",
project_key="TEST",
issue_type="Bug",
)
assert result is True
mock_post.assert_called_once()
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
)
@patch.object(Jira, "get_projects", return_value={"TEST": {"name": "Test Project"}})
@patch.object(Jira, "get_available_issue_types", return_value=["Bug"])
@patch("prowler.lib.outputs.jira.jira.requests.post")
def test_send_finding_failure(
self,
mock_post,
mock_get_issue_types,
mock_get_projects,
mock_cloud_id,
mock_get_access_token,
):
"""Test that send_finding returns False when the request fails."""
# To disable vulture
mock_cloud_id = mock_cloud_id
mock_get_access_token = mock_get_access_token
mock_get_projects = mock_get_projects
mock_get_issue_types = mock_get_issue_types
# Mock failed response
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.json.return_value = {"errors": {"summary": "Required field"}}
mock_post.return_value = mock_response
result = self.jira_integration.send_finding(
check_id="test-check",
check_title="Test Finding",
severity="High",
status="FAIL",
project_key="TEST",
issue_type="Bug",
)
assert result is False
mock_post.assert_called_once()
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
)
@patch.object(Jira, "get_projects", return_value={"TEST": {"name": "Test Project"}})
@patch.object(Jira, "get_available_issue_types", return_value=["Bug"])
@patch("prowler.lib.outputs.jira.jira.requests.post")
def test_send_finding_custom_fields_error(
self,
mock_post,
mock_get_issue_types,
mock_get_projects,
mock_cloud_id,
mock_get_access_token,
):
"""Test that send_finding returns False when custom fields cause an error."""
# To disable vulture
mock_cloud_id = mock_cloud_id
mock_get_access_token = mock_get_access_token
mock_get_projects = mock_get_projects
mock_get_issue_types = mock_get_issue_types
# Mock response with custom fields error
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.json.return_value = {
"errors": {
"customfield_10001": "This custom field is required",
"customfield_10002": "Invalid value for custom field",
}
}
mock_post.return_value = mock_response
result = self.jira_integration.send_finding(
check_id="test-check",
check_title="Test Finding",
severity="High",
status="FAIL",
project_key="TEST",
issue_type="Bug",
)
assert result is False
mock_post.assert_called_once()