Files
prowler/mcp_server/prowler_mcp_server/prowler_app/tools/providers.py
T

751 lines
31 KiB
Python

"""Provider Management tools for Prowler MCP Server.
This module provides tools for managing provider connections,
including searching, connecting, and deleting providers.
"""
from typing import Any
from fastmcp.exceptions import ToolError
from pydantic import Field
from prowler_mcp_server.lib.errors import InvalidArgument
from prowler_mcp_server.lib.types import NonBlankStr
from prowler_mcp_server.prowler_app.models.providers import (
ProviderConnectionStatus,
ProviderDeletionResult,
ProvidersListResponse,
)
from prowler_mcp_server.prowler_app.tools.base import BaseTool
class ProvidersTools(BaseTool):
"""Tools for provider management operations
Provides tools for:
- prowler_search_providers: Search and view configured providers with their connection status
- prowler_connect_provider: Connect or register a provider for security scanning in Prowler
- prowler_delete_provider: Permanently remove a provider from Prowler
"""
async def search_providers(
self,
provider_id: list[str] = Field(
default=[],
description="Filter by Prowler's internal UUID(s) (v4) for the provider(s), generated when the provider is registered in the system.",
),
provider_uid: list[str] = Field(
default=[],
description="Filter by provider's unique identifier(s), this ID is the one provided by the provider itself. Format varies by provider type: AWS Account ID (12 digits), Azure Subscription ID (UUID), GCP Project ID (string), Kubernetes namespace, GitHub username/organization, M365 domain ID, etc. All supported provider types are listed in the Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server",
),
provider_type: list[str] = Field(
default=[],
description="Filter by provider type. Valid values include: 'aws', 'azure', 'gcp', 'kubernetes'... For more valid values, please refer to Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server.",
),
alias: str | None = Field(
default=None,
description="Search by provider alias/friendly name. Partial match supported (case-insensitive). Use this to find providers by their human-readable name (e.g., 'Production', 'Dev', 'AWS Main')",
),
connected: (
bool | str | None
) = Field( # Wrong `str` hint type due to bad MCP Clients implementation
default=None,
description="Filter by connection status. True returns only successfully connected providers (credentials work), False returns only providers with failed connections (credentials invalid). If not specified, returns all connected, failed and not tested providers. Strings 'true' and 'false' are also accepted.",
),
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]:
"""Search and view configured providers to be scanned with Prowler.
This tool returns a unified view of all providers configured in Prowler.
For getting more details about what types of providers are available to be scanned with Prowler or
what are the UIDs are accepted for each provider type, please refer to Prowler Hub/Prowler Documentation
that you can also find in form of tools in this MCP Server.
Each provider includes:
- Provider identification: Prowler Internal ID, External Provider UID, Provider Alias
- Provider context: Provider Type
- Connection status: Connected (true), Failed (false), Not Tested (null)
"""
self.api_client.validate_page_size(page_size)
params = {
"fields[providers]": "uid,alias,provider,connection,secret",
"page[number]": page_number,
"page[size]": page_size,
}
# Build filter parameters
if provider_id:
params["filter[id__in]"] = provider_id
if provider_uid:
params["filter[uid__in]"] = provider_uid
if provider_type:
params["filter[provider__in]"] = provider_type
if alias:
params["filter[alias__icontains]"] = alias
if connected is not None:
if isinstance(connected, bool):
params["filter[connected]"] = connected
else:
if connected.lower() == "true":
params["filter[connected]"] = True
elif connected.lower() == "false":
params["filter[connected]"] = False
else:
raise InvalidArgument(
f"Invalid connected value: {connected}. Valid values are True, False, 'true', 'false' or None."
)
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get("/providers", params=clean_params)
simplified_response = ProvidersListResponse.from_api_response(api_response)
# Fetch secret_type for each provider that has a secret
for provider in simplified_response.providers:
# Get the provider data from the API response to access relationships
provider_data = next(
(
provider_api_response
for provider_api_response in api_response["data"]
if provider_api_response["id"] == provider.id
),
None,
)
if provider_data:
secret_relationship = provider_data.get("relationships", {}).get(
"secret", {}
)
secret_data = secret_relationship.get("data")
if secret_data:
secret_id = secret_data["id"]
provider.secret_type = await self._get_secret_type(secret_id)
return simplified_response.model_dump()
async def connect_provider(
self,
provider_uid: NonBlankStr = Field(
description="Provider's unique identifier. For supported UID provider formats, please refer to Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server"
),
provider_type: NonBlankStr = Field(
description="Type of provider to be scanned with Prowler. Valid values include: 'aws', 'azure', 'gcp', 'kubernetes'... For more valid values, please refer to Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server."
),
alias: NonBlankStr | None = Field(
default=None,
description="Human-friendly name for this provider. Optional but recommended for easy identification. Use descriptive names to distinguish multiple accounts of the same type.",
),
credentials: dict[str, Any] | None = Field(
default=None,
description="Provider-specific credentials for authentication. Optional - if not provided, provider is created but not connected. Structure varies by provider type. For supported provider types, please refer to Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server",
),
) -> dict[str, Any]:
"""Register a provider to be scanned with Prowler.
This tool will register a provider in Prowler, even if the UID is wrong.
If the provider is already registered, it will be updated with the new provided alias or credentials if provided.
If credentials are provided, they will be added to the indicated provider, if the provider does not exist, it will be created and the credentials will be added to it.
If the connection test is successful, the provider will be connected.
If the connection test fails, the provider will be created but not connected.
The tool always returns the provider details after its registration or update.
Example Input:
- AWS Static Credentials:
```json
{
"provider_uid": "123456789012",
"provider_type": "aws",
"alias": "production-aws-account",
"credentials": {
"aws_access_key_id": "AKIA...",
"aws_secret_access_key": "...",
"aws_session_token": "..."
}
}
```
- AWS Assume Role:
```json
{
"provider_uid": "987654321098",
"provider_type": "aws",
"alias": "staging-aws-account",
"credentials": {
"role_arn": "arn:aws:iam::987654321098:role/ProwlerScanRole",
"external_id": "...",
"aws_access_key_id": "AKIA...", # Optional
"aws_secret_access_key": "...", # Optional
"aws_session_token": "...", # Optional
"session_duration": 3600, # Optional
"role_session_name": "..." # Optional
}
}
```
- Azure/M365 Static Credentials:
```json
{
"provider_uid": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
"provider_type": "azure",
"alias": "production-azure-subscription",
"credentials": {
"client_id": "...",
"client_secret": "...",
"tenant_id": "..."
}
}
```
- GCP Service Account Account Key:
```json
{
"provider_uid": "my-gcp-project-prod",
"provider_type": "gcp",
"alias": "production-gcp-project",
"credentials": {
"service_account_key": {
"type": "service_account",
"project_id": "...",
"private_key_id": "...",
"private_key": "...",
"client_email": "...",
}
}
}
```
- Kubernetes Static Credentials:
```json
{
"provider_uid": "prod-k8s-cluster",
"provider_type": "kubernetes",
"alias": "production-kubernetes-cluster",
"credentials": {
"kubeconfig_content": "..."
}
}
```
- GitHub OAuth App Token:
```json
{
"provider_uid": "my-organization",
"provider_type": "github",
"alias": "my-github-organization",
"credentials": {
"oauth_app_token": "..."
}
}
NOTE: THERE ARE MORE PROVIDER TYPES AND CREDENTIAL TYPES AVAILABLE, PLEASE REFER TO THE Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server.
"""
# Step 1: Check if provider already exists
prowler_provider_id = await self._check_provider_exists(provider_uid)
# Step 2: Create or update provider
if prowler_provider_id is None:
prowler_provider_id = await self._create_provider(
provider_uid, provider_type, alias
)
elif alias:
await self._update_provider_alias(prowler_provider_id, alias)
# Step 3: Handle credentials if provided and capture secret response
secret_response = None
if credentials:
secret_response = await self._store_credentials(
prowler_provider_id, credentials
)
# Step 4: Test connection
connection_status = await self._test_connection(prowler_provider_id)
# Step 5: Get final provider state with relationships
final_provider = await self._get_final_provider_state(prowler_provider_id)
# Transform to structured response using model
connection_result = ProviderConnectionStatus.create(
provider_data=final_provider["data"],
connection_status=connection_status,
)
if secret_response:
# We just stored credentials, use the secret_type from the response
connection_result.provider.secret_type = (
secret_response.get("data", {}).get("attributes", {}).get("secret_type")
)
else:
# No new credentials provided, check if provider has an existing secret
secret_data = (
final_provider.get("data", {})
.get("relationships", {})
.get("secret", {})
.get("data")
)
if secret_data:
# Provider has existing secret, fetch its type
secret_id = secret_data["id"]
connection_result.provider.secret_type = await self._get_secret_type(
secret_id
)
return connection_result.model_dump()
async def delete_provider(
self,
provider_id: NonBlankStr = Field(
description="Prowler's internal UUID (v4) for the provider to permanently remove, generated when the provider was registered in the system. Use `prowler_search_providers` tool to find the provider_id if you only know the alias or the provider's own identifier (provider_uid)"
),
) -> dict[str, Any]:
"""Permanently remove a registered provider from Prowler.
WARNING: This is a destructive operation that cannot be undone. The provider will need to be
re-added with prowler_connect_provider if you want to scan it again.
Prowler removes the provider and everything attached to it (its scans, findings and
resources) in a background task, so a large provider can take longer than the time
this tool waits for it.
The result includes:
- status: 'deleted' when Prowler finished removing the provider, 'in_progress' when
the deletion was accepted and is still running
- task_id: the background task, present when the deletion was still running
NEVER send the deletion again while status='in_progress'. Use prowler_search_providers
to check whether the provider is gone.
"""
self.logger.info(f"Deleting provider {provider_id}...")
# A failure of the request itself is left to the shared classifier: the
# deletion never started, so there is no partial state to describe.
task_response = await self.api_client.delete(f"/providers/{provider_id}")
task_id = task_response.get("data", {}).get("id")
if not task_id:
# The deletion may well be running, so this must not read as "nothing
# happened". No `from` clause: this names the provider and the tool
# that checks it, neither of which the shared classifier can know.
raise ToolError(
f"Prowler accepted the deletion of provider {provider_id} but did not "
"return the ID of the background task, so its outcome cannot be checked. "
"Use prowler_search_providers to see whether the provider is still there "
"before sending the deletion again."
)
try:
await self.api_client.poll_task_until_complete(
task_id=task_id, timeout=60, poll_interval=1.0
)
except Exception as e:
self.logger.error(f"Provider deletion did not complete cleanly: {e}")
return await self._provider_deletion_fallback(provider_id, task_id)
return ProviderDeletionResult(
status="deleted",
message=f"Provider {provider_id} deleted successfully",
).model_dump()
# Private helper methods
async def _provider_deletion_fallback(
self, provider_id: str, task_id: str
) -> dict[str, Any]:
"""Report a provider deletion whose polling did not end on a completed task.
Running out of the polling window is not a failure: Prowler removes the
provider together with its scans, findings and resources, which outlives
60 seconds on a large account. The deletion was accepted and is still
going, so calling it failed would be wrong twice over -- it is not, and
it invites a retry of a destructive call already in flight.
The task is read once more here, because polling gives up on the clock
rather than on the task: a deletion that finished just after the last
poll is a finished deletion and is reported as one.
Only a task that actually stopped is an error, and it is raised rather
than returned, because then the provider is still there.
Raises:
ToolError: If the deletion task ended without deleting the provider.
Raised without a ``from`` clause because the message names what
was left behind, which the shared classifier cannot know.
"""
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 == "completed":
# The deletion outran the polling window by a moment, not by more.
return ProviderDeletionResult(
status="deleted",
message=f"Provider {provider_id} deleted successfully",
).model_dump()
if state in ("failed", "cancelled"):
# The failure that got us here is logged, not relayed: it carries
# upstream text, and the classifier masks exactly this kind of
# message when a tool does not write it itself.
raise ToolError(
f"The task deleting provider {provider_id} ended as '{state}', so the "
"provider was not deleted. Prowler removes a provider together with its "
"scans, findings and resources, so part of that may already be gone. Use "
"prowler_search_providers to check the current state."
)
if state is None:
message = (
f"The deletion of provider {provider_id} was accepted, but its progress "
"could not be read, so whether it finished is unknown. Do not "
"send the deletion again. Use prowler_search_providers to check whether "
"the provider is gone."
)
else:
message = (
f"The deletion of provider {provider_id} was accepted and is still "
f"running (task state '{state}'), which is normal for a provider with "
"many scans and findings. Do not send the deletion again. Use "
"prowler_search_providers to check whether it is gone."
)
return ProviderDeletionResult(
status="in_progress",
task_id=task_id,
message=message,
).model_dump()
async def _check_provider_exists(self, provider_uid: str) -> str | None:
"""Check if a provider already exists by its UID.
Args:
provider_uid: The provider's unique identifier (e.g., AWS account ID)
Returns:
The Prowler-generated provider ID if exists, None otherwise
Raises:
Exception: If multiple providers with the same UID are found (data integrity issue)
Exception: If API request fails
"""
self.logger.info(f"Checking if provider {provider_uid} exists...")
response = await self.api_client.get(
"/providers", params={"filter[uid]": provider_uid}
)
providers = response.get("data", [])
if len(providers) == 0:
self.logger.info(f"Provider {provider_uid} does not exist")
return None
elif len(providers) == 1:
prowler_provider_id = providers[0].get("id")
self.logger.info(
f"Provider {provider_uid} exists with ID {prowler_provider_id}"
)
return prowler_provider_id
else:
# Multiple providers with the same UID is a data integrity issue
raise ToolError(
f"Data integrity error: Found {len(providers)} providers with UID '{provider_uid}'. "
f"Each provider UID should be unique. Please contact support or manually clean up duplicate providers."
)
async def _create_provider(
self, provider_uid: str, provider_type: str, alias: str | None
) -> str:
"""Create a new provider.
Args:
provider_uid: The provider's unique identifier
provider_type: Type of provider to be scanned with Prowler (aws, azure, gcp, etc.)
alias: Optional human-friendly name for the provider
Returns:
The provider UID (which is used as the ID)
"""
self.logger.info(f"Creating provider {provider_uid} (type: {provider_type})...")
provider_body = {
"data": {
"type": "providers",
"attributes": {
"uid": provider_uid,
"provider": provider_type,
},
}
}
if alias:
provider_body["data"]["attributes"]["alias"] = alias
await self.api_client.post("/providers", json_data=provider_body)
provider_id = await self._check_provider_exists(provider_uid)
if provider_id is None:
raise ToolError(
f"Prowler accepted the creation of provider {provider_uid} but the "
"provider cannot be found afterwards. Use prowler_search_providers to "
"check whether it exists before creating it again."
)
return provider_id
async def _update_provider_alias(
self, prowler_provider_id: str, alias: str
) -> None:
"""Update the alias of an existing provider.
Args:
prowler_provider_id: The Prowler-generated provider ID
alias: New human-friendly name for the provider
"""
self.logger.info(f"Updating provider {prowler_provider_id} alias...")
update_body = {
"data": {
"type": "providers",
"id": prowler_provider_id,
"attributes": {
"alias": alias,
},
}
}
result = await self.api_client.patch(
f"/providers/{prowler_provider_id}", json_data=update_body
)
if result.get("data", {}).get("attributes", {}).get("alias") != alias:
raise ToolError(
f"Provider {prowler_provider_id} exists, but its alias was not updated. "
"Use prowler_search_providers to read its current alias."
)
def _determine_secret_type(self, credentials: dict[str, Any]) -> str:
"""Determine the secret type from credentials structure.
Args:
credentials: The credentials dictionary
Returns:
Secret type: "role", "service_account", or "static"
"""
if "role_arn" in credentials:
return "role"
elif "service_account_key" in credentials:
return "service_account"
else:
return "static"
async def _get_provider_secret_id(self, prowler_provider_id: str) -> str | None:
"""Get the secret ID for a provider if it exists.
Args:
prowler_provider_id: The Prowler-generated provider ID
Returns:
The secret ID if the provider has one, None if it has none
Raises:
Exception: If the lookup itself failed, so that "no secret" is never
reported for a provider whose secret could not be read
"""
# A failure here is not swallowed into None. None means "this provider has
# no secret", which sends `_store_credentials` down the create branch, and
# a provider holds at most one secret: creating a second one is refused,
# and the caller would be told its credentials were rejected when all that
# actually failed was this read.
response = await self.api_client.get(
"/providers/secrets",
params={"filter[provider]": prowler_provider_id},
)
secrets = response.get("data", [])
if len(secrets) > 0:
secret_id = secrets[0].get("id")
self.logger.info(
f"Found existing secret {secret_id} for provider {prowler_provider_id}"
)
return secret_id
self.logger.info(f"No existing secret found for provider {prowler_provider_id}")
return None
async def _get_secret_type(self, secret_id: str) -> str | None:
"""Get the secret type for a given secret ID.
Args:
secret_id: The secret ID from provider relationships
Returns:
The secret type ("role", "service_account", or "static") if found, None otherwise
"""
try:
response = await self.api_client.get(
f"/providers/secrets/{secret_id}",
params={"fields[provider-secrets]": "secret_type"},
)
secret_type = (
response.get("data", {}).get("attributes", {}).get("secret_type")
)
return secret_type
except Exception as e:
self.logger.error(f"Error fetching secret type for {secret_id}: {e}")
return None
async def _store_credentials(
self, prowler_provider_id: str, credentials: dict[str, Any]
) -> dict[str, Any]:
"""Store or update credentials for a provider.
Args:
prowler_provider_id: The Prowler-generated provider ID
credentials: The credentials to store
Returns:
The API response with the secret data
"""
self.logger.info(
f"Adding/updating credentials for provider {prowler_provider_id}..."
)
secret_type = self._determine_secret_type(credentials)
# Check if a secret already exists for this provider
existing_secret_id = await self._get_provider_secret_id(prowler_provider_id)
if existing_secret_id:
# Update existing secret
self.logger.info(f"Updating existing secret {existing_secret_id}...")
update_body = {
"data": {
"type": "provider-secrets",
"id": existing_secret_id,
"attributes": {
"secret_type": secret_type,
"secret": credentials,
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": prowler_provider_id,
}
}
},
}
}
try:
response = await self.api_client.patch(
f"/providers/secrets/{existing_secret_id}",
json_data=update_body,
)
self.logger.info("Credentials updated successfully")
return response
except Exception as e:
self.logger.error(f"Error updating credentials: {e}")
raise
else:
# Create new secret
self.logger.info("Creating new secret...")
secret_body = {
"data": {
"type": "provider-secrets",
"attributes": {
"secret_type": secret_type,
"secret": credentials,
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": prowler_provider_id,
}
}
},
}
}
try:
response = await self.api_client.post(
"/providers/secrets", json_data=secret_body
)
self.logger.info("Credentials added successfully")
return response
except Exception as e:
self.logger.error(f"Error adding credentials: {e}")
raise
async def _test_connection(self, prowler_provider_id: str) -> dict[str, Any]:
"""Test connection to a provider and wait for the result.
A test that could not be run is reported as 'connected: None', which
`ProviderConnectionStatus` renders as 'not_tested', rather than as a
failure. Credentials that do not work come back as a completed task
carrying 'connected: False', so an exception here never describes them:
it means this server could not get the test run at all -- an expired
Prowler credential, a rate limit, a test that outlived the timeout.
Reporting that as 'failed' would blame the provider's credentials for
something they did not cause, and send the caller off to fix a working
role.
Args:
prowler_provider_id: The Prowler-generated provider ID
Returns:
Connection status dictionary with a 'connected' boolean or None, and
an optional 'error' message
"""
self.logger.info(f"Testing connection for provider {prowler_provider_id}...")
try:
# Initiate the connection test task
task_response = await self.api_client.post(
f"/providers/{prowler_provider_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 test task."
)
# Poll until task completes (with 60 second timeout)
completed_task = await self.api_client.poll_task_until_complete(
task_id=task_id, timeout=60, poll_interval=1.0
)
# Extract the result from the completed task
task_result = (
completed_task.get("data", {}).get("attributes", {}).get("result")
)
if not isinstance(task_result, dict):
raise ValueError(
"The connection test task completed without reporting a result."
)
return task_result
except Exception as e:
self.logger.error(f"Connection test could not be completed: {e}")
return {
"connected": None,
"error": (
"The connection test could not be completed. This says nothing "
"about the provider's credentials, they were never tested. Use "
"prowler_search_providers to read the connection state Prowler has "
"stored for this provider."
),
}
async def _get_final_provider_state(
self, prowler_provider_id: str
) -> dict[str, Any]:
"""Get final provider state with relationships.
Args:
prowler_provider_id: The Prowler-generated provider ID
Returns:
Provider data dictionary
"""
return await self.api_client.get(
f"/providers/{prowler_provider_id}",
)