From c88f74503826102bb14bf25e1233144146987f98 Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:47:12 +0200 Subject: [PATCH] feat(jira): return the created issue, sanitize labels and add bulk status lookup (#12539) Co-authored-by: Josema Camacho --- .../jira-finding-labels-url.added.md | 1 + .../jira-get-issues-status.added.md | 1 + .../lib/outputs/jira/exceptions/exceptions.py | 22 + prowler/lib/outputs/jira/jira.py | 880 ++++++++++++-- prowler/lib/outputs/jira/models.py | 112 ++ tests/lib/outputs/jira/jira_test.py | 1069 +++++++++++++++-- 6 files changed, 1839 insertions(+), 246 deletions(-) create mode 100644 prowler/changelog.d/jira-finding-labels-url.added.md create mode 100644 prowler/changelog.d/jira-get-issues-status.added.md create mode 100644 prowler/lib/outputs/jira/models.py diff --git a/prowler/changelog.d/jira-finding-labels-url.added.md b/prowler/changelog.d/jira-finding-labels-url.added.md new file mode 100644 index 0000000000..16266aba99 --- /dev/null +++ b/prowler/changelog.d/jira-finding-labels-url.added.md @@ -0,0 +1 @@ +`Jira.send_finding()` returns typed creation outcomes with the issue key, immutable ID and browse URL, and supports length-safe finding and delivery-attempt labels plus marker lookup for uncertain deliveries diff --git a/prowler/changelog.d/jira-get-issues-status.added.md b/prowler/changelog.d/jira-get-issues-status.added.md new file mode 100644 index 0000000000..4926418e7a --- /dev/null +++ b/prowler/changelog.d/jira-get-issues-status.added.md @@ -0,0 +1 @@ +`Jira.get_issues_status()` resolves issue references by immutable ID in batches and returns explicit open, done, moved, missing, forbidden or unknown outcomes without treating missing issues as deleted diff --git a/prowler/lib/outputs/jira/exceptions/exceptions.py b/prowler/lib/outputs/jira/exceptions/exceptions.py index 0f8aef1efd..adfbf2c183 100644 --- a/prowler/lib/outputs/jira/exceptions/exceptions.py +++ b/prowler/lib/outputs/jira/exceptions/exceptions.py @@ -94,6 +94,14 @@ class JiraBaseException(ProwlerException): "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.", }, + (9022, "JiraGetIssuesStatusError"): { + "message": "Failed to get the issues status from Jira.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (9023, "JiraGetIssuesStatusResponseError"): { + "message": "Failed to get the issues status from Jira, response code did not match 200.", + "remediation": "Please check the connection settings and permissions and try again.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -262,3 +270,17 @@ class JiraRequiredCustomFieldsError(JiraBaseException): super().__init__( 9021, file=file, original_exception=original_exception, message=message ) + + +class JiraGetIssuesStatusError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9022, file=file, original_exception=original_exception, message=message + ) + + +class JiraGetIssuesStatusResponseError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9023, 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 9fb4c7d6ad..1ddeb8cf3f 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -1,6 +1,8 @@ import base64 +import hashlib import os import re +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime, timedelta from typing import Dict, List, Optional @@ -36,6 +38,16 @@ from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraSendFindingsResponseError, JiraTestConnectionError, ) +from prowler.lib.outputs.jira.models import ( + JiraCreationOutcome, + JiraCreationResult, + JiraIssueLookupOutcome, + JiraIssueReference, + JiraIssueSearchMatch, + JiraIssueSearchOutcome, + JiraIssueSearchResult, + JiraIssueStatusResult, +) from prowler.providers.common.models import Connection ATLASSIAN_SITE_NAME_REGEX = re.compile( @@ -368,6 +380,7 @@ class Jira: _refresh_token: str = None _expiration_date: int = None _cloud_id: str = None + _site_url: str = None _scopes: list[str] = None AUTH_URL = "https://auth.atlassian.com/authorize" PARAMS_TEMPLATE = { @@ -382,6 +395,12 @@ class Jira: TOKEN_URL = "https://auth.atlassian.com/oauth/token" API_TOKEN_URL = "https://api.atlassian.com/oauth/token/accessible-resources" REQUEST_TIMEOUT = 90 + ISSUE_STATUS_BATCH_SIZE = 100 + LABEL_MAX_LENGTH = 255 + FINDING_LABEL_PREFIX = "prowler-finding" + DELIVERY_ATTEMPT_LABEL_PREFIX = "prowler-attempt" + ISSUE_KEY_REGEX = re.compile(r"^[A-Z][A-Z0-9_]*-[0-9]+$") + ISSUE_ID_REGEX = re.compile(r"^[0-9]+$") HEADER_TEMPLATE = { "Content-Type": "application/json", "X-Force-Accept-Language": "true", @@ -404,6 +423,7 @@ class Jira: self._api_token = api_token self._domain = domain self._scopes = ["read:jira-user", "read:jira-work", "write:jira-work"] + self._validated_destinations: set[tuple[str, str]] = set() # If the client mail, API token and site name are present, use basic auth if user_mail and api_token and domain: self._using_basic_auth = True @@ -432,6 +452,255 @@ class Jira: """ return " ".join(summary.split())[:255] + @property + def site_url(self) -> Optional[str]: + """Base URL of the Jira site, used to build issue browse links. + + Basic auth derives it from the configured site name; OAuth captures it + from the accessible-resources response when resolving the cloud id. + """ + if self._using_basic_auth and self._domain: + return f"https://{self._domain}.atlassian.net" + return self._site_url + + def get_issue_url(self, issue_key: str) -> Optional[str]: + """Build the browse URL for an issue key, or None if the site is unknown.""" + site_url = self.site_url + if not site_url or not issue_key: + return None + return f"{site_url.rstrip('/')}/browse/{issue_key}" + + @staticmethod + def sanitize_label(label: Optional[str]) -> str: + """Make a value safe to use as a Jira label. + + Jira rejects labels containing whitespace and longer than 255 + characters. The transformation is deterministic so the same input always + yields the same label: whitespace runs become a single underscore, + control characters are dropped and the result is truncated. + + Args: + label: Raw label text. + + Returns: + The sanitized label, or an empty string if nothing usable remains. + """ + if not label: + return "" + cleaned = "".join(ch for ch in str(label) if ch.isprintable() or ch.isspace()) + collapsed = re.sub(r"_+", "_", "_".join(cleaned.split())) + return collapsed.strip("_")[: Jira.LABEL_MAX_LENGTH] + + @classmethod + def sanitize_labels(cls, labels: Optional[list[str]]) -> list[str]: + """Sanitize a list of labels, dropping empties and duplicates (order kept).""" + result: list[str] = [] + for label in labels or []: + sanitized = cls.sanitize_label(label) + if sanitized and sanitized not in result: + result.append(sanitized) + return result + + @classmethod + def _build_prefixed_label(cls, prefix: str, raw_value: Optional[str]) -> str: + """Build a deterministic Jira label with collision-safe truncation.""" + if not raw_value: + return "" + raw_value = str(raw_value) + readable_value = cls.sanitize_label(raw_value) + if not readable_value: + return "" + complete_label = f"{prefix}-{readable_value}" + if len(complete_label) <= cls.LABEL_MAX_LENGTH: + return complete_label + + digest = hashlib.sha256(raw_value.encode("utf-8")).hexdigest() + readable_length = cls.LABEL_MAX_LENGTH - len(prefix) - len(digest) - 2 + readable_prefix = readable_value[:readable_length].rstrip("_") + return f"{prefix}-{readable_prefix}-{digest}" + + @classmethod + def build_finding_label(cls, finding_uid: Optional[str]) -> str: + """Build the stable label used to identify a Prowler finding.""" + return cls._build_prefixed_label(cls.FINDING_LABEL_PREFIX, finding_uid) + + @classmethod + def build_delivery_attempt_label(cls, marker: Optional[str]) -> str: + """Build the stable label used to reconcile a delivery attempt.""" + return cls._build_prefixed_label(cls.DELIVERY_ATTEMPT_LABEL_PREFIX, marker) + + @staticmethod + def _retry_after(response: requests.Response) -> Optional[str]: + """Return Jira's Retry-After header without parsing or normalizing it.""" + headers = getattr(response, "headers", None) + if not isinstance(headers, Mapping): + return None + retry_after = headers.get("Retry-After") + return str(retry_after) if retry_after is not None else None + + @staticmethod + def _response_json(response: requests.Response) -> object: + """Decode a Jira response, returning None for invalid JSON.""" + try: + return response.json() + except (ValueError, requests.exceptions.JSONDecodeError): + return None + + def _issue_identity( + self, issue: object + ) -> tuple[Optional[str], Optional[str], Optional[str]]: + """Return individually validated Jira issue identity fields.""" + issue = issue if isinstance(issue, dict) else {} + issue_id = issue.get("id") + issue_key = issue.get("key") + if not isinstance(issue_id, str) or not self.ISSUE_ID_REGEX.fullmatch(issue_id): + issue_id = None + if not isinstance(issue_key, str) or not self.ISSUE_KEY_REGEX.fullmatch( + issue_key + ): + issue_key = None + return issue_id, issue_key, self.get_issue_url(issue_key) if issue_key else None + + def _classify_creation_response( + self, + response: requests.Response, + delivery_marker: Optional[str] = None, + ) -> JiraCreationResult: + """Classify Jira's create-issue response without inferring delivery.""" + status_code = response.status_code + retry_after = self._retry_after(response) + response_json = self._response_json(response) + response_details = { + "delivery_marker": delivery_marker, + "http_status": status_code, + "retry_after": retry_after, + } + + if status_code == 201: + if not isinstance(response_json, dict): + return JiraCreationResult( + outcome=JiraCreationOutcome.UNCERTAIN, + error_code="malformed_success_response", + error_message="Jira returned an invalid successful creation response.", + **response_details, + ) + + issue_id, issue_key, issue_url = self._issue_identity(response_json) + if not issue_key or not issue_id or not issue_url: + missing_fields = [ + field + for field, value in zip( + ("key", "id", "url"), (issue_key, issue_id, issue_url) + ) + if not value + ] + return JiraCreationResult( + outcome=JiraCreationOutcome.UNCERTAIN, + issue_key=issue_key, + issue_id=issue_id, + issue_url=issue_url, + error_code="incomplete_success_response", + error_message=( + "Jira confirmed creation without a usable " + f"{', '.join(missing_fields)}." + ), + **response_details, + ) + return JiraCreationResult( + outcome=JiraCreationOutcome.CONFIRMED_SUCCESS, + issue_key=issue_key, + issue_id=issue_id, + issue_url=issue_url, + **response_details, + ) + + error_message = _format_jira_issue_creation_error(response_json, status_code) + if status_code == 429: + outcome = JiraCreationOutcome.RETRYABLE_FAILURE + error_code = "rate_limited" + elif status_code == 408: + outcome = JiraCreationOutcome.UNCERTAIN + error_code = "jira_http_408" + elif 400 <= status_code < 500: + outcome = JiraCreationOutcome.CONFIRMED_REJECTION + error_code = f"jira_http_{status_code}" + else: + outcome = JiraCreationOutcome.UNCERTAIN + error_code = f"jira_http_{status_code}" + return JiraCreationResult( + outcome=outcome, + error_code=error_code, + error_message=error_message, + **response_details, + ) + + @staticmethod + def _creation_transport_result( + exception: requests.exceptions.RequestException, + delivery_marker: Optional[str], + ) -> JiraCreationResult: + """Classify create-issue transport failures by delivery certainty.""" + if isinstance(exception, requests.exceptions.ConnectTimeout): + outcome = JiraCreationOutcome.RETRYABLE_FAILURE + error_code = "connect_timeout" + error_message = "Jira could not be reached before the request was sent." + else: + outcome = JiraCreationOutcome.UNCERTAIN + error_code = "ambiguous_transport_failure" + error_message = "Jira did not confirm whether it created the issue." + return JiraCreationResult( + outcome=outcome, + delivery_marker=delivery_marker, + error_code=error_code, + error_message=error_message, + ) + + @staticmethod + def _destination_validation_result( + error: Exception, + delivery_marker: Optional[str], + ) -> JiraCreationResult: + """Classify a catalog failure that happened before issue creation.""" + errors = (error, getattr(error, "original_exception", None)) + no_projects = any(isinstance(item, JiraNoProjectsError) for item in errors) + http_status = next( + (item.http_status for item in errors if hasattr(item, "http_status")), None + ) + retry_after = next( + (item.retry_after for item in errors if hasattr(item, "retry_after")), None + ) + + if no_projects: + outcome = JiraCreationOutcome.CONFIRMED_REJECTION + error_code = "invalid_project" + error_message = "The Jira project key is invalid." + elif http_status is not None and 400 <= http_status < 500: + if http_status in (408, 429): + outcome = JiraCreationOutcome.RETRYABLE_FAILURE + error_code = "destination_temporarily_unavailable" + else: + outcome = JiraCreationOutcome.CONFIRMED_REJECTION + error_code = ( + "invalid_credentials" + if http_status in (401, 403) + else "destination_rejected" + ) + error_message = "The Jira destination could not be validated." + else: + outcome = JiraCreationOutcome.RETRYABLE_FAILURE + error_code = "destination_validation_failed" + error_message = ( + "The Jira destination could not be validated before sending." + ) + return JiraCreationResult( + outcome=outcome, + delivery_marker=delivery_marker, + http_status=http_status, + retry_after=retry_after, + error_code=error_code, + error_message=error_message, + ) + @staticmethod def _build_code_block_content(code_value: str) -> Optional[Dict]: if not code_value: @@ -707,6 +976,7 @@ class Jira: if response.status_code == 200: resources = response.json() if len(resources) > 0: + self._site_url = resources[0].get("url") return resources[0].get("id") else: error_message = ( @@ -939,7 +1209,10 @@ class Jira: access_token = self.get_access_token() if not access_token: - return ValueError("Failed to get access token") + raise JiraNoTokenError( + message="No token was found", + file=os.path.basename(__file__), + ) headers = self.get_headers(access_token) @@ -965,10 +1238,13 @@ class Jira: logger.error( f"Failed to get projects: {response.status_code} - {response.text}" ) - raise JiraGetProjectsResponseError( + response_error = JiraGetProjectsResponseError( message="Failed to get projects from Jira", file=os.path.basename(__file__), ) + response_error.http_status = response.status_code + response_error.retry_after = self._retry_after(response) + raise response_error except JiraNoProjectsError as no_projects_error: raise no_projects_error except JiraRefreshTokenError as refresh_error: @@ -980,6 +1256,7 @@ class Jira: raise JiraGetProjectsError( message="Failed to get projects from Jira", file=os.path.basename(__file__), + original_exception=e, ) def get_available_issue_types(self, project_key: str = None) -> list[str]: @@ -1004,7 +1281,7 @@ class Jira: access_token = self.get_access_token() if not access_token: - return JiraNoTokenError( + raise JiraNoTokenError( message="No token was found", file=os.path.basename(__file__), ) @@ -1029,9 +1306,12 @@ class Jira: else: response_error = f"Failed to get available issue types: {response.status_code} - {response.text}" logger.error(response_error) - raise JiraGetAvailableIssueTypesResponseError( + response_error = JiraGetAvailableIssueTypesResponseError( message=response_error, file=os.path.basename(__file__) ) + response_error.http_status = response.status_code + response_error.retry_after = self._retry_after(response) + raise response_error except JiraRefreshTokenError as refresh_error: raise refresh_error except JiraRefreshTokenResponseError as response_error: @@ -1041,6 +1321,7 @@ class Jira: raise JiraGetAvailableIssueTypesError( message="Failed to get available issue types", file=os.path.basename(__file__), + original_exception=e, ) def get_metadata(self) -> dict: @@ -1290,7 +1571,6 @@ class Jira: finding_url: str = "", tenant_info: str = "", ) -> dict: - # ADF forbids empty text nodes, so Jira rejects them with 400 INVALID_INPUT. def _safe(value: str) -> str: return value if (value and value.strip()) else "-" @@ -2195,12 +2475,383 @@ class Jira: return {"type": "doc", "version": 1, "content": content} + @staticmethod + def _unknown_issue_status( + reference: JiraIssueReference, + *, + http_status: Optional[int] = None, + retry_after: Optional[str] = None, + error_code: str = "unknown", + error_message: str = "Jira could not confirm the issue status.", + ) -> JiraIssueStatusResult: + """Build a safe unknown status without implying deletion.""" + return JiraIssueStatusResult( + reference=reference, + outcome=JiraIssueLookupOutcome.UNKNOWN, + http_status=http_status, + retry_after=retry_after, + error_code=error_code, + error_message=error_message, + ) + + def _set_unknown_issue_statuses( + self, + results: dict[JiraIssueReference, JiraIssueStatusResult], + references: list[JiraIssueReference], + **details, + ) -> None: + """Assign the same unknown observation to a group of references.""" + for reference in references: + results[reference] = self._unknown_issue_status(reference, **details) + + def _resolved_issue_status( + self, + reference: JiraIssueReference, + issue: dict, + retry_after: Optional[str], + ) -> JiraIssueStatusResult: + """Classify one issue returned by Jira's bulk fetch endpoint.""" + malformed = { + "http_status": 200, + "retry_after": retry_after, + "error_code": "malformed_issue", + "error_message": "Jira returned malformed issue data.", + } + issue_id = str(issue.get("id")) + issue_key = issue.get("key") + if not isinstance(issue_key, str) or not self.ISSUE_KEY_REGEX.fullmatch( + issue_key + ): + return self._unknown_issue_status(reference, **malformed) + + fields = issue.get("fields") + status = fields.get("status") if isinstance(fields, dict) else None + category = status.get("statusCategory") if isinstance(status, dict) else None + category_key = category.get("key") if isinstance(category, dict) else None + status_name = status.get("name") if isinstance(status, dict) else None + if issue_key != reference.issue_key: + outcome = JiraIssueLookupOutcome.MOVED + else: + outcome = { + "new": JiraIssueLookupOutcome.OPEN, + "indeterminate": JiraIssueLookupOutcome.OPEN, + "done": JiraIssueLookupOutcome.DONE, + }.get(category_key) + if not outcome or not isinstance(status_name, str): + return self._unknown_issue_status(reference, **malformed) + + return JiraIssueStatusResult( + reference=reference, + outcome=outcome, + current_issue_id=issue_id, + current_issue_key=issue_key, + current_issue_url=self.get_issue_url(issue_key), + status=status_name if isinstance(status_name, str) else None, + status_category=category_key if isinstance(category_key, str) else None, + http_status=200, + retry_after=retry_after, + ) + + def get_issues_status( + self, issue_references: list[JiraIssueReference] + ) -> list[JiraIssueStatusResult]: + """Resolve unique Jira references by ID without inferring deletion.""" + references = list(dict.fromkeys(issue_references or [])) + if not references: + return [] + + results: dict[JiraIssueReference, JiraIssueStatusResult] = {} + valid_references: list[JiraIssueReference] = [] + for reference in references: + if ( + not reference.issue_id + or not isinstance(reference.issue_id, str) + or not self.ISSUE_ID_REGEX.fullmatch(reference.issue_id) + or not isinstance(reference.issue_key, str) + or not self.ISSUE_KEY_REGEX.fullmatch(reference.issue_key) + ): + results[reference] = self._unknown_issue_status( + reference, + error_code="invalid_reference", + error_message="The stored Jira issue reference is invalid.", + ) + else: + valid_references.append(reference) + + if valid_references: + try: + access_token = self.get_access_token() + except ( + JiraRefreshTokenError, + JiraRefreshTokenResponseError, + JiraGetAccessTokenError, + ): + access_token = None + if not access_token: + self._set_unknown_issue_statuses( + results, + valid_references, + error_code="authentication_failed", + error_message="Jira authentication failed during status lookup.", + ) + return [results[reference] for reference in references] + + headers = self.get_headers(access_token, content_type_json=True) + for start in range(0, len(valid_references), self.ISSUE_STATUS_BATCH_SIZE): + batch = valid_references[start : start + self.ISSUE_STATUS_BATCH_SIZE] + try: + response = requests.post( + f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue/bulkfetch", + json={ + "issueIdsOrKeys": [ + reference.issue_id for reference in batch + ], + "fields": ["status"], + }, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) + except requests.exceptions.RequestException: + self._set_unknown_issue_statuses( + results, + batch, + error_code="transport_failure", + error_message="Jira status lookup failed in transit.", + ) + continue + + retry_after = self._retry_after(response) + if response.status_code != 200: + self._set_unknown_issue_statuses( + results, + batch, + http_status=response.status_code, + retry_after=retry_after, + error_code=f"jira_http_{response.status_code}", + ) + continue + + response_json = self._response_json(response) + if not isinstance(response_json, dict) or not isinstance( + response_json.get("issues", []), list + ): + self._set_unknown_issue_statuses( + results, + batch, + http_status=200, + retry_after=retry_after, + error_code="malformed_response", + error_message="Jira returned a malformed status response.", + ) + continue + + references_by_id: dict[str, list[JiraIssueReference]] = {} + for reference in batch: + references_by_id.setdefault(reference.issue_id, []).append( + reference + ) + + for issue in response_json.get("issues", []): + if not isinstance(issue, dict): + continue + current_issue_id = issue.get("id") + matching_references = references_by_id.get( + str(current_issue_id), [] + ) + if not matching_references: + continue + for reference in matching_references: + results[reference] = self._resolved_issue_status( + reference, issue, retry_after + ) + + issue_errors = response_json.get("issueErrors", []) + if isinstance(issue_errors, list): + for issue_error in issue_errors: + if not isinstance(issue_error, dict): + continue + error_reference = issue_error.get( + "issueIdOrKey", + issue_error.get("issueId", issue_error.get("id")), + ) + matching_references = references_by_id.get( + str(error_reference), [] + ) + error_status = issue_error.get( + "statusCode", + issue_error.get("status", issue_error.get("errorCode")), + ) + try: + error_status = int(error_status) + except (TypeError, ValueError): + error_status = None + outcome = { + 403: JiraIssueLookupOutcome.FORBIDDEN, + 404: JiraIssueLookupOutcome.MISSING, + }.get(error_status) + if outcome: + for reference in matching_references: + results[reference] = JiraIssueStatusResult( + reference=reference, + outcome=outcome, + http_status=error_status, + retry_after=retry_after, + error_code=f"jira_http_{error_status}", + error_message="Jira could not return the issue.", + ) + + for reference in batch: + if reference not in results: + results[reference] = self._unknown_issue_status( + reference, + http_status=200, + retry_after=retry_after, + error_code="omitted_issue", + error_message="Jira omitted the issue from its response.", + ) + + return [results[reference] for reference in references] + + @staticmethod + def _escape_jql_string(value: str) -> str: + """Escape a string used inside a quoted JQL value.""" + return value.replace("\\", "\\\\").replace('"', '\\"') + + def search_issues_by_delivery_attempt( + self, delivery_attempt_marker: Optional[str] + ) -> JiraIssueSearchResult: + """Search Jira by a caller-owned delivery marker.""" + matches: list[JiraIssueSearchMatch] = [] + + def result( + outcome: JiraIssueSearchOutcome, + *, + error_code: Optional[str] = None, + error_message: Optional[str] = None, + response: Optional[requests.Response] = None, + ) -> JiraIssueSearchResult: + return JiraIssueSearchResult( + outcome=outcome, + matches=tuple(matches), + http_status=getattr(response, "status_code", None), + retry_after=( + self._retry_after(response) if response is not None else None + ), + error_code=error_code, + error_message=error_message, + ) + + attempt_label = self.build_delivery_attempt_label(delivery_attempt_marker) + if not attempt_label: + return result( + JiraIssueSearchOutcome.UNKNOWN, + error_code="invalid_delivery_marker", + error_message="The Jira delivery marker is empty.", + ) + try: + access_token = self.get_access_token() + except ( + JiraRefreshTokenError, + JiraRefreshTokenResponseError, + JiraGetAccessTokenError, + ): + access_token = None + if not access_token: + return result( + JiraIssueSearchOutcome.UNKNOWN, + error_code="authentication_failed", + error_message="Jira authentication failed during marker lookup.", + ) + + headers = self.get_headers(access_token, content_type_json=True) + payload = { + "jql": f'labels = "{self._escape_jql_string(attempt_label)}"', + "fields": ["key"], + "maxResults": 100, + } + seen_matches: set[tuple[str, str]] = set() + seen_page_tokens: set[str] = set() + while True: + try: + response = requests.post( + f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/search/jql", + json=payload, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) + except requests.exceptions.RequestException: + return result( + JiraIssueSearchOutcome.RETRYABLE_FAILURE, + error_code="transport_failure", + error_message="Jira marker lookup failed in transit.", + ) + + if response.status_code != 200: + retryable = response.status_code == 429 or response.status_code >= 500 + return result( + ( + JiraIssueSearchOutcome.RETRYABLE_FAILURE + if retryable + else JiraIssueSearchOutcome.UNKNOWN + ), + error_code=f"jira_http_{response.status_code}", + error_message="Jira marker lookup failed.", + response=response, + ) + + response_json = self._response_json(response) + if not isinstance(response_json, dict) or not isinstance( + response_json.get("issues"), list + ): + return result( + JiraIssueSearchOutcome.UNKNOWN, + error_code="malformed_response", + error_message="Jira returned a malformed marker lookup response.", + response=response, + ) + + for issue in response_json["issues"]: + issue_id, issue_key, issue_url = self._issue_identity(issue) + if not issue_id or not issue_key or not issue_url: + return result( + JiraIssueSearchOutcome.UNKNOWN, + error_code="malformed_issue", + error_message="Jira returned malformed marker lookup data.", + response=response, + ) + identity = (issue_id, issue_key) + if identity not in seen_matches: + seen_matches.add(identity) + matches.append( + JiraIssueSearchMatch( + issue_id=issue_id, + issue_key=issue_key, + issue_url=issue_url, + ) + ) + + next_page_token = response_json.get("nextPageToken") + if not next_page_token: + return result(JiraIssueSearchOutcome.SUCCESS, response=response) + if ( + not isinstance(next_page_token, str) + or next_page_token in seen_page_tokens + ): + return result( + JiraIssueSearchOutcome.UNKNOWN, + error_code="pagination_stalled", + error_message="Jira marker lookup pagination did not complete.", + response=response, + ) + seen_page_tokens.add(next_page_token) + payload["nextPageToken"] = next_page_token + def send_findings( self, findings: list[Finding] = None, project_key: str = None, issue_type: str = None, - issue_labels: list[str] = None, + issue_labels: Optional[list[str]] = None, finding_url: str = None, tenant_info: str = None, ): @@ -2300,6 +2951,7 @@ class Jira: "customfield_10088": {"value": "Core"}, } } + issue_labels = self.sanitize_labels(issue_labels) if issue_labels: payload["fields"]["labels"] = issue_labels @@ -2310,25 +2962,25 @@ class Jira: timeout=self.REQUEST_TIMEOUT, ) - 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) - raise JiraSendFindingsResponseError( - message=response_error, file=os.path.basename(__file__) - ) + creation_result = self._classify_creation_response(response) + if not creation_result.is_confirmed_success: + response_json = self._response_json(response) or {} # Check if the error is due to required custom fields - if response.status_code == 400 and "errors" in response_json: + if ( + response.status_code == 400 + and isinstance(response_json, dict) + 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_") - } + custom_field_errors = {} + if isinstance(errors, dict): + custom_field_errors = { + k: v + for k, v in errors.items() + if k.startswith("customfield_") + } if custom_field_errors: custom_fields_formatted = ", ".join( [ @@ -2341,19 +2993,19 @@ class Jira: file=os.path.basename(__file__), ) - response_error = f"Failed to send finding: {response.status_code} - {response_json}" + response_error = ( + creation_result.error_message + or "Jira did not confirm issue creation." + ) logger.error(response_error) raise JiraSendFindingsResponseError( message=response_error, file=os.path.basename(__file__) ) 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}" - ) + logger.info( + "Finding sent successfully: %s", + creation_result.issue_key, + ) except JiraRequiredCustomFieldsError as custom_fields_error: raise custom_fields_error except JiraRefreshTokenError as refresh_error: @@ -2391,7 +3043,8 @@ class Jira: compliance: dict = "", project_key: str = "", issue_type: str = "", - issue_labels: list[str] = "", + issue_labels: Optional[list[str]] = None, + delivery_attempt_marker: Optional[str] = None, finding_url: str = "", tenant_info: str = "", affected_failing_resources: int = 0, @@ -2402,7 +3055,7 @@ class Jira: failing_for: str = "", finding_group_url: str = "", finding_group_link_text: str = "", - ) -> bool: + ) -> JiraCreationResult: """ Send the finding to Jira @@ -2430,6 +3083,8 @@ class Jira: - project_key: The project key - issue_type: The issue type - issue_labels: The issue labels + - delivery_attempt_marker: Caller-owned marker used to reconcile an + ambiguous delivery attempt - finding_url: The finding URL - tenant_info: The tenant info - affected_failing_resources: The number of affected failing resources @@ -2442,43 +3097,43 @@ class Jira: - finding_group_url: The finding group URL - finding_group_link_text: The link text for the finding group URL - Raises: - - JiraRefreshTokenError: Failed to refresh the access token - - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 - - JiraNoTokenError: Failed to get an access token - - 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 + - A typed creation result. Only ``confirmed_success`` proves that + Jira created the issue. """ try: access_token = self.get_access_token() if not access_token: - raise JiraNoTokenError( - message="No token was found", - file=os.path.basename(__file__), + return JiraCreationResult( + outcome=JiraCreationOutcome.CONFIRMED_REJECTION, + delivery_marker=delivery_attempt_marker, + error_code="missing_credentials", + error_message="Jira credentials are unavailable.", ) - projects = self.get_projects() + destination = (project_key, issue_type) + if destination not in self._validated_destinations: + projects = self.get_projects() + if project_key not in projects: + logger.error("The project key is invalid") + return JiraCreationResult( + outcome=JiraCreationOutcome.CONFIRMED_REJECTION, + delivery_marker=delivery_attempt_marker, + error_code="invalid_project", + error_message="The Jira project key is invalid.", + ) - 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__) - ) + 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") + return JiraCreationResult( + outcome=JiraCreationOutcome.CONFIRMED_REJECTION, + delivery_marker=delivery_attempt_marker, + error_code="invalid_issue_type", + error_message="The Jira issue type is invalid.", + ) + self._validated_destinations.add(destination) headers = self.get_headers(access_token, content_type_json=True) @@ -2553,81 +3208,40 @@ class Jira: "issuetype": {"name": issue_type}, } } + issue_labels = list(issue_labels or []) + attempt_label = self.build_delivery_attempt_label(delivery_attempt_marker) + if attempt_label: + issue_labels.append(attempt_label) + issue_labels = self.sanitize_labels(issue_labels) 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, - timeout=self.REQUEST_TIMEOUT, + try: + response = requests.post( + f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue", + json=payload, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) + except requests.exceptions.RequestException as error: + return self._creation_transport_result(error, delivery_attempt_marker) + return self._classify_creation_response(response, delivery_attempt_marker) + except ( + JiraRefreshTokenError, + JiraRefreshTokenResponseError, + JiraGetAccessTokenError, + ): + return JiraCreationResult( + outcome=JiraCreationOutcome.RETRYABLE_FAILURE, + delivery_marker=delivery_attempt_marker, + error_code="authentication_failed_before_send", + error_message="Jira authentication failed before sending.", ) - - if response.status_code != 201: - try: - response_json = response.json() - except (ValueError, requests.exceptions.JSONDecodeError): - response_error = _format_jira_issue_creation_error( - {}, response.status_code - ) - logger.error(response_error) - raise JiraSendFindingsResponseError( - message=response_error, file=os.path.basename(__file__) - ) - - # Check if the error is due to required custom fields - if ( - response.status_code == 400 - and isinstance(response_json, dict) - and "errors" in response_json - ): - errors = response_json.get("errors", {}) - # Look for custom field errors (fields starting with "customfield_") - custom_field_errors = {} - if isinstance(errors, dict): - 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 = _format_jira_issue_creation_error( - response_json, response.status_code - ) - logger.error(response_error) - raise JiraSendFindingsResponseError( - message=response_error, file=os.path.basename(__file__) - ) - 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}") - raise custom_fields_error - except JiraSendFindingsResponseError as response_error: - logger.error(f"Jira response error: {response_error}") - raise response_error - except JiraRefreshTokenError as refresh_error: - logger.error(f"Token refresh error: {refresh_error}") - raise refresh_error - except JiraRefreshTokenResponseError as response_error: - raise response_error - except JiraNoTokenError as no_token_error: - raise no_token_error - except Exception as e: - logger.error(f"Failed to send finding: {e}") - return False + except ( + JiraNoProjectsError, + JiraGetProjectsError, + JiraGetProjectsResponseError, + JiraGetAvailableIssueTypesError, + JiraGetAvailableIssueTypesResponseError, + ) as error: + return self._destination_validation_result(error, delivery_attempt_marker) diff --git a/prowler/lib/outputs/jira/models.py b/prowler/lib/outputs/jira/models.py new file mode 100644 index 0000000000..7c2abc094b --- /dev/null +++ b/prowler/lib/outputs/jira/models.py @@ -0,0 +1,112 @@ +"""Typed result models for Jira issue creation and lookup operations.""" + +from dataclasses import dataclass +from enum import Enum +from typing import Optional, Tuple + + +class JiraCreationOutcome(str, Enum): + """Possible outcomes of a Jira issue creation attempt.""" + + CONFIRMED_SUCCESS = "confirmed_success" + CONFIRMED_REJECTION = "confirmed_rejection" + RETRYABLE_FAILURE = "retryable_failure" + UNCERTAIN = "uncertain" + + +@dataclass(frozen=True) +class JiraCreationResult: + """Immutable result of a Jira issue creation attempt.""" + + outcome: JiraCreationOutcome + issue_key: Optional[str] = None + issue_id: Optional[str] = None + issue_url: Optional[str] = None + delivery_marker: Optional[str] = None + http_status: Optional[int] = None + retry_after: Optional[str] = None + error_code: Optional[str] = None + error_message: Optional[str] = None + + def __post_init__(self) -> None: + """Require a complete issue reference for confirmed creation.""" + if self.outcome == JiraCreationOutcome.CONFIRMED_SUCCESS and not all( + (self.issue_key, self.issue_id, self.issue_url) + ): + raise ValueError( + "Confirmed Jira issue creation requires an issue key, ID, and URL" + ) + + @property + def is_confirmed_success(self) -> bool: + """Return whether Jira confirmed that it created the issue.""" + return self.outcome == JiraCreationOutcome.CONFIRMED_SUCCESS + + def __bool__(self) -> bool: + """Preserve boolean compatibility for confirmed creation only.""" + return self.is_confirmed_success + + +@dataclass(frozen=True) +class JiraIssueReference: + """Stable Jira issue identity and its last known key.""" + + issue_id: str + issue_key: str + + +class JiraIssueLookupOutcome(str, Enum): + """Possible outcomes of looking up a Jira issue.""" + + OPEN = "open" + DONE = "done" + MOVED = "moved" + MISSING = "missing" + FORBIDDEN = "forbidden" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class JiraIssueStatusResult: + """Immutable result of looking up a Jira issue's current state.""" + + reference: JiraIssueReference + outcome: JiraIssueLookupOutcome + current_issue_id: Optional[str] = None + current_issue_key: Optional[str] = None + current_issue_url: Optional[str] = None + status: Optional[str] = None + status_category: Optional[str] = None + http_status: Optional[int] = None + retry_after: Optional[str] = None + error_code: Optional[str] = None + error_message: Optional[str] = None + + +class JiraIssueSearchOutcome(str, Enum): + """Possible outcomes of searching Jira issues by a delivery marker.""" + + SUCCESS = "success" + RETRYABLE_FAILURE = "retryable_failure" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class JiraIssueSearchMatch: + """A Jira issue found through a delivery-marker search.""" + + issue_id: str + issue_key: str + issue_url: str + + +@dataclass(frozen=True) +class JiraIssueSearchResult: + """Immutable result of searching Jira issues by a delivery marker.""" + + outcome: JiraIssueSearchOutcome + matches: Tuple[JiraIssueSearchMatch, ...] = () + http_status: Optional[int] = None + retry_after: Optional[str] = None + error_code: Optional[str] = None + error_message: Optional[str] = None diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 9abd2d26a1..2b8ac90512 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -1,29 +1,42 @@ import base64 +import hashlib +from dataclasses import FrozenInstanceError from datetime import datetime, timedelta +from types import SimpleNamespace from typing import List, Optional from unittest.mock import MagicMock, PropertyMock, patch from urllib.parse import parse_qs, urlparse import pytest +import requests from freezegun import freeze_time from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraAuthenticationError, JiraBasicAuthError, JiraCreateIssueError, + JiraGetAccessTokenError, JiraGetAvailableIssueTypesError, JiraGetCloudIDError, JiraGetProjectsError, JiraGetProjectsResponseError, JiraNoProjectsError, - JiraNoTokenError, JiraRefreshTokenError, JiraRefreshTokenResponseError, JiraRequiredCustomFieldsError, - JiraSendFindingsResponseError, JiraTestConnectionError, ) from prowler.lib.outputs.jira.jira import Jira, MarkdownToADFConverter +from prowler.lib.outputs.jira.models import ( + JiraCreationOutcome, + JiraCreationResult, + JiraIssueLookupOutcome, + JiraIssueReference, + JiraIssueSearchMatch, + JiraIssueSearchOutcome, + JiraIssueSearchResult, + JiraIssueStatusResult, +) TEST_DATETIME = "2023-01-01T12:01:01+00:00" @@ -72,6 +85,62 @@ class TestMarkdownToADFConverter: assert result[0]["content"][0]["marks"] == [{"type": "code"}] +@pytest.mark.parametrize("missing_field", ["issue_key", "issue_id", "issue_url"]) +def test_confirmed_creation_requires_complete_identity(missing_field): + identity = { + "issue_key": "SEC-1", + "issue_id": "10001", + "issue_url": "https://example.atlassian.net/browse/SEC-1", + } + identity[missing_field] = None + + with pytest.raises(ValueError, match="requires an issue key, ID, and URL"): + JiraCreationResult(JiraCreationOutcome.CONFIRMED_SUCCESS, **identity) + + +@pytest.mark.parametrize( + ("instance", "field"), + [ + (JiraCreationResult(JiraCreationOutcome.UNCERTAIN), "error_code"), + (JiraIssueReference("10001", "SEC-1"), "issue_key"), + ( + JiraIssueStatusResult( + JiraIssueReference("10001", "SEC-1"), JiraIssueLookupOutcome.OPEN + ), + "status", + ), + (JiraIssueSearchMatch("10001", "SEC-1", "https://example"), "issue_key"), + (JiraIssueSearchResult(JiraIssueSearchOutcome.SUCCESS), "matches"), + ], +) +def test_jira_result_models_are_immutable(instance, field): + with pytest.raises(FrozenInstanceError): + setattr(instance, field, None) + + +@pytest.mark.parametrize( + ("enum", "values"), + [ + ( + JiraCreationOutcome, + { + "confirmed_success", + "confirmed_rejection", + "retryable_failure", + "uncertain", + }, + ), + ( + JiraIssueLookupOutcome, + {"open", "done", "moved", "missing", "forbidden", "unknown"}, + ), + (JiraIssueSearchOutcome, {"success", "retryable_failure", "unknown"}), + ], +) +def test_jira_outcome_values_are_stable(enum, values): + assert {outcome.value for outcome in enum} == values + + class TestJiraIntegration: @pytest.fixture(autouse=True) @patch.object(Jira, "get_auth", return_value=None) @@ -108,6 +177,52 @@ class TestJiraIntegration: domain=self.domain, ) + @pytest.fixture + def jira_response(self): + def build(status_code=200, payload=None, headers=None, json_error=None): + response = MagicMock(status_code=status_code, headers=headers or {}) + if json_error: + response.json.side_effect = json_error + else: + response.json.return_value = payload + return response + + return build + + @pytest.fixture + def jira_issue(self): + def build(issue_id, key, status=None, category=None): + status_data = None + if status is not None: + status_data = {"name": status, "statusCategory": {"key": category}} + return {"id": issue_id, "key": key, "fields": {"status": status_data}} + + return build + + @pytest.fixture + def oauth_post(self): + with ( + patch.object(Jira, "get_access_token", return_value="token"), + patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="cloud" + ), + patch("prowler.lib.outputs.jira.jira.requests.post") as post, + ): + yield post + + @pytest.fixture + def send_finding_post(self, oauth_post): + self.jira_integration._site_url = "https://example.atlassian.net" + with ( + patch.object(Jira, "get_projects", return_value={"TEST": {}}) as projects, + patch.object( + Jira, "get_available_issue_types", return_value=["Bug"] + ) as issue_types, + ): + yield SimpleNamespace( + post=oauth_post, projects=projects, issue_types=issue_types + ) + @staticmethod def _collect_text_from_cell(cell: dict) -> str: pieces: List[str] = [] @@ -813,6 +928,20 @@ class TestJiraIntegration: with pytest.raises(JiraRefreshTokenError): self.jira_integration.get_projects() + @pytest.mark.parametrize( + ("method_name", "args", "error"), + [ + ("get_projects", (), JiraGetProjectsError), + ("get_available_issue_types", ("TEST",), JiraGetAvailableIssueTypesError), + ], + ) + @patch.object(Jira, "get_access_token", return_value=None) + def test_catalog_methods_raise_without_access_token( + self, mock_get_access_token, method_name, args, error + ): + with pytest.raises(error): + getattr(self.jira_integration, method_name)(*args) + @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" @@ -956,6 +1085,7 @@ class TestJiraIntegration: finding.compliance = {"CIS": ["2.1.1", "2.1.2"], "NIST": ["AC-3", "AC-6"]} self.jira_integration.cloud_id = "valid_cloud_id" + self.jira_integration._site_url = "https://example.atlassian.net" self.jira_integration.send_findings( findings=[finding], @@ -1981,18 +2111,17 @@ class TestJiraIntegration: mock_cloud_id, mock_get_access_token, ): - """Test that send_finding returns True when the finding is sent successfully.""" + """Test that send_finding returns a confirmed typed result.""" # 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_response = MagicMock(status_code=201, headers={}) + mock_response.json.return_value = {"id": "10001", "key": "TEST-123"} mock_post.return_value = mock_response + self.jira_integration._site_url = "https://example.atlassian.net" result = self.jira_integration.send_finding( check_id="test-check", @@ -2003,9 +2132,745 @@ class TestJiraIntegration: issue_type="Bug", ) - assert result is True + assert result.outcome == JiraCreationOutcome.CONFIRMED_SUCCESS + assert result.issue_key == "TEST-123" + assert result.issue_id == "10001" + assert result.issue_url == "https://example.atlassian.net/browse/TEST-123" + assert result.is_confirmed_success is True + assert bool(result) is True mock_post.assert_called_once() + @pytest.mark.parametrize( + ("payload", "json_error", "site_url", "error_code"), + [ + ( + None, + ValueError("invalid JSON"), + "https://example", + "malformed_success_response", + ), + ({"id": "10001"}, None, "https://example", "incomplete_success_response"), + ({"key": "TEST-1"}, None, "https://example", "incomplete_success_response"), + ( + {"id": "10001", "key": "invalid key"}, + None, + "https://example", + "incomplete_success_response", + ), + ( + {"id": "not-an-id", "key": "TEST-1"}, + None, + "https://example", + "incomplete_success_response", + ), + ( + {"id": "10001", "key": "TEST-1"}, + None, + None, + "incomplete_success_response", + ), + ], + ) + def test_send_finding_invalid_201_is_uncertain( + self, + send_finding_post, + jira_response, + payload, + json_error, + site_url, + error_code, + ): + self.jira_integration._site_url = site_url + send_finding_post.post.return_value = jira_response( + 201, payload, json_error=json_error + ) + result = self.jira_integration.send_finding( + project_key="TEST", issue_type="Bug", delivery_attempt_marker="attempt-123" + ) + + assert result.outcome == JiraCreationOutcome.UNCERTAIN + assert result.error_code == error_code + assert result.delivery_marker == "attempt-123" + assert result.http_status == 201 + assert bool(result) is False + if site_url is None: + assert (result.issue_id, result.issue_key, result.issue_url) == ( + "10001", + "TEST-1", + None, + ) + + @pytest.mark.parametrize( + ("status_code", "outcome", "retry_after"), + [ + (401, JiraCreationOutcome.CONFIRMED_REJECTION, None), + (403, JiraCreationOutcome.CONFIRMED_REJECTION, None), + (408, JiraCreationOutcome.UNCERTAIN, None), + (429, JiraCreationOutcome.RETRYABLE_FAILURE, "17"), + ], + ) + def test_send_finding_classifies_http_failures( + self, send_finding_post, jira_response, status_code, outcome, retry_after + ): + send_finding_post.post.return_value = jira_response( + status_code, + {"errorMessages": ["Jira error"]}, + {"Retry-After": retry_after} if retry_after else None, + ) + result = self.jira_integration.send_finding( + project_key="TEST", issue_type="Bug" + ) + + assert result.outcome == outcome + assert result.http_status == status_code + assert result.retry_after == retry_after + + @pytest.mark.parametrize( + "transport_error, expected_outcome", + [ + ( + requests.exceptions.ConnectTimeout(), + JiraCreationOutcome.RETRYABLE_FAILURE, + ), + (requests.exceptions.ReadTimeout(), JiraCreationOutcome.UNCERTAIN), + (requests.exceptions.ConnectionError(), JiraCreationOutcome.UNCERTAIN), + ], + ) + def test_send_finding_classifies_transport_failures( + self, send_finding_post, transport_error, expected_outcome + ): + send_finding_post.post.side_effect = transport_error + result = self.jira_integration.send_finding( + project_key="TEST", + issue_type="Bug", + delivery_attempt_marker="stable-marker", + ) + + assert result.outcome == expected_outcome + assert result.delivery_marker == "stable-marker" + + @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_returns_issue_url_with_basic_auth( + self, + mock_post, + mock_get_issue_types, + mock_get_projects, + mock_cloud_id, + mock_get_access_token, + ): + """Test that send_finding builds the browse URL from the basic auth site name.""" + # 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 = MagicMock(status_code=201, headers={}) + mock_response.json.return_value = {"id": "10001", "key": "TEST-7"} + mock_post.return_value = mock_response + + result = self.jira_integration_basic_auth.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) + + assert result.outcome == JiraCreationOutcome.CONFIRMED_SUCCESS + assert result.issue_key == "TEST-7" + assert result.issue_id == "10001" + assert result.issue_url == "https://test-domain.atlassian.net/browse/TEST-7" + + @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_sanitizes_issue_labels( + self, + mock_post, + mock_get_issue_types, + mock_get_projects, + mock_cloud_id, + mock_get_access_token, + ): + """Test that labels are sanitized, deduplicated and empties dropped before sending.""" + # 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 = MagicMock(status_code=201, headers={}) + mock_response.json.return_value = {"id": "10001", "key": "TEST-123"} + mock_post.return_value = mock_response + + self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + issue_labels=[ + "prowler", + "prowler-finding-arn:aws:s3:::my bucket/with space", + "prowler", + "", + " ", + ], + ) + + payload = mock_post.call_args.kwargs["json"] + assert payload["fields"]["labels"] == [ + "prowler", + "prowler-finding-arn:aws:s3:::my_bucket/with_space", + ] + + def test_send_finding_reuses_marker_and_caches_valid_destinations( + self, send_finding_post, jira_response + ): + """Test retries keep one marker and catalogs are cached per destination.""" + responses = [ + jira_response( + 201, + { + "id": str(10001 + index), + "key": f"TEST-{index + 1}", + }, + ) + for index in range(3) + ] + send_finding_post.projects.return_value = {"TEST": {}, "OPS": {}} + send_finding_post.issue_types.side_effect = [["Bug"], ["Task"]] + send_finding_post.post.side_effect = responses + + for _ in range(2): + self.jira_integration.send_finding( + project_key="TEST", + issue_type="Bug", + delivery_attempt_marker="marker 123", + ) + self.jira_integration.send_finding( + project_key="OPS", + issue_type="Task", + delivery_attempt_marker="marker 123", + ) + + assert send_finding_post.projects.call_count == 2 + assert send_finding_post.issue_types.call_count == 2 + labels = [ + call.kwargs["json"]["fields"]["labels"] + for call in send_finding_post.post.call_args_list + ] + assert labels == [["prowler-attempt-marker_123"]] * 3 + + @pytest.mark.parametrize( + "projects, issue_types, expected_code", + [ + ({}, ["Bug"], "invalid_project"), + ({"TEST": {}}, [], "invalid_issue_type"), + (JiraNoProjectsError(message="No projects"), ["Bug"], "invalid_project"), + ], + ) + def test_send_finding_invalid_destination_is_confirmed_rejection( + self, send_finding_post, projects, issue_types, expected_code + ): + if isinstance(projects, Exception): + send_finding_post.projects.side_effect = projects + else: + send_finding_post.projects.return_value = projects + send_finding_post.issue_types.return_value = issue_types + result = self.jira_integration.send_finding( + project_key="TEST", issue_type="Bug" + ) + + assert result.outcome == JiraCreationOutcome.CONFIRMED_REJECTION + assert result.error_code == expected_code + send_finding_post.post.assert_not_called() + + @pytest.mark.parametrize( + ("failure_stage", "status_code", "outcome", "error_code", "retry_after"), + [ + ( + "projects", + 403, + JiraCreationOutcome.CONFIRMED_REJECTION, + "invalid_credentials", + None, + ), + ( + "issue_types", + 403, + JiraCreationOutcome.CONFIRMED_REJECTION, + "invalid_credentials", + None, + ), + ( + "projects", + 429, + JiraCreationOutcome.RETRYABLE_FAILURE, + "destination_temporarily_unavailable", + "23", + ), + ], + ) + def test_send_finding_classifies_catalog_failures( + self, + oauth_post, + jira_response, + failure_stage, + status_code, + outcome, + error_code, + retry_after, + ): + failure = jira_response( + status_code, headers={"Retry-After": retry_after} if retry_after else None + ) + failure.text = "unsafe response body" + responses = [failure] + if failure_stage == "issue_types": + projects = jira_response(200, [{"key": "TEST", "name": "Test"}]) + responses = [projects, failure] + + with patch("prowler.lib.outputs.jira.jira.requests.get", side_effect=responses): + result = self.jira_integration.send_finding( + project_key="TEST", issue_type="Bug" + ) + + assert result.outcome == outcome + assert result.http_status == status_code + assert result.error_code == error_code + assert result.retry_after == retry_after + assert "unsafe response body" not in result.error_message + oauth_post.assert_not_called() + + def test_sanitize_label(self): + """Test the deterministic Jira label sanitizer.""" + assert Jira.sanitize_label("") == "" + assert Jira.sanitize_label(None) == "" + assert Jira.sanitize_label(" ") == "" + assert Jira.sanitize_label("simple") == "simple" + assert Jira.sanitize_label("with space") == "with_space" + assert Jira.sanitize_label(" many spaces \t tabs\nnewline ") == ( + "many_spaces_tabs_newline" + ) + assert Jira.sanitize_label("ctrl\x00char\x07here") == "ctrlcharhere" + assert Jira.sanitize_label("__ Keep__CASE Here __") == "Keep_CASE_Here" + assert Jira.sanitize_label("arn:aws:iam::123456789012:role/Admin") == ( + "arn:aws:iam::123456789012:role/Admin" + ) + long_label = "x" * 300 + assert Jira.sanitize_label(long_label) == "x" * 255 + # Idempotent: sanitizing an already sanitized value is a no-op + once = Jira.sanitize_label("a b\tc") + assert Jira.sanitize_label(once) == once + + def test_prefixed_labels_are_length_safe_and_collision_resistant(self): + """Test finding and attempt labels preserve identity at Jira's limit.""" + assert Jira.build_finding_label("") == "" + assert Jira.build_delivery_attempt_label(None) == "" + finding_prefix = f"{Jira.FINDING_LABEL_PREFIX}-" + exact_uid = "A" * (Jira.LABEL_MAX_LENGTH - len(finding_prefix)) + assert Jira.build_finding_label(exact_uid) == f"{finding_prefix}{exact_uid}" + + long_prefix = "A" * 400 + first_uid = f"{long_prefix}-first" + second_uid = f"{long_prefix}-second" + first_label = Jira.build_finding_label(first_uid) + second_label = Jira.build_finding_label(second_uid) + + assert len(first_label) == Jira.LABEL_MAX_LENGTH + assert len(second_label) == Jira.LABEL_MAX_LENGTH + assert first_label.endswith(hashlib.sha256(first_uid.encode()).hexdigest()) + assert second_label.endswith(hashlib.sha256(second_uid.encode()).hexdigest()) + assert first_label != second_label + assert Jira.build_delivery_attempt_label("Attempt CASE") == ( + "prowler-attempt-Attempt_CASE" + ) + long_attempt_label = Jira.build_delivery_attempt_label(first_uid) + assert len(long_attempt_label) == Jira.LABEL_MAX_LENGTH + assert long_attempt_label.endswith( + hashlib.sha256(first_uid.encode()).hexdigest() + ) + + def test_sanitize_labels(self): + """Test list sanitization keeps order, drops empties and duplicates.""" + assert Jira.sanitize_labels(None) == [] + assert Jira.sanitize_labels([]) == [] + assert Jira.sanitize_labels(["b", "a b", "b", "", "a_b"]) == ["b", "a_b"] + + def test_site_url_and_issue_url(self): + """Test site_url derivation and browse URL building for both auth modes.""" + assert ( + self.jira_integration_basic_auth.site_url + == "https://test-domain.atlassian.net" + ) + assert ( + self.jira_integration_basic_auth.get_issue_url("TEST-1") + == "https://test-domain.atlassian.net/browse/TEST-1" + ) + # OAuth: unknown until accessible-resources is fetched + assert self.jira_integration.site_url is None + assert self.jira_integration.get_issue_url("TEST-1") is None + self.jira_integration._site_url = "https://oauth-site.atlassian.net/" + assert ( + self.jira_integration.get_issue_url("TEST-1") + == "https://oauth-site.atlassian.net/browse/TEST-1" + ) + assert self.jira_integration.get_issue_url("") is None + + @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("prowler.lib.outputs.jira.jira.requests.post") + def test_get_issues_status(self, mock_post, mock_cloud_id, mock_get_access_token): + """Test bulk status lookup returns explicit outcomes in input order.""" + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + mock_response = MagicMock(status_code=200, headers={}) + mock_response.json.return_value = { + "issues": [ + { + "id": "10001", + "key": "SEC-1", + "fields": { + "status": { + "name": "Resolved by policy", + "statusCategory": {"key": "done"}, + } + }, + }, + { + "id": "10002", + "key": "SEC-2", + "fields": { + "status": { + "name": "In Progress", + "statusCategory": {"key": "indeterminate"}, + } + }, + }, + ], + "issueErrors": [{"id": "10003", "statusCode": 404}], + } + mock_post.return_value = mock_response + + references = [ + JiraIssueReference("10001", "SEC-1"), + JiraIssueReference("10002", "SEC-2"), + JiraIssueReference("10003", "SEC-3"), + JiraIssueReference("10001", "SEC-1"), + ] + result = self.jira_integration.get_issues_status(references) + + assert [item.reference for item in result] == references[:3] + assert [item.outcome for item in result] == [ + JiraIssueLookupOutcome.DONE, + JiraIssueLookupOutcome.OPEN, + JiraIssueLookupOutcome.MISSING, + ] + assert result[0].status == "Resolved by policy" + assert result[1].status_category == "indeterminate" + assert result[2].http_status == 404 + mock_post.assert_called_once() + assert mock_post.call_args.args[0].endswith("/rest/api/3/issue/bulkfetch") + assert mock_post.call_args.kwargs["json"] == { + "issueIdsOrKeys": ["10001", "10002", "10003"], + "fields": ["status"], + } + + def test_get_issues_status_moved_precedes_status_and_parses_mixed_errors( + self, oauth_post, jira_response, jira_issue + ): + self.jira_integration._site_url = "https://example.atlassian.net" + oauth_post.return_value = jira_response( + 200, + { + "issues": [ + jira_issue("10001", "OPS-9", "Custom completion", "done"), + jira_issue("10004", "SEC-4"), + jira_issue("10006", "SEC-6", "Custom", "unclassified"), + ], + "issueErrors": [ + {"issueIdOrKey": "10002", "errorCode": 403}, + {"issueId": "10003", "status": 404}, + ], + }, + ) + references = [ + JiraIssueReference("10001", "SEC-1"), + JiraIssueReference("10002", "SEC-2"), + JiraIssueReference("10003", "SEC-3"), + JiraIssueReference("10004", "SEC-4"), + JiraIssueReference("10005", "SEC-5"), + JiraIssueReference("10006", "SEC-6"), + ] + + results = self.jira_integration.get_issues_status(references) + + assert [result.outcome for result in results] == [ + JiraIssueLookupOutcome.MOVED, + JiraIssueLookupOutcome.FORBIDDEN, + JiraIssueLookupOutcome.MISSING, + JiraIssueLookupOutcome.UNKNOWN, + JiraIssueLookupOutcome.UNKNOWN, + JiraIssueLookupOutcome.UNKNOWN, + ] + assert results[0].current_issue_key == "OPS-9" + assert results[0].current_issue_url.endswith("/browse/OPS-9") + assert results[0].status == "Custom completion" + assert results[3].error_code == "malformed_issue" + assert results[4].error_code == "omitted_issue" + assert results[5].error_code == "malformed_issue" + + def test_get_issues_status_malformed_batch_is_unknown( + self, oauth_post, jira_response + ): + oauth_post.return_value = jira_response(200, {"issues": "invalid"}) + reference = JiraIssueReference("10001", "SEC-1") + + result = self.jira_integration.get_issues_status([reference])[0] + + assert result.outcome == JiraIssueLookupOutcome.UNKNOWN + assert result.error_code == "malformed_response" + + @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("prowler.lib.outputs.jira.jira.requests.post") + def test_get_issues_status_batches_requests( + self, mock_post, mock_cloud_id, mock_get_access_token + ): + """Test that more than ISSUE_STATUS_BATCH_SIZE keys are fetched in batches.""" + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + mock_response = MagicMock(status_code=200, headers={}) + mock_response.json.return_value = {"issues": []} + mock_post.return_value = mock_response + + references = [JiraIssueReference(str(i), f"SEC-{i}") for i in range(1, 251)] + results = self.jira_integration.get_issues_status(references) + assert len(results) == 250 + assert all( + result.outcome == JiraIssueLookupOutcome.UNKNOWN for result in results + ) + assert mock_post.call_count == 3 + sizes = [ + len(call.kwargs["json"]["issueIdsOrKeys"]) + for call in mock_post.call_args_list + ] + assert sizes == [100, 100, 50] + + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_get_issues_status_without_keys(self, mock_post): + """Test that no request is made when there is nothing to look up.""" + assert self.jira_integration.get_issues_status([]) == [] + invalid_references = [ + JiraIssueReference("1", "SEC-1\n"), + JiraIssueReference("2", "SEC-١"), + JiraIssueReference("not-an-id", "SEC-3"), + ] + results = self.jira_integration.get_issues_status(invalid_references) + assert [result.outcome for result in results] == [ + JiraIssueLookupOutcome.UNKNOWN, + JiraIssueLookupOutcome.UNKNOWN, + JiraIssueLookupOutcome.UNKNOWN, + ] + assert all(result.error_code == "invalid_reference" for result in results) + mock_post.assert_not_called() + + def test_get_issues_status_preserves_success_when_later_batch_fails( + self, oauth_post, jira_response, jira_issue + ): + """Test that a failing later batch does not erase earlier results.""" + ok = jira_response( + 200, + {"issues": [jira_issue("1", "SEC-1", "Done", "done")]}, + ) + failed = jira_response(502, headers={"Retry-After": "12"}) + oauth_post.side_effect = [ok, failed] + + references = [JiraIssueReference(str(i), f"SEC-{i}") for i in range(1, 102)] + results = self.jira_integration.get_issues_status(references) + assert results[0].outcome == JiraIssueLookupOutcome.DONE + assert all( + result.outcome == JiraIssueLookupOutcome.UNKNOWN for result in results[1:] + ) + assert results[-1].http_status == 502 + assert results[-1].retry_after == "12" + assert oauth_post.call_count == 2 + + @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("prowler.lib.outputs.jira.jira.requests.post") + def test_get_issues_status_response_error( + self, mock_post, mock_cloud_id, mock_get_access_token + ): + """Test that a whole-batch error produces unknown without losing input.""" + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + mock_response = MagicMock(status_code=403, headers={}) + mock_response.text = "forbidden" + mock_post.return_value = mock_response + + reference = JiraIssueReference("10001", "SEC-1") + result = self.jira_integration.get_issues_status([reference])[0] + assert result.reference == reference + assert result.outcome == JiraIssueLookupOutcome.UNKNOWN + assert result.http_status == 403 + + @patch.object(Jira, "get_access_token", side_effect=JiraGetAccessTokenError()) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_get_issues_status_access_token_error_returns_unknown( + self, mock_post, mock_get_access_token + ): + reference = JiraIssueReference("10001", "SEC-1") + result = self.jira_integration.get_issues_status([reference])[0] + + assert result.outcome == JiraIssueLookupOutcome.UNKNOWN + assert result.error_code == "authentication_failed" + mock_get_access_token.assert_called_once_with() + mock_post.assert_not_called() + + @pytest.mark.parametrize( + "issues", + [ + [], + [{"id": "10001", "key": "SEC-1"}], + [ + {"id": "10001", "key": "SEC-1"}, + {"id": "10002", "key": "SEC-2"}, + ], + ], + ) + def test_search_issues_by_delivery_attempt_returns_all_matches( + self, oauth_post, jira_response, issues + ): + self.jira_integration._site_url = "https://example.atlassian.net" + oauth_post.return_value = jira_response(200, {"issues": issues}) + result = self.jira_integration.search_issues_by_delivery_attempt("attempt 123") + + assert result.outcome == JiraIssueSearchOutcome.SUCCESS + assert [match.issue_id for match in result.matches] == [ + issue["id"] for issue in issues + ] + assert all( + match.issue_url.endswith(match.issue_key) for match in result.matches + ) + assert oauth_post.call_args.args[0].endswith("/rest/api/3/search/jql") + assert oauth_post.call_args.kwargs["json"]["jql"] == ( + 'labels = "prowler-attempt-attempt_123"' + ) + + @pytest.mark.parametrize( + "response_kwargs, side_effect, expected_outcome, expected_code, retry_after", + [ + ( + {"status_code": 429, "headers": {"Retry-After": "9"}}, + None, + JiraIssueSearchOutcome.RETRYABLE_FAILURE, + "jira_http_429", + "9", + ), + ( + {"json_error": ValueError("invalid JSON")}, + None, + JiraIssueSearchOutcome.UNKNOWN, + "malformed_response", + None, + ), + ( + {}, + requests.exceptions.ReadTimeout(), + JiraIssueSearchOutcome.RETRYABLE_FAILURE, + "transport_failure", + None, + ), + ( + {"payload": {"issues": [], "nextPageToken": "repeated"}}, + None, + JiraIssueSearchOutcome.UNKNOWN, + "pagination_stalled", + None, + ), + ], + ) + def test_search_issues_by_delivery_attempt_classifies_failures( + self, + oauth_post, + jira_response, + response_kwargs, + side_effect, + expected_outcome, + expected_code, + retry_after, + ): + oauth_post.return_value = jira_response(**response_kwargs) + oauth_post.side_effect = side_effect + result = self.jira_integration.search_issues_by_delivery_attempt("attempt-123") + + assert result.outcome == expected_outcome + assert result.error_code == expected_code + assert result.retry_after == retry_after + + @patch.object(Jira, "get_access_token", side_effect=JiraGetAccessTokenError()) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_search_issues_by_delivery_attempt_access_token_error_returns_unknown( + self, mock_post, mock_get_access_token + ): + result = self.jira_integration.search_issues_by_delivery_attempt("attempt-123") + + assert result.outcome == JiraIssueSearchOutcome.UNKNOWN + assert result.error_code == "authentication_failed" + mock_get_access_token.assert_called_once_with() + mock_post.assert_not_called() + + @pytest.mark.parametrize( + "issue", + [ + {"id": "not-an-id", "key": "SEC-1"}, + {"id": "10001", "key": "invalid key"}, + ], + ) + def test_search_issues_by_delivery_attempt_rejects_invalid_identity( + self, oauth_post, jira_response, issue + ): + self.jira_integration._site_url = "https://example.atlassian.net" + oauth_post.return_value = jira_response(200, {"issues": [issue]}) + result = self.jira_integration.search_issues_by_delivery_attempt("attempt-123") + + assert result.outcome == JiraIssueSearchOutcome.UNKNOWN + assert result.error_code == "malformed_issue" + + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_captures_site_url(self, mock_get): + """Test that the OAuth cloud id lookup records the site URL.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"id": "cloud-1", "url": "https://oauth-site.atlassian.net"} + ] + mock_get.return_value = mock_response + + assert self.jira_integration.get_cloud_id("token") == "cloud-1" + assert self.jira_integration.site_url == "https://oauth-site.atlassian.net" + @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" @@ -2022,15 +2887,12 @@ class TestJiraIntegration: mock_get_access_token, ): """Test that Jira summary is sent as one line.""" - # 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 = MagicMock() - mock_response.status_code = 201 - mock_response.json.return_value = {"id": "ISSUE-123", "key": "TEST-123"} + mock_response = MagicMock(status_code=201, headers={}) + mock_response.json.return_value = {"id": "10001", "key": "TEST-123"} mock_post.return_value = mock_response long_check_id = "check\nwith\rcontrol\tcharacters " + "x" * 260 @@ -2045,7 +2907,7 @@ class TestJiraIntegration: grouped_resources=[], ) - assert result is True + assert result.issue_key == "TEST-123" payload = mock_post.call_args.kwargs["json"] expected_summary = ( f"[Prowler] HIGH - {' '.join(long_check_id.split())} - " @@ -2069,35 +2931,31 @@ class TestJiraIntegration: mock_cloud_id, mock_get_access_token, ): - """Test that send_finding raises with Jira JSON error details.""" - # To disable vulture + """Test that a definitive Jira 400 is a confirmed rejection.""" 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 = MagicMock(status_code=400, headers={}) mock_response.json.return_value = { "errors": {"Team": "Team is required."}, "errorMessages": ["Field 'Team' cannot be set."], } mock_post.return_value = mock_response - with pytest.raises(JiraSendFindingsResponseError) as error: - self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) + 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 "Failed to create Jira issue" in str(error.value) - assert "'Team': 'Team is required.'" in str(error.value) - assert "Field 'Team' cannot be set." in str(error.value) + assert result.outcome == JiraCreationOutcome.CONFIRMED_REJECTION + assert result.error_code == "jira_http_400" + assert "'Team': 'Team is required.'" in result.error_message + assert "Field 'Team' cannot be set." in result.error_message mock_post.assert_called_once() @patch.object(Jira, "get_access_token", return_value="valid_access_token") @@ -2115,30 +2973,27 @@ class TestJiraIntegration: mock_cloud_id, mock_get_access_token, ): - """Test send_finding raises with status-code context for non-JSON errors.""" - # To disable vulture + """Test a 5xx response is uncertain even without a JSON body.""" 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 = MagicMock() - mock_response.status_code = 502 + mock_response = MagicMock(status_code=502, headers={}) mock_response.json.side_effect = ValueError("No JSON body") mock_post.return_value = mock_response - with pytest.raises(JiraSendFindingsResponseError) as error: - self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) + 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 "Failed to create Jira issue" in str(error.value) - assert "Jira returned status code 502" in str(error.value) + assert result.outcome == JiraCreationOutcome.UNCERTAIN + assert result.error_code == "jira_http_502" + assert "Jira returned status code 502" in result.error_message mock_post.assert_called_once() @patch.object(Jira, "get_access_token", return_value="valid_access_token") @@ -2156,16 +3011,12 @@ class TestJiraIntegration: mock_cloud_id, mock_get_access_token, ): - """Test that send_finding raises when custom fields cause an error.""" - # To disable vulture + """Test that required custom fields are a confirmed rejection.""" 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 = MagicMock(status_code=400, headers={}) mock_response.json.return_value = { "errors": { "customfield_10001": "This custom field is required", @@ -2174,59 +3025,57 @@ class TestJiraIntegration: } mock_post.return_value = mock_response - with pytest.raises(JiraRequiredCustomFieldsError) as error: - self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) + 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 "Jira project requires custom fields" in str(error.value) - assert "customfield_10001" in str(error.value) + assert result.outcome == JiraCreationOutcome.CONFIRMED_REJECTION + assert "customfield_10001" in result.error_message mock_post.assert_called_once() - @patch.object( - Jira, - "get_access_token", - side_effect=JiraRefreshTokenError(message="Failed to refresh the access token"), + @pytest.mark.parametrize( + "access_token_error", + [ + JiraRefreshTokenError(message="Failed to refresh the access token"), + JiraGetAccessTokenError(message="Failed to get the access token"), + ], ) - def test_send_finding_reraises_refresh_token_error(self, mock_get_access_token): - """Test send_finding re-raises refresh token errors for API propagation.""" - # To disable vulture - mock_get_access_token = mock_get_access_token + @patch.object(Jira, "get_access_token") + def test_send_finding_access_token_errors_are_retryable( + self, mock_get_access_token, access_token_error + ): + """Test access-token failures before sending are retryable.""" + mock_get_access_token.side_effect = access_token_error + result = self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) - with pytest.raises(JiraRefreshTokenError) as error: - self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) - - assert error.value.message == "Failed to refresh the access token" + assert result.outcome == JiraCreationOutcome.RETRYABLE_FAILURE @patch.object(Jira, "get_access_token", return_value=None) def test_send_finding_reraises_no_token_error(self, mock_get_access_token): - """Test send_finding re-raises missing token errors for API propagation.""" - # To disable vulture + """Test missing credentials are a confirmed rejection.""" mock_get_access_token = mock_get_access_token + result = self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) - with pytest.raises(JiraNoTokenError) as error: - self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) - - assert error.value.message == "No token was found" + assert result.outcome == JiraCreationOutcome.CONFIRMED_REJECTION @patch.object( Jira, @@ -2238,25 +3087,19 @@ class TestJiraIntegration: def test_send_finding_reraises_refresh_token_response_error( self, mock_get_access_token ): - """Test send_finding re-raises refresh token response errors for API propagation.""" - # To disable vulture + """Test refresh response failures before sending are retryable.""" mock_get_access_token = mock_get_access_token - - with pytest.raises(JiraRefreshTokenResponseError) as error: - self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) - - assert ( - error.value.message - == "Failed to refresh the access token, response code did not match 200" + 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.outcome == JiraCreationOutcome.RETRYABLE_FAILURE + def test_get_headers_oauth_with_access_token(self): """Test get_headers returns correct OAuth headers with access token.""" self.jira_integration._using_basic_auth = False