mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
1087 lines
50 KiB
Python
1087 lines
50 KiB
Python
"""Integrations tools for Prowler MCP Server.
|
|
|
|
This module provides tools for managing where Prowler sends its results, including:
|
|
- Generic integration lifecycle (list, get, update, delete, connection check)
|
|
- Integration creation, with one tool per integration type
|
|
- Jira specific operations (available issue types, sending findings as work items)
|
|
"""
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from fastmcp.exceptions import ToolError
|
|
from pydantic import Field
|
|
|
|
from prowler_mcp_server.lib.errors import CredentialError, InvalidArgument
|
|
from prowler_mcp_server.lib.types import NonBlankStr
|
|
from prowler_mcp_server.prowler_app.models.integrations import (
|
|
DetailedIntegration,
|
|
IntegrationConnectionStatus,
|
|
IntegrationsListResponse,
|
|
JiraDispatchResult,
|
|
JiraIssueTypes,
|
|
)
|
|
from prowler_mcp_server.prowler_app.tools.base import BaseTool
|
|
from prowler_mcp_server.prowler_app.utils.api_client import ProwlerAPIError
|
|
|
|
# The configuration is deliberately left out of the list view, it belongs to the
|
|
# detailed view returned by prowler_get_integration
|
|
INTEGRATION_LIST_FIELDS = (
|
|
"enabled,connected,connection_last_checked_at,integration_type,providers,"
|
|
"inserted_at,updated_at"
|
|
)
|
|
|
|
CONNECTION_CHECK_TIMEOUT = 120
|
|
# One Jira work item is created per finding, sequentially, so this needs to be generous
|
|
JIRA_DISPATCH_TIMEOUT = 300
|
|
|
|
# The API replaces the whole credentials object, so a partial one destroys the rest
|
|
JIRA_REQUIRED_CREDENTIALS = ("domain", "user_mail", "api_token")
|
|
|
|
|
|
def _providers_relationship(provider_ids: list[str]) -> dict[str, Any]:
|
|
"""Build the JSON:API relationship linkage attaching an integration to providers."""
|
|
return {
|
|
"providers": {
|
|
"data": [
|
|
{"type": "providers", "id": provider_id} for provider_id in provider_ids
|
|
]
|
|
}
|
|
}
|
|
|
|
|
|
class IntegrationsTools(BaseTool):
|
|
"""Tools for integration management operations.
|
|
|
|
Provides tools for:
|
|
- prowler_list_integrations: List the configured integrations and their connection state
|
|
- prowler_get_integration: Get an integration with its full configuration
|
|
- prowler_create_amazon_s3_integration: Export scan outputs to an S3 bucket
|
|
- prowler_create_aws_security_hub_integration: Send findings to AWS Security Hub
|
|
- prowler_create_jira_integration: Connect a Jira site to open work items from findings
|
|
- prowler_update_integration: Change credentials, configuration, providers or enabled state
|
|
- prowler_delete_integration: Permanently remove an integration
|
|
- prowler_test_integration_connection: Check an integration connection and refresh its discovered configuration
|
|
- prowler_get_jira_issue_types: List the issue types available in a Jira project
|
|
- prowler_send_findings_to_jira: Create Jira work items for a set of findings
|
|
"""
|
|
|
|
async def list_integrations(
|
|
self,
|
|
integration_type: list[str] = Field(
|
|
default=[],
|
|
description="Filter by integration type(s). Valid values: 'amazon_s3' (export scan outputs to an S3 bucket), 'aws_security_hub' (send findings to AWS Security Hub), 'jira' (open Jira work items from findings). Leave empty to return every type.",
|
|
),
|
|
page_size: int = Field(
|
|
default=50, description="Number of results to return per page."
|
|
),
|
|
page_number: int = Field(
|
|
default=1, description="Page number to retrieve (1-indexed)"
|
|
),
|
|
) -> dict[str, Any]:
|
|
"""List the integrations configured in Prowler, with their connection state.
|
|
|
|
Integrations are the destinations Prowler sends its results to. They are configured
|
|
per tenant and require the 'manage_integrations' permission.
|
|
|
|
IMPORTANT: This tool returns LIGHTWEIGHT integrations without the integration-type
|
|
specific configuration. Use prowler_get_integration to get the full configuration,
|
|
such as the S3 bucket name or the available Jira projects.
|
|
|
|
Default behavior:
|
|
- Returns every integration type
|
|
- Returns 50 integrations per page. Tenants normally have a handful of them, so the
|
|
first page usually contains all of them
|
|
|
|
Each integration includes:
|
|
- Core identification: id (UUID for prowler_get_integration), integration_type
|
|
- State: enabled, connected (true, false, or null when never checked), connection_last_checked_at
|
|
- Scope: provider_ids, the providers the integration is attached to. Empty means it
|
|
applies to the whole tenant, which is always the case for Jira
|
|
- Temporal data: inserted_at, updated_at timestamps
|
|
|
|
NOTE: The API does not support filtering by 'enabled' or 'connected'. Read those
|
|
fields from the returned results instead.
|
|
|
|
Workflow:
|
|
1. Use this tool to see which integrations exist and whether they are working
|
|
2. Use prowler_get_integration with the 'id' to get the full configuration
|
|
3. Use prowler_test_integration_connection to re-check a broken integration
|
|
4. Use prowler_update_integration to fix credentials or settings
|
|
"""
|
|
self.logger.info("Listing integrations...")
|
|
self.api_client.validate_page_size(page_size)
|
|
|
|
params = {
|
|
"fields[integrations]": INTEGRATION_LIST_FIELDS,
|
|
"page[size]": page_size,
|
|
"page[number]": page_number,
|
|
}
|
|
|
|
if integration_type:
|
|
params["filter[integration_type__in]"] = integration_type
|
|
|
|
clean_params = self.api_client.build_filter_params(params)
|
|
api_response = await self.api_client.get("/integrations", params=clean_params)
|
|
|
|
simplified_response = IntegrationsListResponse.from_api_response(api_response)
|
|
return simplified_response.model_dump()
|
|
|
|
async def get_integration(
|
|
self,
|
|
integration_id: NonBlankStr = Field(
|
|
description="UUID of the integration to retrieve. Must be a valid UUID format (e.g., '019ac0d6-90d5-73e9-9acf-c22e256f1bac'). Use prowler_list_integrations to find it."
|
|
),
|
|
) -> dict[str, Any]:
|
|
"""Retrieve an integration with its complete, integration-type specific configuration.
|
|
|
|
IMPORTANT: Credentials are never returned by Prowler, only the configuration.
|
|
|
|
This tool provides ALL information that prowler_list_integrations returns PLUS the
|
|
'configuration' object, whose contents depend on the integration type:
|
|
- amazon_s3: 'bucket_name' and 'output_directory'
|
|
- aws_security_hub: 'send_only_fails', 'archive_previous_findings' and
|
|
'enabled_regions' (the AWS regions Security Hub is enabled in, discovered by the
|
|
connection check)
|
|
- jira: 'domain', 'projects' (a mapping of project key to project name) and
|
|
'issue_types' (a mapping of project key to its available issue types). Both are
|
|
discovered by the connection check, so an empty 'projects' means the connection
|
|
has not been checked yet
|
|
|
|
Workflow:
|
|
1. Use prowler_list_integrations to find the integration 'id'
|
|
2. Use this tool to read its configuration
|
|
3. For Jira, read 'projects' here before calling prowler_get_jira_issue_types
|
|
"""
|
|
self.logger.info(f"Retrieving integration {integration_id}...")
|
|
|
|
integration = await self._get_integration_raw(integration_id)
|
|
return DetailedIntegration.from_api_response(integration).model_dump()
|
|
|
|
async def create_amazon_s3_integration(
|
|
self,
|
|
bucket_name: NonBlankStr = Field(
|
|
description="Name of the S3 bucket where Prowler will upload the scan outputs (CSV, HTML, OCSF JSON and compliance reports)."
|
|
),
|
|
output_directory: str = Field(
|
|
default="output",
|
|
description='Directory inside the bucket where the outputs are written. Normalized server-side: leading slashes are stripped, the characters < > : " | ? * are rejected and the maximum length is 900 characters.',
|
|
),
|
|
provider_ids: list[str] = Field(
|
|
default=[],
|
|
description="Prowler UUIDs of the providers whose scan outputs are exported to this bucket. Use prowler_search_providers to find them. Leave empty to attach no provider yet.",
|
|
),
|
|
role_arn: NonBlankStr | None = Field(
|
|
default=None,
|
|
description="ARN of the IAM role Prowler assumes to write to the bucket (e.g. 'arn:aws:iam::123456789012:role/ProwlerS3Integration'). Recommended over static keys.",
|
|
),
|
|
external_id: NonBlankStr | None = Field(
|
|
default=None,
|
|
description="External ID required by the trust policy of the assumed role. In Prowler Cloud this is the tenant ID.",
|
|
),
|
|
role_session_name: NonBlankStr | None = Field(
|
|
default=None,
|
|
description="Identifier for the role session, useful to track it in AWS logs. Only letters, digits and the characters =,.@_- are allowed.",
|
|
),
|
|
session_duration: int = Field(
|
|
default=3600,
|
|
description="Duration of the assumed role session in seconds. Must be between 900 and 43200. Defaults to 3600 when omitted.",
|
|
),
|
|
aws_access_key_id: NonBlankStr | None = Field(
|
|
default=None,
|
|
description="AWS access key ID. Only needed when the Prowler deployment has no ambient AWS credentials.",
|
|
),
|
|
aws_secret_access_key: NonBlankStr | None = Field(
|
|
default=None,
|
|
description="AWS secret access key. Required when 'aws_access_key_id' is provided.",
|
|
),
|
|
aws_session_token: NonBlankStr | None = Field(
|
|
default=None,
|
|
description="AWS session token, only for temporary credentials.",
|
|
),
|
|
enabled: bool = Field(
|
|
default=True,
|
|
description="Whether the integration starts enabled. A disabled integration is never used after a scan.",
|
|
),
|
|
) -> dict[str, Any]:
|
|
"""Create an Amazon S3 integration to export scan outputs to an S3 bucket.
|
|
|
|
After every scan of an attached provider, Prowler uploads the generated reports to
|
|
's3://{bucket_name}/{output_directory}/'.
|
|
|
|
IMPORTANT: The connection is checked right after creation, and the result is part of
|
|
the response. The check writes and deletes a small test object in the bucket, so the
|
|
credentials need s3:PutObject, s3:ListBucket and s3:DeleteObject on it.
|
|
|
|
Default behavior:
|
|
- The integration is created enabled
|
|
- All credential parameters are optional: providing none sends empty credentials,
|
|
which makes Prowler use the ambient AWS credentials of the deployment. That only
|
|
works on self-hosted Prowler, Prowler Cloud requires a role or static keys
|
|
|
|
Example Input:
|
|
- IAM role (recommended):
|
|
```json
|
|
{
|
|
"bucket_name": "my-security-reports",
|
|
"output_directory": "prowler",
|
|
"provider_ids": ["019ac0d6-90d5-73e9-9acf-c22e256f1bac"],
|
|
"role_arn": "arn:aws:iam::123456789012:role/ProwlerS3Integration",
|
|
"external_id": "019ac0d6-90d5-73e9-9acf-c22e256f1bac"
|
|
}
|
|
```
|
|
- Static credentials:
|
|
```json
|
|
{
|
|
"bucket_name": "my-security-reports",
|
|
"aws_access_key_id": "AKIA...",
|
|
"aws_secret_access_key": "..."
|
|
}
|
|
```
|
|
|
|
Workflow:
|
|
1. Use prowler_search_providers to get the provider UUIDs to attach
|
|
2. Use this tool to create the integration
|
|
3. Read 'connected' in the response. If it is 'failed', read 'error', then fix the
|
|
bucket policy or the credentials with prowler_update_integration
|
|
"""
|
|
self.logger.info(f"Creating Amazon S3 integration for bucket {bucket_name}...")
|
|
|
|
credentials = self._build_aws_credentials(
|
|
role_arn=role_arn,
|
|
external_id=external_id,
|
|
role_session_name=role_session_name,
|
|
session_duration=session_duration,
|
|
aws_access_key_id=aws_access_key_id,
|
|
aws_secret_access_key=aws_secret_access_key,
|
|
aws_session_token=aws_session_token,
|
|
)
|
|
|
|
return await self._create_integration(
|
|
integration_type="amazon_s3",
|
|
configuration={
|
|
"bucket_name": bucket_name,
|
|
"output_directory": output_directory,
|
|
},
|
|
credentials=credentials,
|
|
provider_ids=provider_ids,
|
|
enabled=enabled,
|
|
)
|
|
|
|
async def create_aws_security_hub_integration(
|
|
self,
|
|
provider_id: NonBlankStr = Field(
|
|
description="Prowler UUID of the AWS provider whose findings are sent to Security Hub. It must be an AWS provider, and it can only have one Security Hub integration. Use prowler_search_providers with provider_type=['aws'] to find it."
|
|
),
|
|
send_only_fails: bool = Field(
|
|
default=False,
|
|
description="When true, only findings with FAIL status are sent to Security Hub. When false, passed findings are sent too.",
|
|
),
|
|
archive_previous_findings: bool = Field(
|
|
default=False,
|
|
description="When true, findings that are no longer present in the latest scan are archived in Security Hub.",
|
|
),
|
|
role_arn: NonBlankStr | None = Field(
|
|
default=None,
|
|
description="ARN of a dedicated IAM role Prowler assumes to write to Security Hub. Leave every credential parameter empty to reuse the credentials already stored for the provider, which is the recommended setup.",
|
|
),
|
|
external_id: NonBlankStr | None = Field(
|
|
default=None,
|
|
description="External ID required by the trust policy of the assumed role.",
|
|
),
|
|
role_session_name: NonBlankStr | None = Field(
|
|
default=None,
|
|
description="Identifier for the role session, useful to track it in AWS logs. Only letters, digits and the characters =,.@_- are allowed.",
|
|
),
|
|
session_duration: int | None = Field(
|
|
default=None,
|
|
description="Duration of the assumed role session in seconds. Must be between 900 and 43200. Defaults to 3600 when omitted.",
|
|
),
|
|
aws_access_key_id: NonBlankStr | None = Field(
|
|
default=None, description="AWS access key ID for dedicated credentials."
|
|
),
|
|
aws_secret_access_key: NonBlankStr | None = Field(
|
|
default=None,
|
|
description="AWS secret access key. Required when 'aws_access_key_id' is provided.",
|
|
),
|
|
aws_session_token: NonBlankStr | None = Field(
|
|
default=None,
|
|
description="AWS session token, only for temporary credentials.",
|
|
),
|
|
enabled: bool = Field(
|
|
default=True,
|
|
description="Whether the integration starts enabled. A disabled integration is never used after a scan.",
|
|
),
|
|
) -> dict[str, Any]:
|
|
"""Create an AWS Security Hub integration to send findings to Security Hub in ASFF format.
|
|
|
|
After every scan of the attached provider, Prowler pushes its findings to Security Hub
|
|
in every region where the Prowler partner integration is enabled.
|
|
|
|
IMPORTANT: The Prowler integration must be enabled in AWS Security Hub beforehand, in
|
|
each region where findings should land. The connection check performed right after
|
|
creation is what discovers those regions and fills 'enabled_regions'.
|
|
|
|
Default behavior:
|
|
- The integration is created enabled
|
|
- Leaving every credential parameter empty makes Prowler reuse the credentials already
|
|
stored for the provider. This is the recommended setup
|
|
- send_only_fails defaults to false, so passed findings are sent too
|
|
|
|
Constraints:
|
|
- Exactly one provider, and it must be an AWS provider
|
|
- A provider can only have one Security Hub integration. Creating a second one fails
|
|
with a conflict error
|
|
|
|
Workflow:
|
|
1. Use prowler_search_providers with provider_type=['aws'] to get the provider UUID
|
|
2. Use this tool to create the integration
|
|
3. Read 'enabled_regions' in the response configuration. If it is empty, the Prowler
|
|
integration is not enabled in Security Hub yet
|
|
"""
|
|
self.logger.info(
|
|
f"Creating AWS Security Hub integration for provider {provider_id}..."
|
|
)
|
|
|
|
credentials = self._build_aws_credentials(
|
|
role_arn=role_arn,
|
|
external_id=external_id,
|
|
role_session_name=role_session_name,
|
|
session_duration=session_duration,
|
|
aws_access_key_id=aws_access_key_id,
|
|
aws_secret_access_key=aws_secret_access_key,
|
|
aws_session_token=aws_session_token,
|
|
)
|
|
|
|
return await self._create_integration(
|
|
integration_type="aws_security_hub",
|
|
configuration={
|
|
"send_only_fails": send_only_fails,
|
|
"archive_previous_findings": archive_previous_findings,
|
|
},
|
|
credentials=credentials,
|
|
provider_ids=[provider_id],
|
|
enabled=enabled,
|
|
)
|
|
|
|
async def create_jira_integration(
|
|
self,
|
|
domain: NonBlankStr = Field(
|
|
description="Atlassian site name, without the '.atlassian.net' suffix. For the site 'https://acme.atlassian.net' the value is 'acme'. Full URLs are accepted and normalized automatically."
|
|
),
|
|
user_mail: NonBlankStr = Field(
|
|
description="Email address of the Atlassian account that owns the API token."
|
|
),
|
|
api_token: NonBlankStr = Field(
|
|
description="Atlassian API token, created from the account settings. It needs the 'read:jira-user', 'read:jira-work' and 'write:jira-work' scopes."
|
|
),
|
|
enabled: bool = Field(
|
|
default=True,
|
|
description="Whether the integration starts enabled. Findings cannot be sent to a disabled Jira integration.",
|
|
),
|
|
) -> dict[str, Any]:
|
|
"""Create a Jira integration to open Jira work items from Prowler findings.
|
|
|
|
Unlike the other integration types, Jira is tenant-wide: it is not attached to any
|
|
provider and applies to every finding the role can see.
|
|
|
|
IMPORTANT: Jira integrations do not send anything automatically. Work items are only
|
|
created on demand with prowler_send_findings_to_jira.
|
|
|
|
IMPORTANT: The connection is checked right after creation, and that check is what
|
|
discovers the available Jira projects. If 'connected' comes back 'failed', the
|
|
'projects' mapping stays empty and no finding can be dispatched.
|
|
|
|
Default behavior:
|
|
- The integration is created enabled
|
|
- The configuration is entirely server-generated: 'domain', 'projects' and 'issue_types'
|
|
|
|
Example Input:
|
|
```json
|
|
{
|
|
"domain": "acme",
|
|
"user_mail": "security@acme.com",
|
|
"api_token": "ATATT3xFfGF0..."
|
|
}
|
|
```
|
|
|
|
Workflow:
|
|
1. Use this tool to create the integration
|
|
2. Read 'projects' in the response configuration to pick a project key
|
|
3. Use prowler_get_jira_issue_types with that project key to pick an issue type
|
|
4. Use prowler_send_findings_to_jira to create the work items
|
|
"""
|
|
normalized_domain = self._normalize_atlassian_domain(domain)
|
|
self.logger.info(f"Creating Jira integration for domain {normalized_domain}...")
|
|
|
|
return await self._create_integration(
|
|
integration_type="jira",
|
|
# Jira rejects any configuration in the payload, the API generates it
|
|
configuration={},
|
|
credentials={
|
|
"domain": normalized_domain,
|
|
"user_mail": user_mail,
|
|
"api_token": api_token,
|
|
},
|
|
provider_ids=[],
|
|
enabled=enabled,
|
|
)
|
|
|
|
async def update_integration(
|
|
self,
|
|
integration_id: NonBlankStr = Field(
|
|
description="UUID of the integration to update. Use prowler_list_integrations to find it."
|
|
),
|
|
enabled: bool | None = Field(
|
|
default=None,
|
|
description="Enable (True) or disable (False) the integration. If not specified, the enabled state remains unchanged.",
|
|
),
|
|
provider_ids: list[str] | None = Field(
|
|
default=None,
|
|
description="Replace the providers this integration is attached to. Omit to keep the current ones. For 'amazon_s3' an empty list detaches every provider. For 'aws_security_hub' exactly one provider ID is required, since the integration cannot exist without one. Not accepted for Jira integrations, which are tenant-wide.",
|
|
),
|
|
configuration: (
|
|
dict[str, Any] | str | None
|
|
) = Field( # `str` accepted due to bad MCP Clients implementation
|
|
default=None,
|
|
description="Integration-type specific settings to change. Only the keys provided are modified, the rest of the configuration is preserved. For 'amazon_s3': 'bucket_name', 'output_directory'. For 'aws_security_hub': 'send_only_fails', 'archive_previous_findings'. Not accepted for 'jira', whose configuration is entirely server-generated.",
|
|
),
|
|
credentials: (
|
|
dict[str, Any] | str | None
|
|
) = Field( # `str` accepted due to bad MCP Clients implementation
|
|
default=None,
|
|
description="Replace the stored credentials. The whole object is replaced, so every needed key must be provided. For 'amazon_s3' and 'aws_security_hub': any of 'role_arn', 'external_id', 'role_session_name', 'session_duration', 'aws_access_key_id', 'aws_secret_access_key', 'aws_session_token'; an empty object clears them so Prowler falls back to the ambient or provider credentials. For 'jira': 'domain', 'user_mail' and 'api_token', all required, an empty or partial object is refused because it would destroy the stored credentials.",
|
|
),
|
|
) -> dict[str, Any]:
|
|
"""Update an integration's credentials, configuration, providers or enabled state.
|
|
|
|
The integration type cannot be changed. To switch types, delete the integration and
|
|
create a new one.
|
|
|
|
Default behavior:
|
|
- Only the parameters provided are changed, everything else is preserved
|
|
- 'configuration' is merged with the current one, so partial updates are safe
|
|
- When 'credentials', 'configuration' or the attached providers change, the connection
|
|
is re-checked and the result is part of the response. Toggling only 'enabled' does
|
|
not re-check it
|
|
|
|
Constraints:
|
|
- Jira integrations reject 'configuration' and 'provider_ids'. Sending a configuration
|
|
would wipe the discovered 'projects' and 'issue_types', so this tool refuses it
|
|
- Jira 'credentials' are replaced as a whole, so 'domain', 'user_mail' and 'api_token'
|
|
are all required. An empty or partial object is refused because it would destroy the
|
|
stored credentials
|
|
- Security Hub integrations must keep exactly one AWS provider, so 'provider_ids' has
|
|
to contain a single ID. Use prowler_delete_integration to stop sending findings
|
|
- The 'enabled_regions' of a Security Hub integration are server-owned and cannot be
|
|
set here, they are refreshed by the connection check
|
|
|
|
Workflow:
|
|
1. Use prowler_get_integration to read the current configuration
|
|
2. Use this tool with only the fields to change
|
|
3. Read 'connected' in the response to confirm the integration still works
|
|
"""
|
|
self.logger.info(f"Updating integration {integration_id}...")
|
|
|
|
current = DetailedIntegration.from_api_response(
|
|
await self._get_integration_raw(integration_id)
|
|
)
|
|
integration_type = current.integration_type
|
|
|
|
if provider_ids is not None:
|
|
if integration_type == "jira":
|
|
raise InvalidArgument(
|
|
"Jira integrations are tenant-wide and cannot be attached to providers."
|
|
)
|
|
if integration_type == "aws_security_hub" and len(provider_ids) != 1:
|
|
raise InvalidArgument(
|
|
"AWS Security Hub integrations must stay attached to exactly one AWS "
|
|
f"provider, got {len(provider_ids)}. Pass a single provider ID, or use "
|
|
"prowler_delete_integration to stop sending findings to Security Hub."
|
|
)
|
|
|
|
attributes: dict[str, Any] = {}
|
|
if enabled is not None:
|
|
attributes["enabled"] = enabled
|
|
|
|
if credentials is not None:
|
|
attributes["credentials"] = self._validate_credentials(
|
|
integration_type, self._as_dict(credentials, "credentials")
|
|
)
|
|
|
|
if configuration is not None:
|
|
if integration_type == "jira":
|
|
raise InvalidArgument(
|
|
"Jira integrations do not accept a configuration: it is generated by Prowler. "
|
|
"Update the credentials instead, or run prowler_test_integration_connection to "
|
|
"refresh the available projects and issue types."
|
|
)
|
|
merged = dict(current.configuration)
|
|
merged.update(self._as_dict(configuration, "configuration"))
|
|
# Server-owned, the API repopulates it from the connection check
|
|
merged.pop("regions", None)
|
|
merged.pop("enabled_regions", None)
|
|
attributes["configuration"] = merged
|
|
|
|
if not attributes and provider_ids is None:
|
|
self.logger.info("No changes provided, returning the current state")
|
|
return current.model_dump()
|
|
|
|
update_body: dict[str, Any] = {
|
|
"data": {
|
|
"type": "integrations",
|
|
"id": integration_id,
|
|
"attributes": attributes,
|
|
}
|
|
}
|
|
if provider_ids is not None:
|
|
update_body["data"]["relationships"] = _providers_relationship(provider_ids)
|
|
|
|
await self.api_client.patch(
|
|
f"/integrations/{integration_id}", json_data=update_body
|
|
)
|
|
|
|
# A different provider means different effective credentials and different
|
|
# discovered configuration, so the stored connection state is stale too
|
|
providers_changed = provider_ids is not None and set(provider_ids) != set(
|
|
current.provider_ids
|
|
)
|
|
recheck_connection = (
|
|
credentials is not None or configuration is not None or providers_changed
|
|
)
|
|
connection_status = (
|
|
await self._test_connection(integration_id) if recheck_connection else None
|
|
)
|
|
|
|
updated = await self._get_integration_raw(integration_id)
|
|
if connection_status is not None:
|
|
return IntegrationConnectionStatus.create(
|
|
updated, connection_status
|
|
).model_dump()
|
|
return DetailedIntegration.from_api_response(updated).model_dump()
|
|
|
|
async def delete_integration(
|
|
self,
|
|
integration_id: NonBlankStr = Field(
|
|
description="UUID of the integration to permanently remove. Use prowler_list_integrations to find it."
|
|
),
|
|
) -> dict[str, Any]:
|
|
"""Permanently remove an integration from Prowler.
|
|
|
|
WARNING: This is a destructive operation that cannot be undone. The stored credentials
|
|
are destroyed with it, so the integration has to be recreated from scratch, with its
|
|
credentials, to be used again.
|
|
|
|
Deletion behavior:
|
|
- Prowler stops sending results to this destination immediately
|
|
- Data already exported stays where it is: objects in S3, findings in Security Hub and
|
|
work items in Jira are not removed
|
|
- To pause an integration instead, use prowler_update_integration with enabled=False
|
|
|
|
Workflow:
|
|
1. Use prowler_get_integration to review what will be deleted
|
|
2. Use this tool to permanently remove it
|
|
3. Verify with prowler_list_integrations (it should no longer appear)
|
|
"""
|
|
self.logger.info(f"Deleting integration {integration_id}...")
|
|
|
|
await self.api_client.delete(f"/integrations/{integration_id}")
|
|
# No `deleted` flag: an integration that was not deleted leaves this tool
|
|
# as an error, so the flag could only ever be True and a reader branching
|
|
# on it would be looking for a shape that does not exist.
|
|
return {"message": f"Integration {integration_id} deleted successfully"}
|
|
|
|
async def test_integration_connection(
|
|
self,
|
|
integration_id: NonBlankStr = Field(
|
|
description="UUID of the integration to check. Use prowler_list_integrations to find it."
|
|
),
|
|
) -> dict[str, Any]:
|
|
"""Check that Prowler can reach an integration with its stored credentials.
|
|
|
|
This also refreshes the parts of the configuration that Prowler discovers from the
|
|
remote system, so it is the way to repair a stale configuration:
|
|
- jira: repopulates 'projects' and 'issue_types'
|
|
- aws_security_hub: repopulates 'enabled_regions'
|
|
|
|
IMPORTANT: A disabled integration is never checked. It comes back as 'failed' with the
|
|
error 'Integration is not enabled'. Enable it first with prowler_update_integration.
|
|
|
|
The check runs as a background task and this tool waits for it, so it can take a few
|
|
seconds to return.
|
|
|
|
Workflow:
|
|
1. Use prowler_list_integrations to spot integrations with connected=false
|
|
2. Use this tool to re-check one after fixing its permissions on the remote side
|
|
3. If it still fails, read 'error' and fix the credentials with prowler_update_integration
|
|
"""
|
|
self.logger.info(f"Checking connection of integration {integration_id}...")
|
|
|
|
connection_status = await self._test_connection(integration_id)
|
|
integration = await self._get_integration_raw(integration_id)
|
|
|
|
return IntegrationConnectionStatus.create(
|
|
integration, connection_status
|
|
).model_dump()
|
|
|
|
async def get_jira_issue_types(
|
|
self,
|
|
integration_id: NonBlankStr = Field(
|
|
description="UUID of the Jira integration. Use prowler_list_integrations with integration_type=['jira'] to find it."
|
|
),
|
|
project_key: NonBlankStr = Field(
|
|
description="Key of the Jira project to read the issue types from (e.g. 'PROJ'). It must be one of the keys in the 'projects' mapping of the integration configuration."
|
|
),
|
|
) -> dict[str, Any]:
|
|
"""List the issue types available in a Jira project.
|
|
|
|
Prowler fetches them live from Jira and stores them in the integration configuration,
|
|
so the answer is always current.
|
|
|
|
IMPORTANT: The project key must already be present in the 'projects' mapping of the
|
|
integration configuration. That mapping is discovered by the connection check, so run
|
|
prowler_test_integration_connection first if it is empty.
|
|
|
|
NOTE: Issue types that require custom fields Prowler does not fill, such as Epic, will
|
|
be listed here but fail when actually creating the work item. Prefer Task, Bug or Story.
|
|
|
|
Workflow:
|
|
1. Use prowler_get_integration to read the 'projects' mapping and pick a project key
|
|
2. Use this tool to get the valid issue types for that project
|
|
3. Use prowler_send_findings_to_jira with the chosen project key and issue type
|
|
"""
|
|
self.logger.info(
|
|
f"Fetching Jira issue types of project {project_key} for integration {integration_id}..."
|
|
)
|
|
|
|
api_response = await self.api_client.get(
|
|
f"/integrations/{integration_id}/jira/issue_types",
|
|
params={"project_key": project_key},
|
|
)
|
|
|
|
issue_types = JiraIssueTypes.from_api_response(api_response.get("data", {}))
|
|
return issue_types.model_dump()
|
|
|
|
async def send_findings_to_jira(
|
|
self,
|
|
integration_id: NonBlankStr = Field(
|
|
description="UUID of the Jira integration to send the findings through. It must be enabled."
|
|
),
|
|
project_key: NonBlankStr = Field(
|
|
description="Key of the Jira project the work items are created in (e.g. 'PROJ'). It must be one of the keys in the 'projects' mapping of the integration configuration."
|
|
),
|
|
issue_type: NonBlankStr = Field(
|
|
description="Jira issue type for the created work items (e.g. 'Task', 'Bug', 'Story'). It must be one of the values returned by prowler_get_jira_issue_types for this project."
|
|
),
|
|
finding_ids: list[str] = Field(
|
|
description="UUIDs of the findings to send. One Jira work item is created per finding. Get them from prowler_search_security_findings. Must contain at least one ID."
|
|
),
|
|
) -> dict[str, Any]:
|
|
"""Create Jira work items for a set of findings.
|
|
|
|
Each work item carries the finding's check title, severity, status, provider, region,
|
|
resource, risk description and remediation steps.
|
|
|
|
WARNING: This creates real work items in Jira. Prowler cannot delete or update them
|
|
afterwards, they have to be handled in Jira. Only call this again for the same findings
|
|
when the previous response had safe_to_retry=true, otherwise it creates duplicates.
|
|
|
|
WARNING: Avoid issue types that require custom fields Prowler does not fill, such as
|
|
Epic. Creation fails for those. Task, Bug and Story normally work.
|
|
|
|
Default behavior:
|
|
- One work item per finding, created sequentially, so large batches take a while
|
|
- The dispatch runs as a background task and this tool waits up to 5 minutes for it.
|
|
If it is still running by then, the response has status='in_progress', an 'error'
|
|
explaining it, and the 'task_id'
|
|
|
|
The result includes:
|
|
- status: 'completed' when Prowler finished the dispatch, 'in_progress' when the task
|
|
is still running, 'failed' when the dispatch was rejected before it started,
|
|
'unknown' when the task stopped without reporting a result
|
|
- safe_to_retry: whether the dispatch can be sent again. It is only true when no work
|
|
item was created, which is the case when the dispatch was rejected before it
|
|
started. NEVER call this tool again for the same findings when it is false, the
|
|
work items already created would be duplicated. Report the outcome to the user and
|
|
let them check Jira instead
|
|
- created_count: number of work items created in Jira, absent unless status='completed'
|
|
- failed_count: number of findings that could not be sent, absent unless
|
|
status='completed'
|
|
|
|
Workflow:
|
|
1. Use prowler_search_security_findings to select the findings to escalate
|
|
2. Use prowler_get_integration to read the 'projects' mapping and pick a project key
|
|
3. Use prowler_get_jira_issue_types to pick a valid issue type
|
|
4. Use this tool with the finding IDs
|
|
"""
|
|
try:
|
|
if not finding_ids:
|
|
raise ValueError(
|
|
"At least one finding ID is required. Use prowler_search_security_findings to get them."
|
|
)
|
|
|
|
self.logger.info(
|
|
f"Sending {len(finding_ids)} finding(s) to Jira project {project_key}..."
|
|
)
|
|
|
|
dispatch_body = {
|
|
"data": {
|
|
"type": "integrations-jira-dispatches",
|
|
"attributes": {
|
|
"project_key": project_key,
|
|
"issue_type": issue_type,
|
|
},
|
|
}
|
|
}
|
|
params = self.api_client.build_filter_params(
|
|
{"filter[finding_id__in]": finding_ids}
|
|
)
|
|
|
|
task_response = await self.api_client.post(
|
|
f"/integrations/{integration_id}/jira/dispatches",
|
|
params=params,
|
|
json_data=dispatch_body,
|
|
)
|
|
except ValueError as e:
|
|
# Refused here, so the request never went out
|
|
self.logger.error(f"Jira dispatch was refused before the request: {e}")
|
|
return self._jira_dispatch_rejected(str(e))
|
|
except ProwlerAPIError as e:
|
|
# Only a client error is a refusal: the API validates the dispatch and
|
|
# then queues the background task before serializing its answer, so a
|
|
# server error may well come back with work items already being created
|
|
if e.status_code >= 500:
|
|
self.logger.error(f"Jira dispatch failed on the server: {e}")
|
|
return self._jira_dispatch_unknown(
|
|
task_id=None,
|
|
error=(
|
|
"the request that starts the dispatch failed on Prowler's side. "
|
|
"It may have been queued anyway."
|
|
),
|
|
)
|
|
|
|
self.logger.error(f"Jira dispatch was rejected by Prowler: {e}")
|
|
return self._jira_dispatch_rejected(str(e))
|
|
except CredentialError:
|
|
# Authentication happens before the request goes out, so nothing was
|
|
# queued. It is raised rather than reported as a dispatch outcome:
|
|
# there is no partial state to describe, and the shared classifier
|
|
# says what has to be fixed, which no retry of this call can.
|
|
raise
|
|
except Exception as e:
|
|
# No answer came back, so the request may still have been accepted
|
|
self.logger.error(f"Jira dispatch could not be started: {e}")
|
|
return self._jira_dispatch_unknown(
|
|
task_id=None,
|
|
error=(
|
|
"the request that starts the dispatch got no answer. "
|
|
"It may have been accepted anyway."
|
|
),
|
|
)
|
|
|
|
task_id = task_response.get("data", {}).get("id")
|
|
if not task_id:
|
|
self.logger.error("Jira dispatch response did not include a task ID")
|
|
return self._jira_dispatch_unknown(
|
|
task_id=None,
|
|
error="Prowler accepted the dispatch but did not return the ID of the background task, so its outcome cannot be checked.",
|
|
)
|
|
|
|
try:
|
|
completed_task = await self.api_client.poll_task_until_complete(
|
|
task_id=task_id, timeout=JIRA_DISPATCH_TIMEOUT, poll_interval=2.0
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"Jira dispatch did not complete cleanly: {e}")
|
|
return await self._jira_dispatch_fallback(task_id, str(e))
|
|
|
|
try:
|
|
return JiraDispatchResult.from_task_result(
|
|
completed_task.get("data", {}).get("attributes", {}).get("result")
|
|
).model_dump()
|
|
except ValueError as e:
|
|
self.logger.error(f"Jira dispatch result could not be read: {e}")
|
|
return self._jira_dispatch_unknown(task_id, str(e))
|
|
|
|
# Private helper methods
|
|
|
|
def _build_aws_credentials(
|
|
self,
|
|
role_arn: str | None = None,
|
|
external_id: str | None = None,
|
|
role_session_name: str | None = None,
|
|
session_duration: int | None = None,
|
|
aws_access_key_id: str | None = None,
|
|
aws_secret_access_key: str | None = None,
|
|
aws_session_token: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build the AWS credentials object, leaving out the values not provided.
|
|
|
|
An empty result is valid: it makes Prowler fall back to the ambient AWS credentials
|
|
of the deployment, or to the credentials stored for the provider in the case of
|
|
Security Hub.
|
|
"""
|
|
credentials = {
|
|
"role_arn": role_arn,
|
|
"external_id": external_id,
|
|
"role_session_name": role_session_name,
|
|
"session_duration": session_duration,
|
|
"aws_access_key_id": aws_access_key_id,
|
|
"aws_secret_access_key": aws_secret_access_key,
|
|
"aws_session_token": aws_session_token,
|
|
}
|
|
return {key: value for key, value in credentials.items() if value is not None}
|
|
|
|
def _normalize_atlassian_domain(self, domain: str) -> str:
|
|
"""Reduce a Jira site URL to the bare Atlassian site name.
|
|
|
|
The API only accepts the site name, so 'https://acme.atlassian.net/jira' has to be
|
|
sent as 'acme'.
|
|
"""
|
|
normalized = domain.strip()
|
|
normalized = normalized.split("://", 1)[-1]
|
|
normalized = normalized.split("/", 1)[0]
|
|
normalized = normalized.removesuffix(".atlassian.net")
|
|
|
|
if not normalized:
|
|
raise InvalidArgument(
|
|
f"Invalid Jira domain: {domain}. Provide the Atlassian site name, for example "
|
|
"'acme' for the site 'https://acme.atlassian.net'."
|
|
)
|
|
return normalized
|
|
|
|
def _validate_credentials(
|
|
self, integration_type: str, credentials: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
"""Check that replacing the credentials leaves the integration usable.
|
|
|
|
The API replaces the stored credentials with whatever is sent, so an empty or partial
|
|
object silently destroys them. That is only acceptable for the AWS integration types,
|
|
where no credentials means falling back to the ambient or provider ones.
|
|
"""
|
|
if integration_type != "jira":
|
|
return credentials
|
|
|
|
missing = [
|
|
key
|
|
for key in JIRA_REQUIRED_CREDENTIALS
|
|
if not isinstance(credentials.get(key), str) or not credentials[key].strip()
|
|
]
|
|
if missing:
|
|
raise InvalidArgument(
|
|
"Jira credentials are replaced as a whole, so 'domain', 'user_mail' and "
|
|
f"'api_token' are all required. Missing or empty: {', '.join(missing)}. "
|
|
"Sending an incomplete object would destroy the stored credentials and break "
|
|
"the integration."
|
|
)
|
|
|
|
return {
|
|
**credentials,
|
|
"domain": self._normalize_atlassian_domain(credentials["domain"]),
|
|
}
|
|
|
|
def _as_dict(self, value: dict[str, Any] | str, param_name: str) -> dict[str, Any]:
|
|
"""Accept a JSON object sent as a string by clients that cannot pass objects."""
|
|
if isinstance(value, str):
|
|
try:
|
|
value = json.loads(value)
|
|
except json.JSONDecodeError as e:
|
|
raise InvalidArgument(f"Invalid JSON for {param_name}: {e}") from e
|
|
|
|
if not isinstance(value, dict):
|
|
raise InvalidArgument(f"{param_name} must be a JSON object.")
|
|
return value
|
|
|
|
async def _get_integration_raw(self, integration_id: str) -> dict[str, Any]:
|
|
"""Fetch the raw JSON:API resource of an integration.
|
|
|
|
Raises:
|
|
ToolError: If the payload does not contain a usable integration resource.
|
|
Raised without a ``from`` clause because these messages name the
|
|
integration and the tool that lists valid IDs, and the two cases
|
|
are reported differently: a missing resource is the caller's
|
|
mistake, a resource without attributes is the API's.
|
|
"""
|
|
response = await self.api_client.get(f"/integrations/{integration_id}")
|
|
integration = response.get("data")
|
|
|
|
if not isinstance(integration, dict) or not integration.get("id"):
|
|
raise ToolError(
|
|
f"Integration {integration_id} was not found. Use prowler_list_integrations "
|
|
"to get a valid integration ID."
|
|
)
|
|
|
|
if not isinstance(integration.get("attributes"), dict):
|
|
raise ToolError(
|
|
f"Prowler returned integration {integration_id} without its attributes, so "
|
|
"its state cannot be read."
|
|
)
|
|
|
|
return integration
|
|
|
|
async def _create_integration(
|
|
self,
|
|
integration_type: str,
|
|
configuration: dict[str, Any],
|
|
credentials: dict[str, Any],
|
|
provider_ids: list[str],
|
|
enabled: bool,
|
|
) -> dict[str, Any]:
|
|
"""Create an integration and report the outcome of its connection check.
|
|
|
|
The check is always run: for Jira and Security Hub it is what discovers the projects
|
|
and the enabled regions, so without it the integration is not usable.
|
|
"""
|
|
create_body: dict[str, Any] = {
|
|
"data": {
|
|
"type": "integrations",
|
|
"attributes": {
|
|
"integration_type": integration_type,
|
|
"configuration": configuration,
|
|
"credentials": credentials,
|
|
"enabled": enabled,
|
|
},
|
|
}
|
|
}
|
|
if provider_ids:
|
|
create_body["data"]["relationships"] = _providers_relationship(provider_ids)
|
|
|
|
api_response = await self.api_client.post(
|
|
"/integrations", json_data=create_body
|
|
)
|
|
integration_id = api_response.get("data", {}).get("id")
|
|
|
|
if not integration_id:
|
|
# The integration may well exist, so this must not read as "nothing
|
|
# happened" and invite a duplicate.
|
|
raise ToolError(
|
|
"Prowler accepted the integration creation but did not return its ID, so the "
|
|
"connection could not be checked. Use prowler_list_integrations to see whether "
|
|
"the integration exists before creating it again."
|
|
)
|
|
|
|
connection_status = await self._test_connection(integration_id)
|
|
|
|
try:
|
|
integration = await self._get_integration_raw(integration_id)
|
|
except Exception as e:
|
|
# The integration exists, so surface its ID instead of a plain read
|
|
# failure. No `from` clause: a cause would let the shared classifier
|
|
# replace this with a sentence that does not mention the ID. The
|
|
# failure text stays in the log, where the classifier would keep it.
|
|
self.logger.error(
|
|
f"Integration {integration_id} could not be read back: {e}"
|
|
)
|
|
raise ToolError(
|
|
f"Integration {integration_id} was created, but reading its state failed. "
|
|
"Use prowler_get_integration with that ID to check it."
|
|
)
|
|
|
|
return IntegrationConnectionStatus.create(
|
|
integration, connection_status
|
|
).model_dump()
|
|
|
|
async def _test_connection(self, integration_id: str) -> dict[str, Any]:
|
|
"""Run the connection check of an integration and wait for its result.
|
|
|
|
A check that could not be run is reported as 'connected: None' rather than a failure:
|
|
a disabled integration or wrong credentials come back as a completed task with
|
|
'connected: False', so an exception here only means the outcome is unknown.
|
|
|
|
Returns:
|
|
Connection status dictionary with a 'connected' boolean or None, and an optional
|
|
'error'
|
|
"""
|
|
self.logger.info(f"Testing connection for integration {integration_id}...")
|
|
try:
|
|
task_response = await self.api_client.post(
|
|
f"/integrations/{integration_id}/connection", json_data={}
|
|
)
|
|
task_id = task_response.get("data", {}).get("id")
|
|
|
|
if not task_id:
|
|
raise ValueError(
|
|
"Prowler did not return the ID of the connection check task."
|
|
)
|
|
|
|
completed_task = await self.api_client.poll_task_until_complete(
|
|
task_id=task_id, timeout=CONNECTION_CHECK_TIMEOUT, poll_interval=1.0
|
|
)
|
|
result = completed_task.get("data", {}).get("attributes", {}).get("result")
|
|
|
|
if not isinstance(result, dict):
|
|
raise ValueError(
|
|
"The connection check task completed without reporting a result."
|
|
)
|
|
|
|
return result
|
|
except Exception as e:
|
|
self.logger.error(f"Connection check could not be completed: {e}")
|
|
return {
|
|
"connected": None,
|
|
"error": (
|
|
"The connection check could not be completed. This says nothing "
|
|
"about the stored credentials, run prowler_test_integration_connection "
|
|
"to check them again."
|
|
),
|
|
}
|
|
|
|
async def _jira_dispatch_fallback(self, task_id: str, error: str) -> dict[str, Any]:
|
|
"""Report a Jira dispatch whose polling did not end on a completed task.
|
|
|
|
The dispatch is never safe to retry here. Work items are created one by one, so a task
|
|
that failed or was cancelled halfway may already have created some of them, and a task
|
|
that is still running is creating them right now. The task state only decides how the
|
|
outcome is described.
|
|
"""
|
|
state = None
|
|
try:
|
|
task = await self.api_client.get(f"/tasks/{task_id}")
|
|
state = task.get("data", {}).get("attributes", {}).get("state")
|
|
except Exception as e:
|
|
self.logger.error(f"Could not read the state of task {task_id}: {e}")
|
|
|
|
if state in ("failed", "cancelled"):
|
|
return self._jira_dispatch_unknown(
|
|
task_id,
|
|
f"The dispatch task ended as '{state}' before reporting a result. "
|
|
f"Original error: {error}",
|
|
)
|
|
|
|
return JiraDispatchResult(
|
|
status="in_progress",
|
|
safe_to_retry=False,
|
|
error=(
|
|
f"The dispatch is still running, so some work items may already exist in Jira. "
|
|
f"Do not send these findings again. Original error: {error}"
|
|
),
|
|
task_id=task_id,
|
|
).model_dump()
|
|
|
|
def _jira_dispatch_rejected(self, error: str) -> dict[str, Any]:
|
|
"""Report a dispatch that was refused before any work item could be created.
|
|
|
|
This is the only outcome safe to retry, and it is reserved for the failures
|
|
that prove nothing was queued: a validation error raised here, or a client
|
|
error from the API, which rejects the dispatch before starting its task.
|
|
"""
|
|
return JiraDispatchResult(
|
|
status="failed", safe_to_retry=True, error=error
|
|
).model_dump()
|
|
|
|
def _jira_dispatch_unknown(self, task_id: str | None, error: str) -> dict[str, Any]:
|
|
"""Report a dispatch whose outcome Prowler cannot determine.
|
|
|
|
Work items are created one by one, so an outcome that cannot be read is never safe to
|
|
retry: the dispatch may have created any number of them before stopping.
|
|
"""
|
|
return JiraDispatchResult(
|
|
status="unknown",
|
|
safe_to_retry=False,
|
|
error=(
|
|
f"Prowler cannot tell how many Jira work items were created: {error} "
|
|
"Check the Jira project before sending these findings again."
|
|
),
|
|
task_id=task_id,
|
|
).model_dump()
|