From 0b7055e9832f4bde9592aed2b2835e0e20267714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Fri, 5 Sep 2025 12:25:53 +0200 Subject: [PATCH] feat(jira): add send_finding method with specific finding fields (#8648) --- prowler/CHANGELOG.md | 1 + prowler/lib/outputs/jira/jira.py | 265 +++++++++++++++++++++++++--- tests/lib/outputs/jira/jira_test.py | 125 +++++++++++++ 3 files changed, 368 insertions(+), 23 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 6cb7a2a83e..b9783956d2 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -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 diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 6b3537782f..2491da6c31 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -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 diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index e9df52a950..9747bfe50f 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -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()