mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
feat(jira): add send_finding method with specific finding fields (#8648)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user