From cb7941e9bec690d336559cdecf0d92d82e5249fd Mon Sep 17 00:00:00 2001 From: pedrooot Date: Thu, 28 Aug 2025 14:10:26 +0200 Subject: [PATCH] feat(jira): add data to table and error handling --- .../lib/outputs/jira/exceptions/exceptions.py | 11 + prowler/lib/outputs/jira/jira.py | 1072 ++++++++++------- tests/lib/outputs/jira/jira_test.py | 713 ++++------- 3 files changed, 901 insertions(+), 895 deletions(-) diff --git a/prowler/lib/outputs/jira/exceptions/exceptions.py b/prowler/lib/outputs/jira/exceptions/exceptions.py index ed138a73a4..0f8aef1efd 100644 --- a/prowler/lib/outputs/jira/exceptions/exceptions.py +++ b/prowler/lib/outputs/jira/exceptions/exceptions.py @@ -90,6 +90,10 @@ class JiraBaseException(ProwlerException): "message": "Missing parameters on Jira Init function.", "remediation": "Please check the parameters and try again.", }, + (9021, "JiraRequiredCustomFieldsError"): { + "message": "Jira project requires custom fields that are not supported.", + "remediation": "Please configure the Jira project to not require custom fields, or use a different project.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -251,3 +255,10 @@ class JiraInvalidParameterError(JiraBaseException): super().__init__( 9020, file=file, original_exception=original_exception, message=message ) + + +class JiraRequiredCustomFieldsError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9021, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 616f97b820..ff3ecf41cc 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -28,6 +28,7 @@ from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraNoTokenError, JiraRefreshTokenError, JiraRefreshTokenResponseError, + JiraRequiredCustomFieldsError, JiraSendFindingsResponseError, JiraTestConnectionError, ) @@ -599,7 +600,7 @@ class Jira: return projects else: logger.error( - f"Failed to get projects: {response.status_code} - {response.json()}" + f"Failed to get projects: {response.status_code} - {response.text}" ) raise JiraGetProjectsResponseError( message="Failed to get projects from Jira", @@ -665,7 +666,7 @@ class Jira: issue_types = response.json()["projects"][0]["issuetypes"] return [issue_type["name"] for issue_type in issue_types] else: - response_error = f"Failed to get available issue types: {response.status_code} - {response.json()}" + response_error = f"Failed to get available issue types: {response.status_code} - {response.text}" logger.warning(response_error) raise JiraGetAvailableIssueTypesResponseError( message=response_error, file=os.path.basename(__file__) @@ -698,11 +699,34 @@ class Jira: if status == "MUTED": return "#FFA500" + @staticmethod + def get_severity_color(severity: str) -> str: + """Get the color from the severity + + Args: + - severity: The severity of the finding + + Returns: + - str: The color of the severity + """ + if severity == "critical": + return "#FF0000" + if severity == "high": + return "#FFA500" + if severity == "medium": + return "#FFFF00" + if severity == "low": + return "#008000" + if severity == "informational": + return "#0000FF" + return "#000000" # Default black color for unknown severities + @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, @@ -713,7 +737,589 @@ class Jira: 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, ) -> dict: + table_rows = [ + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Check Id", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": check_id, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Check Title", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": check_title, + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Severity", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": severity, + "marks": [ + {"type": "strong"}, + { + "type": "backgroundColor", + "attrs": { + "color": severity_color, + }, + }, + ], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Status", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": status, + "marks": [ + {"type": "strong"}, + { + "type": "textColor", + "attrs": {"color": status_color}, + }, + ], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Status Extended", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": status_extended, + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Provider", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": provider, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Region", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": region, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Resource UID", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": resource_uid, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Resource Name", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": resource_name, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Risk", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": risk, + } + ], + } + ], + }, + ], + }, + ] + + # Add resource tags row only if there are tags + if resource_tags: + tags_text = ", ".join([f"{k}={v}" for k, v in resource_tags.items()]) + table_rows.append( + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Resource Tags", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": tags_text, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + } + ) + + # Add compliance row only if there are compliance mappings + if compliance: + compliance_text = [] + for framework, requirements in compliance.items(): + if requirements: + requirements_str = ", ".join(requirements) + compliance_text.append(f"{framework}: {requirements_str}") + + if compliance_text: + table_rows.append( + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Compliance", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "; ".join(compliance_text), + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + } + ) + + # Add recommendation row + table_rows.append( + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Recommendation", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": recommendation_text + " ", + }, + { + "type": "text", + "text": recommendation_url, + "marks": [ + { + "type": "link", + "attrs": {"href": recommendation_url}, + } + ], + }, + ], + } + ], + }, + ], + } + ) + + # Add remediation code rows only if they have content + remediation_codes = [ + ("Native IaC", remediation_code_native_iac), + ("Terraform", remediation_code_terraform), + ("CLI", remediation_code_cli), + ("Other", remediation_code_other), + ] + + for code_type, code_value in remediation_codes: + if code_value and code_value.strip(): + table_rows.append( + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": {"colwidth": [1]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": f"Remediation {code_type}", + "marks": [{"type": "strong"}], + } + ], + } + ], + }, + { + "type": "tableCell", + "attrs": {"colwidth": [3]}, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": code_value, + "marks": [{"type": "code"}], + } + ], + } + ], + }, + ], + } + ) + return { "type": "doc", "version": 1, @@ -730,430 +1336,7 @@ class Jira: { "type": "table", "attrs": {"layout": "full-width"}, - "content": [ - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Check Id", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": check_id, - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Check Title", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": check_title, - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Severity", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": severity, - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Status", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": status, - "marks": [ - {"type": "strong"}, - { - "type": "textColor", - "attrs": { - "color": status_color - }, - }, - ], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Status Extended", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": status_extended, - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Provider", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": provider, - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Region", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": region, - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Resource UID", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": resource_uid, - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Resource Name", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": resource_name, - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Risk", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": risk, - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Recommendation", - "marks": [{"type": "strong"}], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": recommendation_text + " ", - }, - { - "type": "text", - "text": recommendation_url, - "marks": [ - { - "type": "link", - "attrs": { - "href": recommendation_url - }, - } - ], - }, - ], - } - ], - }, - ], - }, - ], + "content": table_rows, }, ], } @@ -1177,6 +1360,7 @@ class Jira: - 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 findings to Jira + - JiraRequiredCustomFieldsError: Jira project requires custom fields that are not supported """ try: access_token = self.get_access_token() @@ -1217,10 +1401,14 @@ class Jira: for finding in findings: status_color = self.get_color_from_status(finding.status.value) + severity_color = self.get_severity_color( + finding.metadata.Severity.value.lower() + ) adf_description = self.get_adf_description( check_id=finding.metadata.CheckID, check_title=finding.metadata.CheckTitle, severity=finding.metadata.Severity.value.upper(), + severity_color=severity_color, status=finding.status.value, status_color=status_color, status_extended=finding.status_extended, @@ -1231,6 +1419,12 @@ class Jira: risk=finding.metadata.Risk, recommendation_text=finding.metadata.Remediation.Recommendation.Text, recommendation_url=finding.metadata.Remediation.Recommendation.Url, + remediation_code_native_iac=finding.metadata.Remediation.Code.NativeIaC, + remediation_code_terraform=finding.metadata.Remediation.Code.Terraform, + remediation_code_cli=finding.metadata.Remediation.Code.CLI, + remediation_code_other=finding.metadata.Remediation.Code.Other, + resource_tags=finding.resource_tags, + compliance=finding.compliance, ) payload = { "fields": { @@ -1248,13 +1442,37 @@ class Jira: ) if response.status_code != 201: - response_error = f"Failed to send finding: {response.status_code} - {response.json()}" + response_json = response.json() + # 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() + ] + ) + raise JiraRequiredCustomFieldsError( + message=f"Jira project requires custom fields that are not supported: {custom_fields_formatted}", + file=os.path.basename(__file__), + ) + + response_error = f"Failed to send finding: {response.status_code} - {response_json}" logger.warning(response_error) raise JiraSendFindingsResponseError( message=response_error, file=os.path.basename(__file__) ) else: logger.info(f"Finding sent successfully: {response.json()}") + except JiraRequiredCustomFieldsError as custom_fields_error: + raise custom_fields_error except JiraRefreshTokenError as refresh_error: raise refresh_error except JiraRefreshTokenResponseError as response_error: diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index e4cc7fbc0b..608dfc47a5 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -15,6 +15,7 @@ from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraGetProjectsError, JiraNoProjectsError, JiraRefreshTokenError, + JiraRequiredCustomFieldsError, ) from prowler.lib.outputs.jira.jira import Jira @@ -587,6 +588,20 @@ class TestJiraIntegration: finding.metadata.Risk = "risk" finding.metadata.Remediation.Recommendation.Text = "remediation_text" finding.metadata.Remediation.Recommendation.Url = "remediation_url" + finding.metadata.Remediation.Code.NativeIaC = ( + 'resource "aws_s3_bucket" "example" { bucket = "my-bucket" }' + ) + finding.metadata.Remediation.Code.Terraform = ( + "terraform apply -target=aws_s3_bucket.example" + ) + finding.metadata.Remediation.Code.CLI = ( + "aws s3api create-bucket --bucket my-bucket --region us-east-1" + ) + finding.metadata.Remediation.Code.Other = ( + "Manual configuration required in AWS Console" + ) + finding.resource_tags = {"Environment": "Production", "Owner": "SecurityTeam"} + finding.compliance = {"CIS": ["2.1.1", "2.1.2"], "NIST": ["AC-3", "AC-6"]} self.jira_integration.cloud_id = "valid_cloud_id" @@ -594,483 +609,115 @@ class TestJiraIntegration: findings=[finding], project_key="TEST-1", issue_type="Bug" ) + mock_post.assert_called_once() + + call_args = mock_post.call_args + expected_url = ( "https://api.atlassian.com/ex/jira/valid_cloud_id/rest/api/3/issue" ) - expected_json = { - "fields": { - "project": {"key": "TEST-1"}, - "summary": "[Prowler] HIGH - CHECK-1 - resource-1", - "description": { - "type": "doc", - "version": 1, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Prowler has discovered the following finding:", - } - ], - }, - { - "type": "table", - "attrs": {"layout": "full-width"}, - "content": [ - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Check Id", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "CHECK-1", - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Check Title", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Check Title", - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Severity", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - {"type": "text", "text": "HIGH"} - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Status", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "FAIL", - "marks": [ - {"type": "strong"}, - { - "type": "textColor", - "attrs": { - "color": "#FF0000" - }, - }, - ], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Status Extended", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "status_extended", - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Provider", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "aws", - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Region", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "region", - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Resource UID", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "resource-1", - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Resource Name", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "resource_name", - "marks": [{"type": "code"}], - } - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Risk", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - {"type": "text", "text": "risk"} - ], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "attrs": {"colwidth": [1]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Recommendation", - "marks": [ - {"type": "strong"} - ], - } - ], - } - ], - }, - { - "type": "tableCell", - "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "remediation_text ", - }, - { - "type": "text", - "text": "remediation_url", - "marks": [ - { - "type": "link", - "attrs": { - "href": "remediation_url" - }, - } - ], - }, - ], - } - ], - }, - ], - }, - ], - }, - ], - }, - "issuetype": {"name": "Bug"}, - } - } expected_headers = { "Authorization": "Bearer valid_access_token", "Content-Type": "application/json", } - mock_post.assert_called_once_with( - expected_url, json=expected_json, headers=expected_headers + assert call_args[0][0] == expected_url + assert call_args.kwargs["headers"] == expected_headers + + payload = call_args.kwargs["json"] + assert payload["fields"]["project"]["key"] == "TEST-1" + assert payload["fields"]["summary"] == "[Prowler] HIGH - CHECK-1 - resource-1" + assert payload["fields"]["issuetype"]["name"] == "Bug" + assert payload["fields"]["description"]["type"] == "doc" + + description_content = payload["fields"]["description"]["content"] + + intro_paragraph = description_content[0] + assert intro_paragraph["type"] == "paragraph" + intro_text = intro_paragraph["content"][0] + assert intro_text["type"] == "text" + assert intro_text["text"] == "Prowler has discovered the following finding:" + + table = description_content[1] + assert table["type"] == "table" + + table_rows = table["content"] + row_texts = [] + for row in table_rows: + if row["type"] == "tableRow": + cells = row["content"] + if len(cells) == 2: + key_cell = cells[0]["content"][0]["content"][0]["text"] + + value_content = cells[1]["content"][0]["content"] + if len(value_content) > 1: + value_texts = [] + for content_item in value_content: + if content_item["type"] == "text": + value_texts.append(content_item["text"]) + value_cell = "".join(value_texts) + else: + value_cell = value_content[0]["text"] + + row_texts.append((key_cell, value_cell)) + + expected_keys = [ + "Check Id", + "Check Title", + "Severity", + "Status", + "Status Extended", + "Provider", + "Region", + "Resource UID", + "Resource Name", + "Risk", + "Resource Tags", + "Compliance", + "Recommendation", + "Remediation Native IaC", + "Remediation Terraform", + "Remediation CLI", + "Remediation Other", + ] + + actual_keys = [key for key, _ in row_texts] + + for expected_key in expected_keys: + assert expected_key in actual_keys, f"Missing row key: {expected_key}" + + row_dict = dict(row_texts) + assert row_dict["Check Id"] == "CHECK-1" + assert row_dict["Check Title"] == "Check Title" + assert row_dict["Status"] == "FAIL" + assert row_dict["Severity"] == "HIGH" + assert row_dict["Resource UID"] == "resource-1" + assert row_dict["Resource Name"] == "resource_name" + assert row_dict["Provider"] == "aws" + assert row_dict["Region"] == "region" + assert row_dict["Risk"] == "risk" + assert "remediation_text" in row_dict["Recommendation"] + assert "remediation_url" in row_dict["Recommendation"] + assert ( + row_dict["Remediation Native IaC"] + == 'resource "aws_s3_bucket" "example" { bucket = "my-bucket" }' ) + assert ( + row_dict["Remediation Terraform"] + == "terraform apply -target=aws_s3_bucket.example" + ) + assert ( + row_dict["Remediation CLI"] + == "aws s3api create-bucket --bucket my-bucket --region us-east-1" + ) + assert ( + row_dict["Remediation Other"] + == "Manual configuration required in AWS Console" + ) + assert "Environment=Production" in row_dict["Resource Tags"] + assert "Owner=SecurityTeam" in row_dict["Resource Tags"] + assert "CIS: 2.1.1, 2.1.2" in row_dict["Compliance"] + assert "NIST: AC-3, AC-6" in row_dict["Compliance"] @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( @@ -1084,6 +731,7 @@ class TestJiraIntegration: # To disable vulture mock_get_available_issue_types = mock_get_available_issue_types mock_get_access_token = mock_get_access_token + mock_post = mock_post with pytest.raises(JiraCreateIssueError): self.jira_integration.send_findings( @@ -1119,6 +767,120 @@ class TestJiraIntegration: findings=[finding], project_key="TEST", issue_type="Bug" ) + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] + ) + @patch.object(Jira, "get_projects", return_value={"TEST-1": "Test Project"}) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_send_findings_custom_fields_required_error( + self, + mock_post, + mock_get_projects, + mock_get_available_issue_types, + mock_get_access_token, + ): + """Test that send_findings raises JiraRequiredCustomFieldsError when custom fields are required.""" + # To disable vulture + mock_get_available_issue_types = mock_get_available_issue_types + mock_get_access_token = mock_get_access_token + mock_get_projects = mock_get_projects + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = { + "errorMessages": [], + "errors": { + "customfield_10148": "Team is required.", + "customfield_10088": "Component is required.", + }, + } + mock_post.return_value = mock_response + + finding = MagicMock() + finding.status.value = "FAIL" + finding.status_extended = "status_extended" + finding.metadata.Severity.value = "HIGH" + finding.metadata.CheckID = "CHECK-1" + finding.metadata.CheckTitle = "Check Title" + finding.resource_uid = "resource-1" + finding.resource_name = "resource_name" + finding.metadata.Provider = "aws" + finding.region = "region" + finding.metadata.Risk = "risk" + finding.metadata.Remediation.Recommendation.Text = "remediation_text" + finding.metadata.Remediation.Recommendation.Url = "remediation_url" + finding.metadata.Remediation.Code.NativeIaC = "" + finding.metadata.Remediation.Code.Terraform = "" + finding.metadata.Remediation.Code.CLI = "" + finding.metadata.Remediation.Code.Other = "" + finding.resource_tags = {} + finding.compliance = {} + + self.jira_integration.cloud_id = "valid_cloud_id" + + with pytest.raises(JiraRequiredCustomFieldsError): + self.jira_integration.send_findings( + findings=[finding], project_key="TEST-1", issue_type="Bug" + ) + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] + ) + @patch.object(Jira, "get_projects", return_value={"TEST-1": "Test Project"}) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_send_findings_non_custom_field_400_error( + self, + mock_post, + mock_get_projects, + mock_get_available_issue_types, + mock_get_access_token, + ): + """Test that send_findings raises JiraCreateIssueError for non-custom field 400 errors.""" + # To disable vulture + mock_get_available_issue_types = mock_get_available_issue_types + mock_get_access_token = mock_get_access_token + mock_get_projects = mock_get_projects + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = { + "errorMessages": [], + "errors": { + "summary": "Summary is required.", + "description": "Description is required.", + }, + } + mock_post.return_value = mock_response + + finding = MagicMock() + finding.status.value = "FAIL" + finding.status_extended = "status_extended" + finding.metadata.Severity.value = "HIGH" + finding.metadata.CheckID = "CHECK-1" + finding.metadata.CheckTitle = "Check Title" + finding.resource_uid = "resource-1" + finding.resource_name = "resource_name" + finding.metadata.Provider = "aws" + finding.region = "region" + finding.metadata.Risk = "risk" + finding.metadata.Remediation.Recommendation.Text = "remediation_text" + finding.metadata.Remediation.Recommendation.Url = "remediation_url" + finding.metadata.Remediation.Code.NativeIaC = "" + finding.metadata.Remediation.Code.Terraform = "" + finding.metadata.Remediation.Code.CLI = "" + finding.metadata.Remediation.Code.Other = "" + finding.resource_tags = {} + finding.compliance = {} + + self.jira_integration.cloud_id = "valid_cloud_id" + + with pytest.raises(JiraCreateIssueError): + self.jira_integration.send_findings( + findings=[finding], project_key="TEST-1", issue_type="Bug" + ) + @pytest.mark.parametrize( "status, expected_color", [("FAIL", "#FF0000"), ("PASS", "#008000"), ("MUTED", "#FFA500")], @@ -1126,3 +888,18 @@ class TestJiraIntegration: def test_get_color_from_status(self, status, expected_color): """Test that get_color_from_status returns the correct color for a status.""" assert self.jira_integration.get_color_from_status(status) == expected_color + + @pytest.mark.parametrize( + "severity, expected_color", + [ + ("critical", "#FF0000"), + ("high", "#FFA500"), + ("medium", "#FFFF00"), + ("low", "#008000"), + ("informational", "#0000FF"), + ("unknown", "#000000"), + ], + ) + def test_get_severity_color(self, severity, expected_color): + """Test that get_severity_color returns the correct color for a severity.""" + assert self.jira_integration.get_severity_color(severity) == expected_color