Merge remote-tracking branch 'origin/master' into fix/display-imported-scans
# Conflicts: # ui/app/(prowler)/scans/page.tsx
@@ -72,8 +72,8 @@ NEO4J_APOC_IMPORT_FILE_ENABLED=false
|
||||
NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG=true
|
||||
NEO4J_APOC_TRIGGER_ENABLED=false
|
||||
NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS=0.0.0.0:7687
|
||||
# Neo4j Prowler settings
|
||||
ATTACK_PATHS_BATCH_SIZE=1000
|
||||
# Attack Paths graph settings
|
||||
ATTACK_PATHS_GRAPH_MUTATION_BATCH_SIZE=1000
|
||||
ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES=3
|
||||
ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS=30
|
||||
ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES=250
|
||||
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.35.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.36.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -62,6 +62,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Action | Skill |
|
||||
|--------|-------|
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| Adding ConfigRequirements guardrails to compliance requirements | `prowler-compliance` |
|
||||
| Adding DRF pagination or permissions | `django-drf` |
|
||||
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
|
||||
| Adding indexes or constraints to database tables | `django-migration-psql` |
|
||||
@@ -84,6 +85,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating a universal (multi-provider) compliance framework | `prowler-compliance` |
|
||||
| Creating new checks | `prowler-sdk-check` |
|
||||
| Creating new skills | `skill-creator` |
|
||||
| Creating or reviewing Django migrations | `django-migration-psql` |
|
||||
|
||||
@@ -123,12 +123,12 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
|
||||
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface |
|
||||
|---|---|---|---|---|---|---|
|
||||
| AWS | 615 | 86 | 47 | 19 | Official | UI, API, CLI |
|
||||
| Azure | 190 | 22 | 21 | 16 | Official | UI, API, CLI |
|
||||
| AWS | 621 | 86 | 47 | 19 | Official | UI, API, CLI |
|
||||
| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI |
|
||||
| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI |
|
||||
| Kubernetes | 90 | 7 | 8 | 11 | Official | UI, API, CLI |
|
||||
| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI |
|
||||
| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI |
|
||||
| M365 | 109 | 10 | 6 | 10 | Official | UI, API, CLI |
|
||||
| M365 | 111 | 10 | 6 | 10 | Official | UI, API, CLI |
|
||||
| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI |
|
||||
| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI |
|
||||
| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI |
|
||||
|
||||
@@ -4,6 +4,20 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.36.0] (Prowler v5.35.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `attack-paths-scan-perform` Celery tasks now use the configurable long-task time limits instead of the six-hour defaults [(#12009)](https://github.com/prowler-cloud/prowler/pull/12009)
|
||||
- Attack Paths scans handle provider deletion races cleanly, detect stale tasks after 16 hours, use backend-specific graph synchronization batches, and report exhausted Neptune write retries with the original database error [(#12019)](https://github.com/prowler-cloud/prowler/pull/12019)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Jira integration credentials only accept bare Atlassian site names containing letters, numbers, and hyphens [(#12012)](https://github.com/prowler-cloud/prowler/pull/12012)
|
||||
- Social account linking requires a verified matching email from both the identity provider and the existing user account without sending account connection notifications [(#12013)](https://github.com/prowler-cloud/prowler/pull/12013)
|
||||
|
||||
---
|
||||
|
||||
## [1.35.0] (Prowler v5.34.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
`attack-paths-scan-perform` Celery tasks now use the configurable long-task time limits instead of the six-hour defaults
|
||||
@@ -0,0 +1 @@
|
||||
Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database
|
||||
@@ -1 +0,0 @@
|
||||
Jira integration credentials only accept bare Atlassian site names containing letters, numbers, and hyphens
|
||||
@@ -0,0 +1 @@
|
||||
OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning
|
||||
@@ -1 +0,0 @@
|
||||
Social account linking now requires a verified matching email from both the identity provider and the existing user account
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.36.0"
|
||||
version = "1.37.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import logging
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
from allauth.core.exceptions import ImmediateHttpResponse
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
@@ -17,8 +15,6 @@ from api.utils import accept_invitation_for_user
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseForbidden
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
@staticmethod
|
||||
@@ -105,12 +101,6 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
raise ImmediateHttpResponse(HttpResponseForbidden())
|
||||
sociallogin.connect(request, existing_user)
|
||||
|
||||
def send_notification_mail(self, *args, **kwargs):
|
||||
try:
|
||||
return super().send_notification_mail(*args, **kwargs)
|
||||
except OSError:
|
||||
logger.exception("Failed to send social account connection notification")
|
||||
|
||||
def save_user(self, request, sociallogin, form=None):
|
||||
"""
|
||||
Called after the user data is fully populated from the provider
|
||||
|
||||
@@ -27,6 +27,7 @@ from django.conf import (
|
||||
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
|
||||
|
||||
TEMP_DB_PREFIX = "db-tmp-scan-"
|
||||
DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
|
||||
|
||||
|
||||
# Exceptions
|
||||
@@ -44,6 +45,10 @@ class GraphDatabaseQueryException(Exception):
|
||||
return self.message
|
||||
|
||||
|
||||
class NeptuneWriteRetryExhaustedException(GraphDatabaseQueryException):
|
||||
pass
|
||||
|
||||
|
||||
class WriteQueryNotAllowedException(GraphDatabaseQueryException):
|
||||
pass
|
||||
|
||||
|
||||
@@ -10,6 +10,28 @@ import neo4j.exceptions
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RetryExhaustedError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
retry_context: str,
|
||||
method_name: str,
|
||||
attempts: int,
|
||||
elapsed_seconds: float,
|
||||
last_error: Exception,
|
||||
) -> None:
|
||||
self.retry_context = retry_context
|
||||
self.method_name = method_name
|
||||
self.attempts = attempts
|
||||
self.elapsed_seconds = elapsed_seconds
|
||||
self.last_error = last_error
|
||||
last_message = getattr(last_error, "message", None) or str(last_error)
|
||||
super().__init__(
|
||||
f"{retry_context} {method_name} failed after {attempts} attempts over "
|
||||
f"{elapsed_seconds:.3f}s. Last error: {last_message}"
|
||||
)
|
||||
|
||||
|
||||
class RetryableSession:
|
||||
"""Wrapper around ``neo4j.Session`` with a refreshable retry policy."""
|
||||
|
||||
@@ -19,11 +41,13 @@ class RetryableSession:
|
||||
max_retries: int,
|
||||
retry_if: Callable[[Exception], bool] | None = None,
|
||||
initial_retry_delay_seconds: float = 0,
|
||||
retry_context: str | None = None,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._max_retries = max(0, max_retries)
|
||||
self._retry_if = retry_if
|
||||
self._initial_retry_delay_seconds = max(0.0, initial_retry_delay_seconds)
|
||||
self._retry_context = retry_context
|
||||
self._session = self._session_factory()
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -54,6 +78,7 @@ class RetryableSession:
|
||||
def _call_with_retry(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
attempt = 0
|
||||
last_exc: Exception | None = None
|
||||
started_at = time.monotonic()
|
||||
|
||||
while attempt <= self._max_retries:
|
||||
try:
|
||||
@@ -68,17 +93,38 @@ class RetryableSession:
|
||||
attempt += 1
|
||||
|
||||
if attempt > self._max_retries:
|
||||
if self._retry_context is not None:
|
||||
raise RetryExhaustedError(
|
||||
retry_context=self._retry_context,
|
||||
method_name=method_name,
|
||||
attempts=attempt,
|
||||
elapsed_seconds=time.monotonic() - started_at,
|
||||
last_error=exc,
|
||||
) from exc
|
||||
raise
|
||||
|
||||
delay = self._retry_delay(attempt)
|
||||
logger.warning(
|
||||
"Graph session %s failed with %s; retry %s/%s in %.3fs",
|
||||
method_name,
|
||||
type(exc).__name__,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
delay,
|
||||
)
|
||||
if self._retry_context is not None:
|
||||
error_message = getattr(exc, "message", None) or str(exc)
|
||||
logger.warning(
|
||||
"%s %s failed with %s: %s; retry %s/%s in %.3fs",
|
||||
self._retry_context,
|
||||
method_name,
|
||||
type(exc).__name__,
|
||||
error_message,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
delay,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Graph session %s failed with %s; retry %s/%s in %.3fs",
|
||||
method_name,
|
||||
type(exc).__name__,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
delay,
|
||||
)
|
||||
self._refresh_session()
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
|
||||
@@ -15,6 +15,8 @@ class SinkDatabase(Protocol):
|
||||
has a single graph, and isolation is label-based).
|
||||
"""
|
||||
|
||||
sync_batch_size: int
|
||||
|
||||
def init(self) -> None: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
@@ -54,6 +54,8 @@ DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
|
||||
class Neo4jSink(SinkDatabase):
|
||||
"""Neo4j-backed sink. Multi-database cluster; tenant isolation is physical."""
|
||||
|
||||
sync_batch_size = env.int("ATTACK_PATHS_NEO4J_SYNC_BATCH_SIZE", default=1000)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._driver: neo4j.Driver | None = None
|
||||
self._lock = threading.Lock()
|
||||
@@ -203,7 +205,7 @@ class Neo4jSink(SinkDatabase):
|
||||
"""
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
GRAPH_MUTATION_BATCH_SIZE,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
get_provider_label,
|
||||
)
|
||||
@@ -251,7 +253,7 @@ class Neo4jSink(SinkDatabase):
|
||||
total_key="rels",
|
||||
deleted_key="deleted_rels",
|
||||
initial_total=deleted_relationships,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
relationship_batches += phase_batches
|
||||
@@ -270,7 +272,7 @@ class Neo4jSink(SinkDatabase):
|
||||
total_key="nodes",
|
||||
deleted_key="deleted_nodes",
|
||||
initial_total=0,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from urllib.parse import urlsplit
|
||||
|
||||
import neo4j
|
||||
import neo4j.exceptions
|
||||
from api.attack_paths.retryable_session import RetryableSession
|
||||
from api.attack_paths.retryable_session import RetryableSession, RetryExhaustedError
|
||||
from api.attack_paths.sink.base import SinkDatabase
|
||||
from api.attack_paths.sink.drop import (
|
||||
NODE_DELETE_QUERY_TEMPLATE,
|
||||
@@ -85,6 +85,8 @@ def _is_retryable_write_error(exc: Exception) -> bool:
|
||||
class NeptuneSink(SinkDatabase):
|
||||
"""Neptune-backed sink. Single database; isolation is label-based."""
|
||||
|
||||
sync_batch_size = env.int("ATTACK_PATHS_NEPTUNE_SYNC_BATCH_SIZE", default=500)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._writer: neo4j.Driver | None = None
|
||||
self._reader: neo4j.Driver | None = None
|
||||
@@ -206,6 +208,7 @@ class NeptuneSink(SinkDatabase):
|
||||
from api.attack_paths.database import (
|
||||
ClientStatementException,
|
||||
GraphDatabaseQueryException,
|
||||
NeptuneWriteRetryExhaustedException,
|
||||
WriteQueryNotAllowedException,
|
||||
)
|
||||
|
||||
@@ -227,9 +230,17 @@ class NeptuneSink(SinkDatabase):
|
||||
initial_retry_delay_seconds=(
|
||||
NEPTUNE_WRITE_RETRY_DELAY_SECONDS if is_write_session else 0
|
||||
),
|
||||
retry_context="Neptune write" if is_write_session else None,
|
||||
)
|
||||
yield session_wrapper
|
||||
|
||||
except RetryExhaustedError as exc:
|
||||
last_error = exc.last_error
|
||||
raise NeptuneWriteRetryExhaustedException(
|
||||
message=str(exc),
|
||||
code=getattr(last_error, "code", None),
|
||||
) from last_error
|
||||
|
||||
except neo4j.exceptions.Neo4jError as exc:
|
||||
if (
|
||||
default_access_mode == neo4j.READ_ACCESS
|
||||
@@ -291,7 +302,7 @@ class NeptuneSink(SinkDatabase):
|
||||
graph's branching factor.
|
||||
"""
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
GRAPH_MUTATION_BATCH_SIZE,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
get_provider_label,
|
||||
)
|
||||
@@ -330,7 +341,7 @@ class NeptuneSink(SinkDatabase):
|
||||
total_key="rels",
|
||||
deleted_key="deleted_rels",
|
||||
initial_total=deleted_relationships,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
relationship_batches += phase_batches
|
||||
@@ -349,7 +360,7 @@ class NeptuneSink(SinkDatabase):
|
||||
total_key="nodes",
|
||||
deleted_key="deleted_nodes",
|
||||
initial_total=0,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import uuid
|
||||
from functools import wraps
|
||||
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY, rls_transaction
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from api.models import Provider, Scan
|
||||
from api.models import Membership, Provider, Scan, Tenant
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import DatabaseError, connection, transaction
|
||||
from django.db import DEFAULT_DB_ALIAS, DatabaseError, connection, transaction
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
|
||||
@@ -75,9 +76,11 @@ def handle_provider_deletion(func):
|
||||
"""
|
||||
Decorator that raises `ProviderDeletedException` if provider was deleted during execution.
|
||||
|
||||
Catches `ObjectDoesNotExist` and `DatabaseError` (including `IntegrityError`), checks if
|
||||
provider still exists, and raises `ProviderDeletedException` if not. Otherwise,
|
||||
re-raises original exception.
|
||||
Catches `ObjectDoesNotExist`, `DatabaseError` (including `IntegrityError`), and
|
||||
`GraphDatabaseQueryException`, checks if provider still exists, and raises
|
||||
`ProviderDeletedException` if not. Graph database errors also check whether the
|
||||
tenant still exists and has memberships. Otherwise, re-raises the original
|
||||
exception.
|
||||
|
||||
Requires `tenant_id` and `provider_id` in kwargs.
|
||||
|
||||
@@ -92,11 +95,16 @@ def handle_provider_deletion(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except (ObjectDoesNotExist, DatabaseError):
|
||||
except (ObjectDoesNotExist, DatabaseError, GraphDatabaseQueryException) as exc:
|
||||
tenant_id = kwargs.get("tenant_id")
|
||||
provider_id = kwargs.get("provider_id")
|
||||
database_alias = (
|
||||
DEFAULT_DB_ALIAS
|
||||
if isinstance(exc, GraphDatabaseQueryException)
|
||||
else READ_REPLICA_ALIAS
|
||||
)
|
||||
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
with rls_transaction(tenant_id, using=database_alias):
|
||||
if provider_id is None:
|
||||
scan_id = kwargs.get("scan_id")
|
||||
if scan_id is None:
|
||||
@@ -113,6 +121,13 @@ def handle_provider_deletion(func):
|
||||
raise ProviderDeletedException(
|
||||
f"Provider '{provider_id}' was deleted during the scan"
|
||||
) from None
|
||||
if isinstance(exc, GraphDatabaseQueryException) and (
|
||||
not Tenant.objects.filter(pk=tenant_id).exists()
|
||||
or not Membership.objects.filter(tenant_id=tenant_id).exists()
|
||||
):
|
||||
raise ProviderDeletedException(
|
||||
f"Tenant '{tenant_id}' was deleted during the scan"
|
||||
) from None
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.36.0
|
||||
version: 1.37.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -302,7 +302,9 @@ class TestProwlerSocialAccountAdapter:
|
||||
|
||||
sociallogin.connect.assert_called_once_with(request, create_test_user)
|
||||
|
||||
def test_verified_social_account_link_notifies_owner(self, create_test_user, rf):
|
||||
def test_verified_social_account_link_does_not_send_notification(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
_verify_local_email(create_test_user)
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
create_test_user,
|
||||
@@ -318,34 +320,7 @@ class TestProwlerSocialAccountAdapter:
|
||||
uid="verified-google-account",
|
||||
user=create_test_user,
|
||||
).exists()
|
||||
assert len(mail.outbox) == 1
|
||||
assert mail.outbox[0].to == [create_test_user.email]
|
||||
|
||||
def test_notification_delivery_failure_does_not_break_verified_link(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
_verify_local_email(create_test_user)
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
create_test_user,
|
||||
uid="verified-google-account-without-smtp",
|
||||
)
|
||||
request = rf.get("/")
|
||||
|
||||
with (
|
||||
context.request_context(request),
|
||||
patch(
|
||||
"allauth.socialaccount.adapter."
|
||||
"DefaultSocialAccountAdapter.send_notification_mail",
|
||||
side_effect=ConnectionRefusedError,
|
||||
),
|
||||
):
|
||||
ProwlerSocialAccountAdapter().pre_social_login(request, sociallogin)
|
||||
|
||||
assert SocialAccount.objects.filter(
|
||||
provider="google",
|
||||
uid="verified-google-account-without-smtp",
|
||||
user=create_test_user,
|
||||
).exists()
|
||||
assert mail.outbox == []
|
||||
|
||||
def test_pre_social_login_uses_verified_email_missing_from_extra_data(
|
||||
self, create_test_user, rf
|
||||
@@ -367,7 +342,7 @@ class TestProwlerSocialAccountAdapter:
|
||||
def test_social_account_linking_settings_are_fail_closed(self):
|
||||
assert not socialaccount_app_settings.EMAIL_AUTHENTICATION
|
||||
assert not socialaccount_app_settings.EMAIL_AUTHENTICATION_AUTO_CONNECT
|
||||
assert account_app_settings.EMAIL_NOTIFICATIONS
|
||||
assert not account_app_settings.EMAIL_NOTIFICATIONS
|
||||
|
||||
def test_save_user_social_with_invitation_joins_invited_tenant(
|
||||
self, rf, create_test_user, tenants_fixture
|
||||
|
||||
@@ -2,11 +2,12 @@ import uuid
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY
|
||||
from api.decorators import handle_provider_deletion, set_tenant
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import DatabaseError, IntegrityError
|
||||
from django.db import DEFAULT_DB_ALIAS, DatabaseError, IntegrityError
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -204,6 +205,106 @@ class TestHandleProviderDeletionDecorator:
|
||||
with pytest.raises(DatabaseError):
|
||||
task_func(tenant_id=str(tenant.id), provider_id=str(provider.id))
|
||||
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_graph_database_error_provider_missing_or_soft_deleted(
|
||||
self, mock_provider_filter, mock_rls, tenants_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider_id = str(uuid.uuid4())
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
mock_provider_filter.return_value.exists.return_value = False
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
raise GraphDatabaseQueryException("Temporary database not found")
|
||||
|
||||
with pytest.raises(ProviderDeletedException):
|
||||
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
|
||||
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Tenant.objects.filter")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_graph_database_error_tenant_missing(
|
||||
self, mock_provider_filter, mock_tenant_filter, mock_rls, tenants_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider_id = str(uuid.uuid4())
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
mock_provider_filter.return_value.exists.return_value = True
|
||||
mock_tenant_filter.return_value.exists.return_value = False
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
raise GraphDatabaseQueryException("Temporary database not found")
|
||||
|
||||
with pytest.raises(ProviderDeletedException):
|
||||
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
|
||||
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Membership.objects.filter")
|
||||
@patch("api.decorators.Tenant.objects.filter")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_graph_database_error_tenant_without_memberships(
|
||||
self,
|
||||
mock_provider_filter,
|
||||
mock_tenant_filter,
|
||||
mock_membership_filter,
|
||||
mock_rls,
|
||||
tenants_fixture,
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider_id = str(uuid.uuid4())
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
mock_provider_filter.return_value.exists.return_value = True
|
||||
mock_tenant_filter.return_value.exists.return_value = True
|
||||
mock_membership_filter.return_value.exists.return_value = False
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
raise GraphDatabaseQueryException("Temporary database not found")
|
||||
|
||||
with pytest.raises(ProviderDeletedException):
|
||||
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
|
||||
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Membership.objects.filter")
|
||||
@patch("api.decorators.Tenant.objects.filter")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_graph_database_error_active_provider_and_tenant_reraises(
|
||||
self,
|
||||
mock_provider_filter,
|
||||
mock_tenant_filter,
|
||||
mock_membership_filter,
|
||||
mock_rls,
|
||||
tenants_fixture,
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider_id = str(uuid.uuid4())
|
||||
graph_error = GraphDatabaseQueryException("Temporary database not found")
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
mock_provider_filter.return_value.exists.return_value = True
|
||||
mock_tenant_filter.return_value.exists.return_value = True
|
||||
mock_membership_filter.return_value.exists.return_value = True
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
raise graph_error
|
||||
|
||||
with pytest.raises(GraphDatabaseQueryException) as exc_info:
|
||||
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
|
||||
|
||||
assert exc_info.value is graph_error
|
||||
mock_rls.assert_called_once_with(str(tenant.id), using=DEFAULT_DB_ALIAS)
|
||||
|
||||
def test_missing_provider_and_scan_raises_assertion(self, tenants_fixture):
|
||||
"""Raises AssertionError when neither provider_id nor scan_id in kwargs."""
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.retryable_session import RetryableSession
|
||||
from api.attack_paths.retryable_session import RetryableSession, RetryExhaustedError
|
||||
from neo4j.exceptions import ServiceUnavailable
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class TestRetryableSession:
|
||||
max_retries=3,
|
||||
retry_if=lambda exc: exc is retryable_error,
|
||||
initial_retry_delay_seconds=2,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
assert session.execute_write(work) == "success"
|
||||
@@ -54,6 +55,7 @@ class TestRetryableSession:
|
||||
max_retries=3,
|
||||
retry_if=lambda _: False,
|
||||
initial_retry_delay_seconds=2,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
@@ -83,3 +85,81 @@ class TestRetryableSession:
|
||||
driver_sessions[0].close.assert_called_once_with()
|
||||
driver_sessions[1].close.assert_called_once_with()
|
||||
driver_sessions[2].close.assert_not_called()
|
||||
|
||||
def test_retry_exhaustion_with_context_reports_attempts_and_elapsed_time(self):
|
||||
error = RuntimeError("still retryable")
|
||||
driver_sessions = [MagicMock() for _ in range(3)]
|
||||
for driver_session in driver_sessions:
|
||||
driver_session.execute_write.side_effect = error
|
||||
session = RetryableSession(
|
||||
session_factory=MagicMock(side_effect=driver_sessions),
|
||||
max_retries=2,
|
||||
retry_if=lambda _: True,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.attack_paths.retryable_session.time.monotonic",
|
||||
side_effect=[100.0, 127.1234],
|
||||
),
|
||||
pytest.raises(RetryExhaustedError) as exc_info,
|
||||
):
|
||||
session.execute_write(MagicMock())
|
||||
|
||||
assert exc_info.value.method_name == "execute_write"
|
||||
assert exc_info.value.attempts == 3
|
||||
assert exc_info.value.elapsed_seconds == pytest.approx(27.1234)
|
||||
assert exc_info.value.last_error is error
|
||||
assert exc_info.value.__cause__ is error
|
||||
assert str(exc_info.value) == (
|
||||
"Neptune write execute_write failed after 3 attempts over 27.123s. "
|
||||
"Last error: still retryable"
|
||||
)
|
||||
|
||||
def test_retry_exhaustion_with_zero_retries_reports_one_attempt(self):
|
||||
error = ServiceUnavailable("still unavailable")
|
||||
driver_session = MagicMock()
|
||||
driver_session.execute_write.side_effect = error
|
||||
session = RetryableSession(
|
||||
session_factory=MagicMock(return_value=driver_session),
|
||||
max_retries=0,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
with pytest.raises(RetryExhaustedError) as exc_info:
|
||||
session.execute_write(MagicMock())
|
||||
|
||||
assert exc_info.value.attempts == 1
|
||||
|
||||
@patch("api.attack_paths.retryable_session.time.sleep")
|
||||
@patch("api.attack_paths.retryable_session.random.uniform", return_value=3.0)
|
||||
def test_contextual_retry_warning_includes_original_error(
|
||||
self, _mock_uniform, _mock_sleep
|
||||
):
|
||||
error = RuntimeError("retryable detail")
|
||||
first_session = MagicMock()
|
||||
first_session.execute_write.side_effect = error
|
||||
second_session = MagicMock()
|
||||
second_session.execute_write.return_value = "success"
|
||||
session = RetryableSession(
|
||||
session_factory=MagicMock(side_effect=[first_session, second_session]),
|
||||
max_retries=1,
|
||||
retry_if=lambda _: True,
|
||||
initial_retry_delay_seconds=2,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
with patch("api.attack_paths.retryable_session.logger.warning") as mock_warning:
|
||||
assert session.execute_write(MagicMock()) == "success"
|
||||
|
||||
mock_warning.assert_called_once_with(
|
||||
"%s %s failed with %s: %s; retry %s/%s in %.3fs",
|
||||
"Neptune write",
|
||||
"execute_write",
|
||||
"RuntimeError",
|
||||
"retryable detail",
|
||||
1,
|
||||
1,
|
||||
3.0,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from config.settings import sentry as sentry_settings
|
||||
from config.settings.sentry import before_send
|
||||
|
||||
@@ -82,6 +83,45 @@ def test_before_send_passes_through_non_ignored_log():
|
||||
assert result == event
|
||||
|
||||
|
||||
def test_before_send_ignores_cartography_missing_temporary_database_log():
|
||||
log_record = _make_log_record(
|
||||
msg="Cartography job failed with %s for database %s",
|
||||
name="cartography.graph.job",
|
||||
args=(
|
||||
"Neo.ClientError.Database.DatabaseNotFound",
|
||||
"db-tmp-scan-12345678",
|
||||
),
|
||||
)
|
||||
|
||||
event = MagicMock()
|
||||
|
||||
assert before_send(event, {"log_record": log_record}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("logger_name", "message"),
|
||||
[
|
||||
(
|
||||
"cartography.graph.job.worker",
|
||||
"Neo.ClientError.Database.DatabaseNotFound for db-tmp-scan-12345678",
|
||||
),
|
||||
(
|
||||
"cartography.graph.job",
|
||||
"DatabaseNotFound for db-tmp-scan-12345678",
|
||||
),
|
||||
(
|
||||
"cartography.graph.job",
|
||||
"Neo.ClientError.Database.DatabaseNotFound for db-tenant-12345678",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_before_send_passes_through_similar_cartography_logs(logger_name, message):
|
||||
log_record = _make_log_record(msg=message, name=logger_name)
|
||||
event = MagicMock()
|
||||
|
||||
assert before_send(event, {"log_record": log_record}) is event
|
||||
|
||||
|
||||
def test_before_send_passes_through_non_ignored_exception():
|
||||
"""Test that before_send passes through exceptions that don't contain ignored exceptions."""
|
||||
exc_info = (Exception, Exception("Some other error message"), None)
|
||||
|
||||
@@ -3,7 +3,12 @@ from api.v1.serializer_utils.integrations import (
|
||||
JiraCredentialSerializer,
|
||||
S3ConfigSerializer,
|
||||
)
|
||||
from api.v1.serializers import ImageProviderSecret, KubernetesProviderSecret
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from api.v1.serializers import (
|
||||
ImageProviderSecret,
|
||||
KubernetesProviderSecret,
|
||||
OracleCloudProviderSecret,
|
||||
)
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
||||
@@ -190,6 +195,64 @@ class TestImageProviderSecret:
|
||||
assert "non_field_errors" in serializer.errors
|
||||
|
||||
|
||||
class TestOracleCloudProviderSecret:
|
||||
def valid_secret(self, **overrides):
|
||||
secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaexample",
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
}
|
||||
secret.update(overrides)
|
||||
return secret
|
||||
|
||||
def test_accepts_regionless_secret(self):
|
||||
serializer = OracleCloudProviderSecret(data=self.valid_secret())
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
def test_accepts_and_ignores_region_field(self):
|
||||
secret = self.valid_secret(region="us-phoenix-1")
|
||||
serializer = OracleCloudProviderSecret(data=secret)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"legacy_field, legacy_value",
|
||||
[
|
||||
("region", None),
|
||||
("region", ""),
|
||||
("region", {"name": "us-ashburn-1"}),
|
||||
],
|
||||
)
|
||||
def test_accepts_and_ignores_any_legacy_region_value(
|
||||
self, legacy_field, legacy_value
|
||||
):
|
||||
serializer = OracleCloudProviderSecret(
|
||||
data=self.valid_secret(**{legacy_field: legacy_value})
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
assert legacy_field not in serializer.validated_data
|
||||
|
||||
|
||||
class TestProviderSecretFieldSchema:
|
||||
def test_oraclecloud_schema_includes_legacy_region_field(self):
|
||||
schema = ProviderSecretField._spectacular_annotation["field"]
|
||||
oraclecloud_schema = next(
|
||||
credential_schema
|
||||
for credential_schema in schema["oneOf"]
|
||||
if credential_schema["title"]
|
||||
== "Oracle Cloud Infrastructure (OCI) API Key Credentials"
|
||||
)
|
||||
|
||||
assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
|
||||
|
||||
|
||||
class TestKubernetesProviderSecret:
|
||||
def test_valid_static_kubeconfig_is_accepted(self):
|
||||
kubeconfig_content = """
|
||||
|
||||
@@ -11,7 +11,11 @@ from unittest.mock import MagicMock, patch
|
||||
import neo4j
|
||||
import pytest
|
||||
from api.attack_paths import sink as sink_module
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.attack_paths.database import (
|
||||
GraphDatabaseQueryException,
|
||||
NeptuneWriteRetryExhaustedException,
|
||||
)
|
||||
from api.attack_paths.retryable_session import RetryExhaustedError
|
||||
from api.attack_paths.sink import factory
|
||||
from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink
|
||||
from api.attack_paths.sink.neptune import (
|
||||
@@ -123,6 +127,14 @@ class TestSinkFactory:
|
||||
assert mock_driver.call_count == 1
|
||||
|
||||
|
||||
def test_neo4j_sync_batch_size_defaults_to_1000():
|
||||
assert Neo4jSink.sync_batch_size == 1000
|
||||
|
||||
|
||||
def test_neptune_sync_batch_size_defaults_to_500():
|
||||
assert NeptuneSink.sync_batch_size == 500
|
||||
|
||||
|
||||
class TestGetBackendForScan:
|
||||
"""``get_backend_for_scan`` routes by the row's recorded sink backend."""
|
||||
|
||||
@@ -372,6 +384,7 @@ class TestNeptuneRetryPolicy:
|
||||
assert (
|
||||
kwargs["initial_retry_delay_seconds"] == NEPTUNE_WRITE_RETRY_DELAY_SECONDS
|
||||
)
|
||||
assert kwargs["retry_context"] == "Neptune write"
|
||||
|
||||
@patch("api.attack_paths.sink.neptune.RetryableSession")
|
||||
def test_reader_session_does_not_enable_write_retry_policy(self, retryable_session):
|
||||
@@ -384,6 +397,48 @@ class TestNeptuneRetryPolicy:
|
||||
kwargs = retryable_session.call_args.kwargs
|
||||
assert kwargs["retry_if"] is None
|
||||
assert kwargs["initial_retry_delay_seconds"] == 0
|
||||
assert kwargs["retry_context"] is None
|
||||
|
||||
def test_writer_retry_exhaustion_preserves_neptune_error_details(self):
|
||||
message = (
|
||||
"Unexpected server exception 'Operation failed due to conflicting "
|
||||
"concurrent operations (please retry), 0 transactions are currently "
|
||||
"rolling back.'"
|
||||
)
|
||||
error = neo4j.exceptions.Neo4jError._hydrate_neo4j(
|
||||
code="BoltProtocol.unexpectedException",
|
||||
message=message,
|
||||
)
|
||||
retry_error = RetryExhaustedError(
|
||||
retry_context="Neptune write",
|
||||
method_name="execute_write",
|
||||
attempts=4,
|
||||
elapsed_seconds=27.1234,
|
||||
last_error=error,
|
||||
)
|
||||
sink = NeptuneSink()
|
||||
driver = MagicMock()
|
||||
retryable_session = MagicMock()
|
||||
retryable_session.execute_write.side_effect = retry_error
|
||||
|
||||
with (
|
||||
patch.object(sink, "_get_writer", return_value=driver),
|
||||
patch(
|
||||
"api.attack_paths.sink.neptune.RetryableSession",
|
||||
return_value=retryable_session,
|
||||
),
|
||||
pytest.raises(NeptuneWriteRetryExhaustedException) as exc_info,
|
||||
):
|
||||
with sink.get_session() as session:
|
||||
session.execute_write(MagicMock())
|
||||
|
||||
assert exc_info.value.code == "BoltProtocol.unexpectedException"
|
||||
assert str(exc_info.value) == (
|
||||
"BoltProtocol.unexpectedException: Neptune write execute_write failed "
|
||||
"after 4 attempts over 27.123s. Last error: "
|
||||
f"{message}"
|
||||
)
|
||||
assert exc_info.value.__cause__ is error
|
||||
|
||||
|
||||
class TestNeptuneSinkDropSubgraph:
|
||||
|
||||
@@ -171,6 +171,53 @@ class TestInitializeProwlerProvider:
|
||||
key="value", mutelist_content={"key": "value"}
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_initialize_oraclecloud_provider_removes_region_string(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..fake",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
"region": "us-ashburn-1",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
initialize_prowler_provider(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.assert_called_once_with(
|
||||
user="ocid1.user.oc1..fake",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..fake",
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_initialize_oraclecloud_provider_without_region_omits_scan_filter(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..fake",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
initialize_prowler_provider(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.assert_called_once_with(
|
||||
user="ocid1.user.oc1..fake",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..fake",
|
||||
)
|
||||
|
||||
|
||||
class TestProwlerProviderConnectionTest:
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
@@ -185,6 +232,37 @@ class TestProwlerProviderConnectionTest:
|
||||
key="value", provider_id="1234567890", raise_on_exception=False
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_oraclecloud_connection_test_uses_direct_credentials_without_region(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaexample",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
prowler_provider_connection_test(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
|
||||
user="ocid1.user.oc1..aaaaaaaexample",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
region=getattr(
|
||||
OraclecloudProvider,
|
||||
"_bootstrap_region",
|
||||
OraclecloudProvider._home_region,
|
||||
),
|
||||
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_prowler_provider_connection_test_without_secret(
|
||||
@@ -356,7 +434,7 @@ class TestGetProwlerProviderKwargs:
|
||||
expected_result = {**secret_dict, **expected_extra_kwargs}
|
||||
assert result == expected_result
|
||||
|
||||
def test_get_prowler_provider_kwargs_oraclecloud_converts_region_string_to_set(
|
||||
def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
|
||||
self,
|
||||
):
|
||||
secret_dict = {
|
||||
@@ -377,8 +455,13 @@ class TestGetProwlerProviderKwargs:
|
||||
|
||||
result = get_prowler_provider_kwargs(provider)
|
||||
|
||||
expected_result = {**secret_dict, "region": {"us-ashburn-1"}}
|
||||
assert result == expected_result
|
||||
assert result == {
|
||||
"user": "ocid1.user.oc1..fake",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
"pass_phrase": "fake-passphrase",
|
||||
}
|
||||
|
||||
def test_get_prowler_provider_kwargs_with_mutelist(self):
|
||||
provider_uid = "provider_uid"
|
||||
|
||||
@@ -2917,6 +2917,48 @@ class TestProviderGroupViewSet:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProviderSecretViewSet:
|
||||
@staticmethod
|
||||
def _oraclecloud_secret(**overrides):
|
||||
secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "test-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
}
|
||||
secret.update(overrides)
|
||||
return secret
|
||||
|
||||
def _create_oraclecloud_secret(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
secret,
|
||||
name="OCI Secret",
|
||||
):
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": name,
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": secret,
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(oraclecloud_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
return authenticated_client.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
def test_provider_secrets_list(self, authenticated_client, provider_secret_fixture):
|
||||
response = authenticated_client.get(reverse("providersecret-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -3076,7 +3118,6 @@ current-context: test-context
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-key-content\n-----END RSA PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
"region": "us-ashburn-1",
|
||||
},
|
||||
),
|
||||
# OCI with API key credentials (with key_file)
|
||||
@@ -3088,7 +3129,6 @@ current-context: test-context
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_file": "/path/to/oci_api_key.pem",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
"region": "us-ashburn-1",
|
||||
},
|
||||
),
|
||||
# OCI with API key credentials (with passphrase)
|
||||
@@ -3100,7 +3140,6 @@ current-context: test-context
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-encrypted-key\n-----END RSA PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
"region": "us-ashburn-1",
|
||||
"pass_phrase": "my-secure-passphrase",
|
||||
},
|
||||
),
|
||||
@@ -3258,6 +3297,103 @@ current-context: test-context
|
||||
== data["data"]["relationships"]["provider"]["data"]["id"]
|
||||
)
|
||||
|
||||
def test_provider_secrets_create_oraclecloud_without_region_stores_no_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
provider_secret = ProviderSecret.objects.get()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(
|
||||
key_content=" test-key-content ", region=" us-ashburn-1 "
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
provider_secret = ProviderSecret.objects.get()
|
||||
assert provider_secret.secret["key_content"] == "test-key-content"
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
create_response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(),
|
||||
)
|
||||
provider_secret = ProviderSecret.objects.get(
|
||||
id=create_response.json()["data"]["id"]
|
||||
)
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {"secret": self._oraclecloud_secret()},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.patch(
|
||||
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
create_response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(),
|
||||
)
|
||||
provider_secret = ProviderSecret.objects.get(
|
||||
id=create_response.json()["data"]["id"]
|
||||
)
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {
|
||||
"secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.patch(
|
||||
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attributes, error_code, error_pointer",
|
||||
(
|
||||
|
||||
@@ -252,12 +252,6 @@ def get_prowler_provider_kwargs(
|
||||
**prowler_provider_kwargs,
|
||||
"filter_accounts": [provider.uid],
|
||||
}
|
||||
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
if isinstance(prowler_provider_kwargs.get("region"), str):
|
||||
prowler_provider_kwargs = {
|
||||
**prowler_provider_kwargs,
|
||||
"region": {prowler_provider_kwargs["region"]},
|
||||
}
|
||||
elif provider.provider == Provider.ProviderChoices.OPENSTACK.value:
|
||||
# clouds_yaml_content, clouds_yaml_cloud and provider_id are validated
|
||||
# in the provider itself, so it's not needed here.
|
||||
@@ -288,6 +282,11 @@ def get_prowler_provider_kwargs(
|
||||
**{k: v for k, v in prowler_provider_kwargs.items() if v},
|
||||
}
|
||||
|
||||
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
prowler_provider_kwargs = _normalize_oraclecloud_provider_kwargs(
|
||||
prowler_provider_kwargs
|
||||
)
|
||||
|
||||
if mutelist_processor:
|
||||
mutelist_content = mutelist_processor.configuration.get("Mutelist", {})
|
||||
# IaC and Image providers don't support mutelist (both use Trivy's built-in logic)
|
||||
@@ -300,6 +299,40 @@ def get_prowler_provider_kwargs(
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
|
||||
"""Normalize external OCI secret fields into SDK provider kwargs."""
|
||||
prowler_provider_kwargs = secret.copy()
|
||||
prowler_provider_kwargs.pop("region", None)
|
||||
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
|
||||
"""Normalize external OCI secret fields into test_connection kwargs."""
|
||||
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
|
||||
|
||||
prowler_provider_kwargs = secret.copy()
|
||||
prowler_provider_kwargs.pop("region", None)
|
||||
|
||||
if (
|
||||
prowler_provider_kwargs.get("user")
|
||||
and prowler_provider_kwargs.get("fingerprint")
|
||||
and prowler_provider_kwargs.get("tenancy")
|
||||
and (
|
||||
prowler_provider_kwargs.get("key_content")
|
||||
or prowler_provider_kwargs.get("key_file")
|
||||
)
|
||||
):
|
||||
# Connection validation needs one OCI endpoint, but scans remain unfiltered.
|
||||
prowler_provider_kwargs["region"] = getattr(
|
||||
OraclecloudProvider,
|
||||
"_bootstrap_region",
|
||||
OraclecloudProvider._home_region,
|
||||
)
|
||||
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def initialize_prowler_provider(
|
||||
provider: Provider,
|
||||
mutelist_processor: Processor | None = None,
|
||||
@@ -402,6 +435,15 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
|
||||
if prowler_provider_kwargs.get("registry_token"):
|
||||
image_kwargs["registry_token"] = prowler_provider_kwargs["registry_token"]
|
||||
return prowler_provider.test_connection(**image_kwargs)
|
||||
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
oraclecloud_kwargs = _normalize_oraclecloud_connection_test_kwargs(
|
||||
prowler_provider_kwargs
|
||||
)
|
||||
return prowler_provider.test_connection(
|
||||
**oraclecloud_kwargs,
|
||||
provider_id=provider.uid,
|
||||
raise_on_exception=False,
|
||||
)
|
||||
else:
|
||||
return prowler_provider.test_connection(
|
||||
**prowler_provider_kwargs,
|
||||
|
||||
@@ -295,16 +295,21 @@ from rest_framework_json_api import serializers
|
||||
"type": "string",
|
||||
"description": "The OCID of the tenancy.",
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "The OCI region identifier (e.g., us-ashburn-1, us-phoenix-1).",
|
||||
},
|
||||
"pass_phrase": {
|
||||
"type": "string",
|
||||
"description": "The passphrase for the private key, if encrypted.",
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"deprecated": True,
|
||||
"description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
|
||||
},
|
||||
},
|
||||
"required": ["user", "fingerprint", "tenancy", "region"],
|
||||
"required": ["user", "fingerprint", "tenancy"],
|
||||
"anyOf": [
|
||||
{"required": ["key_file"]},
|
||||
{"required": ["key_content"]},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
|
||||
@@ -1672,6 +1672,7 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
|
||||
validation_error.detail[f"secret/{key}"] = value
|
||||
del validation_error.detail[key]
|
||||
raise validation_error
|
||||
return serializer.validated_data
|
||||
|
||||
|
||||
class AwsProviderSecret(serializers.Serializer):
|
||||
@@ -1813,14 +1814,32 @@ class IacProviderSecret(serializers.Serializer):
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
class LegacyOCIRegionField(serializers.Field):
|
||||
def to_internal_value(self, data):
|
||||
return data
|
||||
|
||||
def to_representation(self, value):
|
||||
return value
|
||||
|
||||
|
||||
class OracleCloudProviderSecret(serializers.Serializer):
|
||||
user = serializers.CharField()
|
||||
fingerprint = serializers.CharField()
|
||||
key_file = serializers.CharField(required=False)
|
||||
key_content = serializers.CharField(required=False)
|
||||
tenancy = serializers.CharField()
|
||||
region = serializers.CharField()
|
||||
pass_phrase = serializers.CharField(required=False)
|
||||
region = LegacyOCIRegionField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs.pop("region", None)
|
||||
|
||||
if "key_file" not in attrs and "key_content" not in attrs:
|
||||
raise serializers.ValidationError(
|
||||
{"key_file": "Either key_file or key_content must be provided."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
@@ -1965,7 +1984,11 @@ class ProviderSecretCreateSerializer(RLSSerializer, BaseWriteProviderSecretSeria
|
||||
secret = attrs.get("secret")
|
||||
|
||||
validated_attrs = super().validate(attrs)
|
||||
self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
|
||||
validated_secret = self.validate_secret_based_on_provider(
|
||||
provider.provider, secret_type, secret
|
||||
)
|
||||
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
validated_attrs["secret"] = validated_secret
|
||||
return validated_attrs
|
||||
|
||||
|
||||
@@ -1997,7 +2020,11 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer):
|
||||
secret = attrs.get("secret")
|
||||
|
||||
validated_attrs = super().validate(attrs)
|
||||
self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
|
||||
validated_secret = self.validate_secret_based_on_provider(
|
||||
provider.provider, secret_type, secret
|
||||
)
|
||||
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
validated_attrs["secret"] = validated_secret
|
||||
return validated_attrs
|
||||
|
||||
|
||||
|
||||
@@ -312,8 +312,8 @@ ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES = env.int(
|
||||
"ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES", 30
|
||||
)
|
||||
ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
|
||||
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880
|
||||
) # 48h
|
||||
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 960
|
||||
) # 16h
|
||||
|
||||
# Selects where the persistent attack-paths graph is stored. The scan
|
||||
# temporary database is always Neo4j; only the sink is configurable.
|
||||
|
||||
@@ -91,6 +91,13 @@ def before_send(event, hint):
|
||||
log_msg = log_record.getMessage()
|
||||
log_lvl = log_record.levelno
|
||||
|
||||
if (
|
||||
getattr(log_record, "name", "") == "cartography.graph.job"
|
||||
and "Neo.ClientError.Database.DatabaseNotFound" in log_msg
|
||||
and "db-tmp-scan-" in log_msg
|
||||
):
|
||||
return None
|
||||
|
||||
# The Neo4j driver logs transient connection errors (defunct
|
||||
# connections, resets) at ERROR level via the `neo4j.io` logger.
|
||||
# `RetryableSession` handles these with retries. If all retries
|
||||
|
||||
@@ -13,7 +13,7 @@ GITHUB_OAUTH_CALLBACK_URL = env("SOCIAL_GITHUB_OAUTH_CALLBACK_URL", default="")
|
||||
ACCOUNT_LOGIN_METHODS = {"email"} # Use Email / Password authentication
|
||||
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
|
||||
ACCOUNT_EMAIL_VERIFICATION = "none" # Do not require email confirmation
|
||||
ACCOUNT_EMAIL_NOTIFICATIONS = True
|
||||
ACCOUNT_EMAIL_NOTIFICATIONS = False
|
||||
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
|
||||
REST_AUTH = {
|
||||
"TOKEN_MODEL": None,
|
||||
|
||||
@@ -8,6 +8,8 @@ import aioboto3
|
||||
import boto3
|
||||
import botocore
|
||||
import neo4j
|
||||
import neo4j.exceptions
|
||||
from api.attack_paths.database import DATABASE_NOT_FOUND_CODE
|
||||
from api.models import (
|
||||
AttackPathsScan as ProwlerAPIAttackPathsScan,
|
||||
)
|
||||
@@ -347,6 +349,12 @@ def sync_aws_account(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if (
|
||||
isinstance(e, neo4j.exceptions.Neo4jError)
|
||||
and e.code == DATABASE_NOT_FOUND_CODE
|
||||
):
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"Synced function {func_name} for AWS account {prowler_api_provider.uid} in {time.perf_counter() - func_t0:.3f}s (FAILED)"
|
||||
)
|
||||
|
||||
@@ -10,13 +10,10 @@ NormalizedList = _provider_config.NormalizedList
|
||||
PROVIDER_CONFIGS = _provider_config.PROVIDER_CONFIGS
|
||||
ProviderConfig = _provider_config.ProviderConfig
|
||||
|
||||
# Batch size for Neo4j write operations (resource labeling, cleanup)
|
||||
BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000)
|
||||
# Batch size for graph mutation operations (resource labeling and subgraph deletion)
|
||||
GRAPH_MUTATION_BATCH_SIZE = env.int("ATTACK_PATHS_GRAPH_MUTATION_BATCH_SIZE", 1000)
|
||||
# Batch size for Postgres findings fetch (keyset pagination page size)
|
||||
FINDINGS_BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 1000)
|
||||
# Batch size for temp-to-tenant graph sync (nodes and relationships per cursor page)
|
||||
SYNC_BATCH_SIZE = env.int("ATTACK_PATHS_SYNC_BATCH_SIZE", 1000)
|
||||
|
||||
# Neo4j internal labels (Prowler-specific, not provider-specific)
|
||||
# - `Internet`: Singleton node representing external internet access for exposed-resource queries
|
||||
# - `ProwlerFinding`: Label for finding nodes created by Prowler and linked to cloud resources
|
||||
|
||||
@@ -21,8 +21,8 @@ from cartography.config import Config as CartographyConfig
|
||||
from celery.utils.log import get_task_logger
|
||||
from prowler.config import config as ProwlerConfig
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
FINDINGS_BATCH_SIZE,
|
||||
GRAPH_MUTATION_BATCH_SIZE,
|
||||
get_node_uid_field,
|
||||
get_provider_resource_label,
|
||||
get_root_node_label,
|
||||
@@ -135,7 +135,7 @@ def add_resource_label(
|
||||
while labeled_count > 0:
|
||||
result = neo4j_session.run(
|
||||
query,
|
||||
{"provider_uid": provider_uid, "batch_size": BATCH_SIZE},
|
||||
{"provider_uid": provider_uid, "batch_size": GRAPH_MUTATION_BATCH_SIZE},
|
||||
)
|
||||
labeled_count = result.single().get("labeled_count", 0)
|
||||
total_labeled += labeled_count
|
||||
|
||||
@@ -372,7 +372,19 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
|
||||
except Exception as e:
|
||||
exception_message = utils.stringify_exception(e, "Attack Paths scan failed")
|
||||
logger.exception(exception_message)
|
||||
temporary_database_missing = (
|
||||
isinstance(e, graph_database.GraphDatabaseQueryException)
|
||||
and e.code == graph_database.DATABASE_NOT_FOUND_CODE
|
||||
and tmp_database_name in str(e)
|
||||
)
|
||||
if temporary_database_missing:
|
||||
logger.warning(exception_message)
|
||||
else:
|
||||
logger.exception(exception_message)
|
||||
cleanup_log_level = (
|
||||
logging.WARNING if temporary_database_missing else logging.ERROR
|
||||
)
|
||||
cleanup_exc_info = not temporary_database_missing
|
||||
ingestion_exceptions["global_error"] = exception_message
|
||||
|
||||
# Recover `graph_data_ready` based on how far the swap got
|
||||
@@ -387,19 +399,24 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.error(
|
||||
f"Failed to recover `graph_data_ready` for provider {attack_paths_scan.provider_id}",
|
||||
exc_info=True,
|
||||
logger.log(
|
||||
cleanup_log_level,
|
||||
"Failed to recover `graph_data_ready` for provider "
|
||||
f"{attack_paths_scan.provider_id}",
|
||||
exc_info=cleanup_exc_info,
|
||||
)
|
||||
|
||||
# Dropping the temporary database if it still exists
|
||||
try:
|
||||
graph_database.drop_database(tmp_cartography_config.neo4j_database)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to drop temporary Neo4j database `{tmp_cartography_config.neo4j_database}` during cleanup: {e}",
|
||||
exc_info=True,
|
||||
except Exception as cleanup_error:
|
||||
logger.log(
|
||||
cleanup_log_level,
|
||||
"Failed to drop temporary Neo4j database "
|
||||
f"`{tmp_cartography_config.neo4j_database}` during cleanup: "
|
||||
f"{cleanup_error}",
|
||||
exc_info=cleanup_exc_info,
|
||||
)
|
||||
|
||||
# Set Attack Paths scan state to FAILED
|
||||
@@ -407,10 +424,12 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
db_utils.finish_attack_paths_scan(
|
||||
attack_paths_scan, StateChoices.FAILED, ingestion_exceptions
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` (row may have been deleted): {e}",
|
||||
exc_info=True,
|
||||
except Exception as cleanup_error:
|
||||
logger.log(
|
||||
cleanup_log_level,
|
||||
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` "
|
||||
f"(row may have been deleted): {cleanup_error}",
|
||||
exc_info=cleanup_exc_info,
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
@@ -30,7 +30,6 @@ from tasks.jobs.attack_paths.config import (
|
||||
PROVIDER_CONFIGS,
|
||||
PROVIDER_ISOLATION_PROPERTIES,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
SYNC_BATCH_SIZE,
|
||||
NormalizedList,
|
||||
get_provider_label,
|
||||
get_tenant_label,
|
||||
@@ -116,6 +115,7 @@ def sync_nodes(
|
||||
Source and target sessions are opened sequentially per batch to avoid
|
||||
holding two Bolt connections simultaneously for the entire sync duration.
|
||||
"""
|
||||
batch_size = sink.sync_batch_size
|
||||
t0 = time.perf_counter()
|
||||
last_id = -1
|
||||
parents_synced = 0
|
||||
@@ -137,7 +137,7 @@ def sync_nodes(
|
||||
with graph_database.get_session(source_database) as source_session:
|
||||
result = source_session.run(
|
||||
NODE_FETCH_QUERY,
|
||||
{"last_id": last_id, "batch_size": SYNC_BATCH_SIZE},
|
||||
{"last_id": last_id, "batch_size": batch_size},
|
||||
)
|
||||
for record in result:
|
||||
batch_count += 1
|
||||
@@ -156,17 +156,17 @@ def sync_nodes(
|
||||
|
||||
for labels, batch in parent_groups.items():
|
||||
rendered_labels = _render_labels(labels, extra_labels)
|
||||
for sink_batch in _iter_sink_batches(batch):
|
||||
for sink_batch in _iter_sink_batches(batch, batch_size):
|
||||
sink.write_nodes(target_database, rendered_labels, sink_batch)
|
||||
|
||||
for child_label, batch in child_groups.items():
|
||||
rendered_labels = _render_labels((child_label,), extra_labels)
|
||||
for sink_batch in _iter_sink_batches(batch):
|
||||
for sink_batch in _iter_sink_batches(batch, batch_size):
|
||||
sink.write_nodes(target_database, rendered_labels, sink_batch)
|
||||
children_synced += len(batch)
|
||||
|
||||
for rel_type, batch in rel_groups.items():
|
||||
for sink_batch in _iter_sink_batches(batch):
|
||||
for sink_batch in _iter_sink_batches(batch, batch_size):
|
||||
sink.write_relationships(
|
||||
target_database, rel_type, provider_id, sink_batch
|
||||
)
|
||||
@@ -205,6 +205,7 @@ def sync_relationships(
|
||||
Source and target sessions are opened sequentially per batch to avoid
|
||||
holding two Bolt connections simultaneously for the entire sync duration.
|
||||
"""
|
||||
batch_size = sink.sync_batch_size
|
||||
t0 = time.perf_counter()
|
||||
last_id = -1
|
||||
total_synced = 0
|
||||
@@ -217,7 +218,7 @@ def sync_relationships(
|
||||
with graph_database.get_session(source_database) as source_session:
|
||||
result = source_session.run(
|
||||
RELATIONSHIPS_FETCH_QUERY,
|
||||
{"last_id": last_id, "batch_size": SYNC_BATCH_SIZE},
|
||||
{"last_id": last_id, "batch_size": batch_size},
|
||||
)
|
||||
for record in result:
|
||||
batch_count += 1
|
||||
@@ -229,7 +230,7 @@ def sync_relationships(
|
||||
break
|
||||
|
||||
for rel_type, batch in grouped.items():
|
||||
for sink_batch in _iter_sink_batches(batch):
|
||||
for sink_batch in _iter_sink_batches(batch, batch_size):
|
||||
sink.write_relationships(
|
||||
target_database, rel_type, provider_id, sink_batch
|
||||
)
|
||||
@@ -247,10 +248,9 @@ def sync_relationships(
|
||||
|
||||
def _iter_sink_batches(
|
||||
rows: list[dict[str, Any]],
|
||||
batch_size: int | None = None,
|
||||
batch_size: int,
|
||||
) -> Iterator[list[dict[str, Any]]]:
|
||||
"""Yield final sink write batches after source rows have been transformed."""
|
||||
batch_size = SYNC_BATCH_SIZE if batch_size is None else batch_size
|
||||
if batch_size <= 0:
|
||||
raise ValueError("Sink batch size must be greater than zero")
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import re
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -49,6 +49,7 @@ from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
|
||||
from config.env import env
|
||||
from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import DatabaseError, IntegrityError, OperationalError, transaction
|
||||
from django.db.models import (
|
||||
Case,
|
||||
@@ -99,6 +100,16 @@ COMPLIANCE_REQUIREMENT_COPY_COLUMNS = (
|
||||
FINDINGS_MICRO_BATCH_SIZE = env.int("DJANGO_FINDINGS_MICRO_BATCH_SIZE", default=3000)
|
||||
# Controls how many rows each ORM bulk_create/bulk_update call sends to Postgres.
|
||||
SCAN_DB_BATCH_SIZE = env.int("DJANGO_SCAN_DB_BATCH_SIZE", default=1000)
|
||||
# Rows per COPY statement when ingesting compliance requirement overviews. All
|
||||
# batches of a scan share one transaction/commit; the batch size only bounds the
|
||||
# client-side CSV buffer and how long each individual COPY statement runs on the
|
||||
# writer (memory footprint, lock time and slow-statement logging under load).
|
||||
COMPLIANCE_COPY_BATCH_SIZE = env.int("DJANGO_COMPLIANCE_COPY_BATCH_SIZE", default=2000)
|
||||
if COMPLIANCE_COPY_BATCH_SIZE < 1:
|
||||
raise ImproperlyConfigured(
|
||||
"DJANGO_COMPLIANCE_COPY_BATCH_SIZE must be a positive integer, got "
|
||||
f"{COMPLIANCE_COPY_BATCH_SIZE}"
|
||||
)
|
||||
# Throttle scan progress persistence: minimum progress delta (fraction 0-1)
|
||||
# between two persisted progress updates.
|
||||
PROGRESS_THROTTLE_DELTA = env.float("DJANGO_SCAN_PROGRESS_THROTTLE_DELTA", default=0.01)
|
||||
@@ -356,30 +367,36 @@ def _bulk_update_resource_failed_findings_counts(
|
||||
raise
|
||||
|
||||
|
||||
def _copy_compliance_requirement_rows(
|
||||
tenant_id: str, rows: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Stream compliance requirement rows into Postgres using COPY.
|
||||
class ComplianceRowScopeError(ValueError):
|
||||
"""A compliance requirement row does not belong to the scan being ingested."""
|
||||
|
||||
We leverage the admin connection (when available) to bypass the COPY + RLS
|
||||
restriction, writing only the fields required by
|
||||
``ComplianceRequirementOverview``.
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
rows: List of row dictionaries prepared by
|
||||
:func:`create_compliance_requirements`.
|
||||
def _compliance_requirement_rows_to_csv(
|
||||
rows: list[dict[str, Any]], tenant_id: str, scan_id: str
|
||||
) -> io.StringIO:
|
||||
"""Serialize compliance requirement rows into a CSV buffer for COPY.
|
||||
|
||||
COPY runs on the admin connection, which bypasses RLS, so every row is
|
||||
checked against the expected tenant/scan before it is written: a mismatched
|
||||
row would otherwise be inserted verbatim into another tenant's data.
|
||||
"""
|
||||
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer)
|
||||
|
||||
datetime_now = datetime.now(tz=UTC)
|
||||
for row in rows:
|
||||
row_tenant_id = str(row.get("tenant_id"))
|
||||
row_scan_id = str(row.get("scan_id"))
|
||||
if row_tenant_id != tenant_id or row_scan_id != scan_id:
|
||||
raise ComplianceRowScopeError(
|
||||
"Compliance requirement row does not belong to the scan being "
|
||||
f"ingested (expected tenant {tenant_id} / scan {scan_id}, got "
|
||||
f"tenant {row_tenant_id} / scan {row_scan_id})"
|
||||
)
|
||||
writer.writerow(
|
||||
[
|
||||
str(row.get("id")),
|
||||
str(row.get("tenant_id")),
|
||||
row_tenant_id,
|
||||
(row.get("inserted_at") or datetime_now).isoformat(),
|
||||
row.get("compliance_id") or "",
|
||||
row.get("framework") or "",
|
||||
@@ -393,65 +410,100 @@ def _copy_compliance_requirement_rows(
|
||||
row.get("total_checks", 0),
|
||||
row.get("passed_findings", 0),
|
||||
row.get("total_findings", 0),
|
||||
str(row.get("scan_id")),
|
||||
row_scan_id,
|
||||
]
|
||||
)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
return csv_buffer
|
||||
|
||||
|
||||
def _copy_compliance_requirement_rows(
|
||||
tenant_id: str, scan_id: str, rows: Iterable[dict[str, Any]], batch_size: int
|
||||
) -> int:
|
||||
"""Replace a scan's compliance requirement rows using batched COPY.
|
||||
|
||||
We leverage the admin connection (when available) to bypass the COPY + RLS
|
||||
restriction. The scan's DELETE and every COPY batch run on one connection
|
||||
inside a single transaction with a single commit, so the writer takes one
|
||||
fsync per scan instead of one per batch, and a failed ingest rolls back
|
||||
without committing a partial delete/insert (which a retry would otherwise
|
||||
delete again, feeding dead rows to autovacuum).
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
scan_id: Scan whose previous rows are replaced.
|
||||
rows: Iterable of row dictionaries, consumed lazily batch by batch.
|
||||
batch_size: Number of rows per COPY statement.
|
||||
|
||||
Returns:
|
||||
int: total number of rows staged and committed.
|
||||
|
||||
Raises:
|
||||
ComplianceRowScopeError: A row belongs to another tenant or scan.
|
||||
"""
|
||||
# Normalized once so the per-row scope check compares like with like even if
|
||||
# the caller passes UUID instances instead of strings.
|
||||
tenant_id = str(tenant_id)
|
||||
scan_id = str(scan_id)
|
||||
total_rows = 0
|
||||
batch_num = 0
|
||||
copy_sql = (
|
||||
"COPY compliance_requirements_overviews ("
|
||||
+ ", ".join(COMPLIANCE_REQUIREMENT_COPY_COLUMNS)
|
||||
+ ") FROM STDIN WITH (FORMAT CSV, DELIMITER ',', QUOTE '\"', ESCAPE '\"', NULL '\\N')"
|
||||
)
|
||||
|
||||
try:
|
||||
with psycopg_connection(MainRouter.admin_db) as connection:
|
||||
connection.autocommit = False
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id])
|
||||
cursor.copy_expert(copy_sql, csv_buffer)
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
csv_buffer.close()
|
||||
with psycopg_connection(MainRouter.admin_db) as connection:
|
||||
connection.autocommit = False
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id])
|
||||
# Idempotent re-run: clearing this scan's rows inside the same
|
||||
# transaction keeps delete + reinsert atomic.
|
||||
cursor.execute(
|
||||
"DELETE FROM compliance_requirements_overviews "
|
||||
"WHERE tenant_id = %s AND scan_id = %s",
|
||||
[tenant_id, scan_id],
|
||||
)
|
||||
for batch, _is_last in batched(rows, batch_size):
|
||||
if not batch:
|
||||
continue
|
||||
batch_num += 1
|
||||
csv_buffer = _compliance_requirement_rows_to_csv(
|
||||
batch, tenant_id, scan_id
|
||||
)
|
||||
try:
|
||||
cursor.copy_expert(copy_sql, csv_buffer)
|
||||
finally:
|
||||
csv_buffer.close()
|
||||
total_rows += len(batch)
|
||||
logger.info(
|
||||
f"Compliance COPY batch {batch_num}: staged {len(batch)} rows "
|
||||
f"({total_rows} total)"
|
||||
)
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
return total_rows
|
||||
|
||||
|
||||
def _persist_compliance_requirement_rows(
|
||||
tenant_id: str, rows: Iterable[dict[str, Any]], batch_size: int = 10000
|
||||
def _bulk_create_compliance_requirement_rows(
|
||||
tenant_id: str, scan_id: str, rows: Iterable[dict[str, Any]], batch_size: int
|
||||
) -> int:
|
||||
"""Persist compliance requirement rows using batched COPY with ORM fallback.
|
||||
"""Replace a scan's compliance requirement rows via the ORM.
|
||||
|
||||
``rows`` is consumed lazily in batches, so peak memory stays at ~``batch_size``
|
||||
rows instead of the full set. A batch that fails COPY falls back to an ORM
|
||||
``bulk_create`` of just that batch.
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
rows: Iterable of row dictionaries reflecting the compliance overview
|
||||
state for a scan.
|
||||
batch_size: Number of rows per COPY batch (default: 10000).
|
||||
|
||||
Returns:
|
||||
int: total number of rows persisted.
|
||||
Fallback for when COPY is unavailable; the delete and every ``bulk_create``
|
||||
share one RLS transaction so the replacement stays atomic.
|
||||
"""
|
||||
total_rows = 0
|
||||
batch_num = 0
|
||||
|
||||
for batch, _is_last in batched(rows, batch_size):
|
||||
if not batch:
|
||||
continue
|
||||
batch_num += 1
|
||||
try:
|
||||
_copy_compliance_requirement_rows(tenant_id, batch)
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
f"COPY bulk insert for compliance requirements batch {batch_num} "
|
||||
"failed; falling back to ORM bulk_create for this batch",
|
||||
exc_info=error,
|
||||
)
|
||||
with rls_transaction(tenant_id):
|
||||
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
|
||||
for batch, _is_last in batched(rows, batch_size):
|
||||
if not batch:
|
||||
continue
|
||||
fallback_objects = [
|
||||
ComplianceRequirementOverview(
|
||||
id=row["id"],
|
||||
@@ -473,20 +525,58 @@ def _persist_compliance_requirement_rows(
|
||||
)
|
||||
for row in batch
|
||||
]
|
||||
with rls_transaction(tenant_id):
|
||||
ComplianceRequirementOverview.objects.bulk_create(
|
||||
fallback_objects, batch_size=500
|
||||
)
|
||||
|
||||
total_rows += len(batch)
|
||||
logger.info(
|
||||
f"Compliance COPY batch {batch_num}: inserted {len(batch)} rows "
|
||||
f"({total_rows} total)"
|
||||
)
|
||||
|
||||
ComplianceRequirementOverview.objects.bulk_create(
|
||||
fallback_objects, batch_size=500
|
||||
)
|
||||
total_rows += len(batch)
|
||||
return total_rows
|
||||
|
||||
|
||||
def _persist_compliance_requirement_rows(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
rows_factory: Callable[[], Iterable[dict[str, Any]]],
|
||||
batch_size: int | None = None,
|
||||
) -> int:
|
||||
"""Persist a scan's compliance requirement rows, replacing any previous ones.
|
||||
|
||||
``rows_factory`` must return a fresh row iterator on every call: the COPY
|
||||
path consumes it lazily in batches (peak memory ~``batch_size`` rows), and
|
||||
if COPY fails the whole ingest falls back to a single ORM transaction that
|
||||
re-iterates the rows.
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
scan_id: Scan whose compliance overview rows are being replaced.
|
||||
rows_factory: Callable returning an iterable of row dictionaries.
|
||||
batch_size: Rows per COPY/bulk_create batch (default:
|
||||
``COMPLIANCE_COPY_BATCH_SIZE``).
|
||||
|
||||
Returns:
|
||||
int: total number of rows persisted.
|
||||
"""
|
||||
if batch_size is None:
|
||||
batch_size = COMPLIANCE_COPY_BATCH_SIZE
|
||||
|
||||
try:
|
||||
return _copy_compliance_requirement_rows(
|
||||
tenant_id, scan_id, rows_factory(), batch_size
|
||||
)
|
||||
except ComplianceRowScopeError:
|
||||
# Cross-tenant/scan rows are a bug in the caller, not a COPY failure:
|
||||
# retrying through the ORM would persist the very rows we rejected.
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
"COPY bulk insert for compliance requirements failed; "
|
||||
"falling back to ORM bulk_create",
|
||||
exc_info=error,
|
||||
)
|
||||
return _bulk_create_compliance_requirement_rows(
|
||||
tenant_id, scan_id, rows_factory(), batch_size
|
||||
)
|
||||
|
||||
|
||||
def _create_compliance_summaries(
|
||||
tenant_id: str, scan_id: str, requirement_statuses: dict
|
||||
) -> None:
|
||||
@@ -885,15 +975,19 @@ def _process_finding_micro_batch(
|
||||
# Denormalized resource arrays populated directly on insert
|
||||
# (was previously a separate bulk_update; saves a CASE WHEN
|
||||
# over thousands of rows per micro-batch).
|
||||
resource_regions=[resource_instance.region]
|
||||
if resource_instance.region
|
||||
else [],
|
||||
resource_services=[resource_instance.service]
|
||||
if resource_instance.service
|
||||
else [],
|
||||
resource_types=[resource_instance.type]
|
||||
if resource_instance.type
|
||||
else [],
|
||||
resource_regions=(
|
||||
[resource_instance.region]
|
||||
if resource_instance.region
|
||||
else []
|
||||
),
|
||||
resource_services=(
|
||||
[resource_instance.service]
|
||||
if resource_instance.service
|
||||
else []
|
||||
),
|
||||
resource_types=(
|
||||
[resource_instance.type] if resource_instance.type else []
|
||||
),
|
||||
)
|
||||
findings_to_create.append(finding_instance)
|
||||
resource_denormalized_data.append(
|
||||
@@ -1708,8 +1802,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
)
|
||||
|
||||
# Yield rows lazily (consumed batch-by-batch by COPY) so peak memory
|
||||
# stays bounded; tally requirement_statuses in the same pass.
|
||||
# stays bounded; tally requirement_statuses in the same pass. The
|
||||
# ORM fallback re-iterates from scratch, so the tally resets first.
|
||||
def _iter_compliance_requirement_rows():
|
||||
requirement_statuses.clear()
|
||||
for region in regions:
|
||||
region_stats = region_requirement_stats.get(region, {})
|
||||
region_findings = findings_count_by_compliance.get(region, {})
|
||||
@@ -1773,12 +1869,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
"total_findings": total_findings,
|
||||
}
|
||||
|
||||
# Idempotent re-run: clear this scan's rows before re-inserting.
|
||||
with rls_transaction(tenant_id):
|
||||
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
|
||||
|
||||
# The delete of the scan's previous rows happens inside the same
|
||||
# transaction as the inserts (see _copy_compliance_requirement_rows).
|
||||
requirements_created = _persist_compliance_requirement_rows(
|
||||
tenant_id, _iter_compliance_requirement_rows()
|
||||
tenant_id_str, scan_id_str, _iter_compliance_requirement_rows
|
||||
)
|
||||
|
||||
# Create pre-aggregated summaries for fast compliance overview lookups
|
||||
|
||||
@@ -11,6 +11,7 @@ from api.compliance import (
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import delete_related_daily_task, rls_transaction
|
||||
from api.decorators import handle_provider_deletion, set_tenant
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from api.models import (
|
||||
Finding,
|
||||
Integration,
|
||||
@@ -666,7 +667,13 @@ class AttackPathsScanRLSTask(RLSTask):
|
||||
scan_id = kwargs.get("scan_id")
|
||||
|
||||
if tenant_id and scan_id:
|
||||
logger.error(f"Attack paths scan task {task_id} failed: {exc}")
|
||||
if isinstance(exc, ProviderDeletedException):
|
||||
logger.warning(
|
||||
f"Attack paths scan task {task_id} stopped because its provider "
|
||||
f"or tenant was deleted: {exc}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Attack paths scan task {task_id} failed: {exc}")
|
||||
attack_paths_db_utils.fail_attack_paths_scan(tenant_id, scan_id, str(exc))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import neo4j.exceptions
|
||||
import pytest
|
||||
from tasks.jobs.attack_paths import aws
|
||||
|
||||
DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
|
||||
|
||||
|
||||
def _make_neo4j_error(code: str) -> neo4j.exceptions.Neo4jError:
|
||||
return neo4j.exceptions.Neo4jError._hydrate_neo4j(
|
||||
code=code,
|
||||
message="graph query failed",
|
||||
)
|
||||
|
||||
|
||||
def _resource_functions(failing_sync, following_sync):
|
||||
return {
|
||||
"failing_sync": failing_sync,
|
||||
"following_sync": following_sync,
|
||||
"permission_relationships": MagicMock(),
|
||||
"resourcegroupstaggingapi": MagicMock(),
|
||||
}
|
||||
|
||||
|
||||
def test_sync_aws_account_reraises_database_not_found_immediately():
|
||||
error = _make_neo4j_error(DATABASE_NOT_FOUND_CODE)
|
||||
failing_sync = MagicMock(side_effect=error)
|
||||
following_sync = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
aws.cartography_aws,
|
||||
"RESOURCE_FUNCTIONS",
|
||||
_resource_functions(failing_sync, following_sync),
|
||||
),
|
||||
patch.object(aws.db_utils, "update_attack_paths_scan_progress"),
|
||||
patch.object(aws.utils, "stringify_exception") as stringify_exception,
|
||||
patch.object(aws.logger, "warning") as warning,
|
||||
pytest.raises(neo4j.exceptions.Neo4jError) as exc_info,
|
||||
):
|
||||
aws.sync_aws_account(
|
||||
SimpleNamespace(uid="123456789012"),
|
||||
[
|
||||
"failing_sync",
|
||||
"following_sync",
|
||||
"permission_relationships",
|
||||
"resourcegroupstaggingapi",
|
||||
],
|
||||
{},
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
assert exc_info.value is error
|
||||
following_sync.assert_not_called()
|
||||
stringify_exception.assert_not_called()
|
||||
warning.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
_make_neo4j_error("Neo.ClientError.Statement.SyntaxError"),
|
||||
RuntimeError("resource sync failed"),
|
||||
],
|
||||
ids=["different-neo4j-error", "non-neo4j-error"],
|
||||
)
|
||||
def test_sync_aws_account_warns_and_continues_for_other_exceptions(error):
|
||||
failing_sync = MagicMock(side_effect=error)
|
||||
following_sync = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
aws.cartography_aws,
|
||||
"RESOURCE_FUNCTIONS",
|
||||
_resource_functions(failing_sync, following_sync),
|
||||
),
|
||||
patch.object(aws.db_utils, "update_attack_paths_scan_progress"),
|
||||
patch.object(
|
||||
aws.utils,
|
||||
"stringify_exception",
|
||||
return_value="formatted failure",
|
||||
),
|
||||
patch.object(aws.logger, "warning") as warning,
|
||||
):
|
||||
failed_syncs = aws.sync_aws_account(
|
||||
SimpleNamespace(uid="123456789012"),
|
||||
[
|
||||
"failing_sync",
|
||||
"following_sync",
|
||||
"permission_relationships",
|
||||
"resourcegroupstaggingapi",
|
||||
],
|
||||
{},
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
assert failed_syncs == {"failing_sync": "formatted failure"}
|
||||
following_sync.assert_called_once_with()
|
||||
warning.assert_called_once()
|
||||
assert "Continuing to the next AWS sync function" in warning.call_args.args[0]
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from contextlib import nullcontext
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
@@ -5,7 +6,9 @@ from unittest.mock import MagicMock, call, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.db_utils import rls_transaction
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from api.models import (
|
||||
AttackPathsScan,
|
||||
Finding,
|
||||
@@ -250,6 +253,32 @@ class TestAttackPathsRun:
|
||||
mock_starting.assert_not_called()
|
||||
mock_create_db.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ingestion_error", "temporary_database_missing"),
|
||||
[
|
||||
(RuntimeError("ingestion boom"), False),
|
||||
(
|
||||
GraphDatabaseQueryException(
|
||||
message="Graph not found: db-scan-id",
|
||||
code="Neo.ClientError.Database.DatabaseNotFound",
|
||||
),
|
||||
True,
|
||||
),
|
||||
(
|
||||
GraphDatabaseQueryException(
|
||||
message="Graph not found: db-tenant-id",
|
||||
code="Neo.ClientError.Database.DatabaseNotFound",
|
||||
),
|
||||
False,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"regular-error",
|
||||
"temporary-database-missing",
|
||||
"sink-database-missing",
|
||||
],
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.scan.logger")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
|
||||
return_value="Cartography failed: ingestion boom",
|
||||
@@ -302,6 +331,9 @@ class TestAttackPathsRun:
|
||||
mock_drop_db,
|
||||
mock_event_loop,
|
||||
mock_stringify,
|
||||
mock_logger,
|
||||
ingestion_error,
|
||||
temporary_database_missing,
|
||||
tenants_fixture,
|
||||
aws_provider,
|
||||
scans_fixture,
|
||||
@@ -321,7 +353,11 @@ class TestAttackPathsRun:
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__.return_value = mock_session
|
||||
session_ctx.__exit__.return_value = False
|
||||
ingestion_fn = MagicMock(side_effect=RuntimeError("ingestion boom"))
|
||||
ingestion_fn = MagicMock(side_effect=ingestion_error)
|
||||
if temporary_database_missing:
|
||||
mock_finish.side_effect = DatabaseError(
|
||||
"Save with update_fields did not affect any rows"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -337,13 +373,28 @@ class TestAttackPathsRun:
|
||||
return_value=ingestion_fn,
|
||||
),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="ingestion boom"):
|
||||
with pytest.raises(type(ingestion_error)):
|
||||
attack_paths_run(str(tenant.id), str(scan.id), "task-456")
|
||||
|
||||
failure_args = mock_finish.call_args[0]
|
||||
assert failure_args[0] is attack_paths_scan
|
||||
assert failure_args[1] == StateChoices.FAILED
|
||||
assert failure_args[2] == {"global_error": "Cartography failed: ingestion boom"}
|
||||
mock_drop_db.assert_called_once_with("db-scan-id")
|
||||
if temporary_database_missing:
|
||||
mock_logger.warning.assert_any_call("Cartography failed: ingestion boom")
|
||||
mock_logger.exception.assert_not_called()
|
||||
mock_logger.log.assert_called_once_with(
|
||||
logging.WARNING,
|
||||
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` "
|
||||
"(row may have been deleted): Save with update_fields did not affect "
|
||||
"any rows",
|
||||
exc_info=False,
|
||||
)
|
||||
else:
|
||||
mock_logger.exception.assert_called_once_with(
|
||||
"Cartography failed: ingestion boom"
|
||||
)
|
||||
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
|
||||
@@ -1265,6 +1316,33 @@ class TestAttackPathsScanRLSTaskOnFailure:
|
||||
|
||||
mock_fail.assert_called_once_with("t-1", "s-1", "boom")
|
||||
|
||||
def test_on_failure_logs_provider_deletion_as_warning(self):
|
||||
from tasks.tasks import AttackPathsScanRLSTask
|
||||
|
||||
task = AttackPathsScanRLSTask()
|
||||
error = ProviderDeletedException("provider deleted")
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.logger") as mock_logger,
|
||||
patch(
|
||||
"tasks.tasks.attack_paths_db_utils.fail_attack_paths_scan"
|
||||
) as mock_fail,
|
||||
):
|
||||
task.on_failure(
|
||||
exc=error,
|
||||
task_id="task-abc",
|
||||
args=(),
|
||||
kwargs={"tenant_id": "t-1", "scan_id": "s-1"},
|
||||
_einfo=None,
|
||||
)
|
||||
|
||||
mock_logger.warning.assert_called_once_with(
|
||||
"Attack paths scan task task-abc stopped because its provider or tenant "
|
||||
"was deleted: provider deleted"
|
||||
)
|
||||
mock_logger.error.assert_not_called()
|
||||
mock_fail.assert_called_once_with("t-1", "s-1", "provider deleted")
|
||||
|
||||
def test_on_failure_skips_when_missing_kwargs(self):
|
||||
from tasks.tasks import AttackPathsScanRLSTask
|
||||
|
||||
@@ -1896,7 +1974,7 @@ class TestSyncNodes:
|
||||
mock_source_1.run.return_value = [row]
|
||||
mock_source_2 = MagicMock()
|
||||
mock_source_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
@@ -1933,7 +2011,7 @@ class TestSyncNodes:
|
||||
src_1.run.return_value = [row]
|
||||
src_2 = MagicMock()
|
||||
src_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
sink.write_nodes.side_effect = lambda *_a, **_kw: call_order.append(
|
||||
"sink:write"
|
||||
)
|
||||
@@ -1969,18 +2047,15 @@ class TestSyncNodes:
|
||||
src_2.run.return_value = [row_b]
|
||||
src_3 = MagicMock()
|
||||
src_3.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
_make_session_ctx(src_3),
|
||||
],
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
_make_session_ctx(src_3),
|
||||
],
|
||||
):
|
||||
result = sync_module.sync_nodes("src", "tgt", "t-1", "p-1", sink, [])
|
||||
|
||||
@@ -2009,17 +2084,14 @@ class TestSyncNodes:
|
||||
src_1.run.return_value = [row]
|
||||
src_2 = MagicMock()
|
||||
src_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=2)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
],
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 2),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
],
|
||||
):
|
||||
result = sync_module.sync_nodes(
|
||||
"src", "tgt", "t-1", "p-1", sink, normalized_lists
|
||||
@@ -2037,7 +2109,7 @@ class TestSyncNodes:
|
||||
def test_sync_nodes_empty_source_returns_zero(self):
|
||||
src = MagicMock()
|
||||
src.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
@@ -2066,7 +2138,7 @@ class TestSyncRelationships:
|
||||
src_1.run.return_value = [row]
|
||||
src_2 = MagicMock()
|
||||
src_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
sink.write_relationships.side_effect = lambda *_a, **_kw: call_order.append(
|
||||
"sink:write"
|
||||
)
|
||||
@@ -2104,18 +2176,15 @@ class TestSyncRelationships:
|
||||
src_2.run.return_value = [row_b]
|
||||
src_3 = MagicMock()
|
||||
src_3.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
_make_session_ctx(src_3),
|
||||
],
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
_make_session_ctx(src_3),
|
||||
],
|
||||
):
|
||||
total = sync_module.sync_relationships("src", "tgt", "p-1", sink)
|
||||
|
||||
@@ -2140,17 +2209,14 @@ class TestSyncRelationships:
|
||||
src_1.run.return_value = rows
|
||||
src_2 = MagicMock()
|
||||
src_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=2)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
],
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 2),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
],
|
||||
):
|
||||
total = sync_module.sync_relationships("src", "tgt", "p-1", sink)
|
||||
|
||||
@@ -2163,7 +2229,7 @@ class TestSyncRelationships:
|
||||
def test_sync_relationships_empty_source_returns_zero(self):
|
||||
src = MagicMock()
|
||||
src.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
@@ -3056,6 +3122,61 @@ class TestCleanupStaleAttackPathsScans:
|
||||
ap_scan.refresh_from_db()
|
||||
assert ap_scan.state == StateChoices.FAILED
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("age_seconds", "should_clean"),
|
||||
[
|
||||
(960 * 60 - 1, False),
|
||||
(960 * 60, False),
|
||||
(960 * 60 + 1, True),
|
||||
],
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.cleanup.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.cleanup._revoke_task")
|
||||
@patch("tasks.jobs.attack_paths.cleanup._ping_workers")
|
||||
def test_stale_threshold_boundary_is_strict(
|
||||
self,
|
||||
mock_ping,
|
||||
mock_revoke,
|
||||
mock_drop_db,
|
||||
mock_recover,
|
||||
age_seconds,
|
||||
should_clean,
|
||||
tenants_fixture,
|
||||
aws_provider,
|
||||
):
|
||||
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
ap_scan, task_result = self._create_executing_scan(
|
||||
tenants_fixture[0],
|
||||
aws_provider,
|
||||
started_at=now - timedelta(seconds=age_seconds),
|
||||
worker="live-worker@host",
|
||||
)
|
||||
mock_ping.return_value = ({"live-worker@host"}, set())
|
||||
|
||||
with patch("tasks.jobs.attack_paths.cleanup.datetime") as mock_datetime:
|
||||
mock_datetime.now.return_value = now
|
||||
result = cleanup_stale_attack_paths_scans()
|
||||
|
||||
assert result["cleaned_up_count"] == int(should_clean)
|
||||
ap_scan.refresh_from_db()
|
||||
expected_state = StateChoices.FAILED if should_clean else StateChoices.EXECUTING
|
||||
assert ap_scan.state == expected_state
|
||||
if should_clean:
|
||||
mock_revoke.assert_called_once_with(task_result, terminate=True)
|
||||
mock_drop_db.assert_called_once()
|
||||
mock_recover.assert_called_once()
|
||||
else:
|
||||
mock_revoke.assert_not_called()
|
||||
mock_drop_db.assert_not_called()
|
||||
mock_recover.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
|
||||
@patch(
|
||||
|
||||
@@ -26,6 +26,7 @@ from prowler.lib.check.models import Severity
|
||||
from prowler.lib.outputs.finding import Status
|
||||
from tasks.jobs.scan import (
|
||||
_ATTACK_SURFACE_MAPPING_CACHE,
|
||||
ComplianceRowScopeError,
|
||||
_aggregate_findings_by_region,
|
||||
_bulk_update_resource_failed_findings_counts,
|
||||
_copy_compliance_requirement_rows,
|
||||
@@ -2314,9 +2315,9 @@ class TestCreateComplianceRequirements:
|
||||
create_compliance_requirements(tenant_id, scan_id)
|
||||
|
||||
mock_persist.assert_called_once()
|
||||
persisted_rows = mock_persist.call_args[0][1]
|
||||
rows_factory = mock_persist.call_args[0][2]
|
||||
requirement_row = next(
|
||||
row for row in persisted_rows if row["requirement_id"] == "1.1"
|
||||
row for row in rows_factory() if row["requirement_id"] == "1.1"
|
||||
)
|
||||
assert requirement_row["requirement_status"] == "FAIL"
|
||||
|
||||
@@ -2454,18 +2455,26 @@ class TestComplianceRequirementCopy:
|
||||
}
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
_copy_compliance_requirement_rows(
|
||||
str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
|
||||
)
|
||||
|
||||
mock_psycopg_connection.assert_called_once_with("admin")
|
||||
connection.cursor.assert_called_once()
|
||||
cursor.execute.assert_called_once()
|
||||
# One execute for set_config plus one for the scan's DELETE.
|
||||
assert cursor.execute.call_count == 2
|
||||
delete_sql, delete_params = cursor.execute.call_args_list[1][0]
|
||||
assert "DELETE FROM compliance_requirements_overviews" in delete_sql
|
||||
assert delete_params == [str(row["tenant_id"]), str(row["scan_id"])]
|
||||
cursor.copy_expert.assert_called_once()
|
||||
connection.commit.assert_called_once()
|
||||
|
||||
csv_rows = list(csv.reader(StringIO(captured["data"])))
|
||||
assert csv_rows[0][0] == str(row["id"])
|
||||
assert csv_rows[0][5] == ""
|
||||
assert csv_rows[0][-1] == str(row["scan_id"])
|
||||
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.filter")
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
@patch(
|
||||
@@ -2473,7 +2482,7 @@ class TestComplianceRequirementCopy:
|
||||
side_effect=Exception("copy failed"),
|
||||
)
|
||||
def test_persist_compliance_requirement_rows_fallback(
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_filter
|
||||
):
|
||||
inserted_at = datetime.now(UTC)
|
||||
row = {
|
||||
@@ -2494,16 +2503,22 @@ class TestComplianceRequirementCopy:
|
||||
}
|
||||
|
||||
tenant_id = row["tenant_id"]
|
||||
scan_id = str(row["scan_id"])
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.__enter__.return_value = None
|
||||
ctx.__exit__.return_value = False
|
||||
mock_rls_transaction.return_value = ctx
|
||||
|
||||
_persist_compliance_requirement_rows(tenant_id, [row])
|
||||
_persist_compliance_requirement_rows(tenant_id, scan_id, lambda: [row])
|
||||
|
||||
mock_copy.assert_called_once_with(tenant_id, [row])
|
||||
mock_copy.assert_called_once()
|
||||
assert mock_copy.call_args[0][0] == tenant_id
|
||||
assert mock_copy.call_args[0][1] == scan_id
|
||||
mock_rls_transaction.assert_called_once_with(tenant_id)
|
||||
# The fallback replaces the scan's rows: delete + insert atomically.
|
||||
mock_filter.assert_called_once_with(scan_id=scan_id)
|
||||
mock_filter.return_value.delete.assert_called_once()
|
||||
mock_bulk_create.assert_called_once()
|
||||
|
||||
args, kwargs = mock_bulk_create.call_args
|
||||
@@ -2515,13 +2530,18 @@ class TestComplianceRequirementCopy:
|
||||
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
@patch("tasks.jobs.scan._copy_compliance_requirement_rows")
|
||||
@patch("tasks.jobs.scan._copy_compliance_requirement_rows", return_value=0)
|
||||
def test_persist_compliance_requirement_rows_no_rows(
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create
|
||||
):
|
||||
_persist_compliance_requirement_rows(str(uuid.uuid4()), [])
|
||||
# Even with no rows the COPY path runs: it must clear the scan's
|
||||
# previous rows so a re-run with fewer findings drops stale data.
|
||||
total = _persist_compliance_requirement_rows(
|
||||
str(uuid.uuid4()), str(uuid.uuid4()), lambda: []
|
||||
)
|
||||
|
||||
mock_copy.assert_not_called()
|
||||
assert total == 0
|
||||
mock_copy.assert_called_once()
|
||||
mock_rls_transaction.assert_not_called()
|
||||
mock_bulk_create.assert_not_called()
|
||||
|
||||
@@ -2610,11 +2630,12 @@ class TestComplianceRequirementCopy:
|
||||
]
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
_copy_compliance_requirement_rows(tenant_id, rows)
|
||||
_copy_compliance_requirement_rows(tenant_id, str(scan_id), rows, 2000)
|
||||
|
||||
mock_psycopg_connection.assert_called_once_with("admin")
|
||||
connection.cursor.assert_called_once()
|
||||
cursor.execute.assert_called_once()
|
||||
# set_config + DELETE of the scan's previous rows.
|
||||
assert cursor.execute.call_count == 2
|
||||
cursor.copy_expert.assert_called_once()
|
||||
|
||||
csv_rows = list(csv.reader(StringIO(captured["data"])))
|
||||
@@ -2644,6 +2665,60 @@ class TestComplianceRequirementCopy:
|
||||
assert csv_rows[2][5] == "2.0"
|
||||
assert csv_rows[2][9] == "MANUAL"
|
||||
|
||||
@patch("tasks.jobs.scan.psycopg_connection")
|
||||
def test_copy_compliance_requirement_rows_batches_share_one_transaction(
|
||||
self, mock_psycopg_connection, settings
|
||||
):
|
||||
"""Every COPY batch runs on the same connection with a single commit."""
|
||||
settings.DATABASES.setdefault("admin", settings.DATABASES["default"])
|
||||
|
||||
connection = MagicMock()
|
||||
cursor = MagicMock()
|
||||
cursor_context = MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
cursor_context.__exit__.return_value = False
|
||||
connection.cursor.return_value = cursor_context
|
||||
connection.__enter__.return_value = connection
|
||||
connection.__exit__.return_value = False
|
||||
|
||||
context_manager = MagicMock()
|
||||
context_manager.__enter__.return_value = connection
|
||||
context_manager.__exit__.return_value = False
|
||||
mock_psycopg_connection.return_value = context_manager
|
||||
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
inserted_at = datetime.now(UTC)
|
||||
rows = [
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"inserted_at": inserted_at,
|
||||
"compliance_id": "cisa_aws",
|
||||
"framework": "CISA",
|
||||
"version": "1.0",
|
||||
"description": f"Requirement {index}",
|
||||
"region": "us-east-1",
|
||||
"requirement_id": f"req-{index}",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 1,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 1,
|
||||
"scan_id": scan_id,
|
||||
}
|
||||
for index in range(3)
|
||||
]
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
total = _copy_compliance_requirement_rows(tenant_id, scan_id, rows, 1)
|
||||
|
||||
assert total == 3
|
||||
# One connection, three COPY statements, one commit for the whole scan.
|
||||
mock_psycopg_connection.assert_called_once_with("admin")
|
||||
assert cursor.copy_expert.call_count == 3
|
||||
connection.commit.assert_called_once()
|
||||
connection.rollback.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.scan.psycopg_connection")
|
||||
def test_copy_compliance_requirement_rows_null_values(
|
||||
self, mock_psycopg_connection, settings
|
||||
@@ -2691,7 +2766,9 @@ class TestComplianceRequirementCopy:
|
||||
}
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
_copy_compliance_requirement_rows(
|
||||
str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
|
||||
)
|
||||
|
||||
csv_rows = list(csv.reader(StringIO(captured["data"])))
|
||||
assert len(csv_rows) == 1
|
||||
@@ -2747,7 +2824,9 @@ class TestComplianceRequirementCopy:
|
||||
}
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
_copy_compliance_requirement_rows(
|
||||
str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
|
||||
)
|
||||
|
||||
# Verify CSV was generated (csv module handles escaping automatically)
|
||||
csv_rows = list(csv.reader(StringIO(captured["data"])))
|
||||
@@ -2808,7 +2887,9 @@ class TestComplianceRequirementCopy:
|
||||
|
||||
before_call = datetime.now(UTC)
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
_copy_compliance_requirement_rows(
|
||||
str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
|
||||
)
|
||||
after_call = datetime.now(UTC)
|
||||
|
||||
csv_rows = list(csv.reader(StringIO(captured["data"])))
|
||||
@@ -2861,12 +2942,84 @@ class TestComplianceRequirementCopy:
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
with pytest.raises(Exception, match="COPY command failed"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
_copy_compliance_requirement_rows(
|
||||
str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
|
||||
)
|
||||
|
||||
# Verify rollback was called
|
||||
connection.rollback.assert_called_once()
|
||||
connection.commit.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("mismatched_field", ["tenant_id", "scan_id"])
|
||||
@patch("tasks.jobs.scan.psycopg_connection")
|
||||
def test_copy_compliance_requirement_rows_rejects_out_of_scope_rows(
|
||||
self, mock_psycopg_connection, mismatched_field, settings
|
||||
):
|
||||
"""COPY bypasses RLS, so rows from another tenant/scan must be rejected."""
|
||||
settings.DATABASES.setdefault("admin", settings.DATABASES["default"])
|
||||
|
||||
connection = MagicMock()
|
||||
cursor = MagicMock()
|
||||
cursor_context = MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
cursor_context.__exit__.return_value = False
|
||||
connection.cursor.return_value = cursor_context
|
||||
connection.__enter__.return_value = connection
|
||||
connection.__exit__.return_value = False
|
||||
|
||||
context_manager = MagicMock()
|
||||
context_manager.__enter__.return_value = connection
|
||||
context_manager.__exit__.return_value = False
|
||||
mock_psycopg_connection.return_value = context_manager
|
||||
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
row = {
|
||||
"id": uuid.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"compliance_id": "test",
|
||||
"framework": "Test",
|
||||
"version": "1.0",
|
||||
"description": "desc",
|
||||
"region": "us-east-1",
|
||||
"requirement_id": "req-1",
|
||||
"requirement_status": "PASS",
|
||||
"passed_checks": 1,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 1,
|
||||
"scan_id": scan_id,
|
||||
}
|
||||
row[mismatched_field] = str(uuid.uuid4())
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
with pytest.raises(ComplianceRowScopeError):
|
||||
_copy_compliance_requirement_rows(tenant_id, scan_id, [row], 2000)
|
||||
|
||||
cursor.copy_expert.assert_not_called()
|
||||
connection.rollback.assert_called_once()
|
||||
connection.commit.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
@patch(
|
||||
"tasks.jobs.scan._copy_compliance_requirement_rows",
|
||||
side_effect=ComplianceRowScopeError("out of scope"),
|
||||
)
|
||||
def test_persist_compliance_requirement_rows_does_not_fall_back_on_scope_error(
|
||||
self, mock_copy, mock_rls_transaction, mock_model
|
||||
):
|
||||
"""A scope violation is a caller bug: the ORM fallback must not persist it."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
|
||||
with pytest.raises(ComplianceRowScopeError):
|
||||
_persist_compliance_requirement_rows(tenant_id, scan_id, lambda: [])
|
||||
|
||||
mock_copy.assert_called_once()
|
||||
mock_rls_transaction.assert_not_called()
|
||||
mock_model.objects.filter.assert_not_called()
|
||||
mock_model.objects.bulk_create.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.scan.psycopg_connection")
|
||||
def test_copy_compliance_requirement_rows_transaction_rollback_on_set_config_error(
|
||||
self, mock_psycopg_connection, settings
|
||||
@@ -2909,7 +3062,9 @@ class TestComplianceRequirementCopy:
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
with pytest.raises(Exception, match="SET prowler.tenant_id failed"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
_copy_compliance_requirement_rows(
|
||||
str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
|
||||
)
|
||||
|
||||
# Verify rollback was called
|
||||
connection.rollback.assert_called_once()
|
||||
@@ -2955,7 +3110,9 @@ class TestComplianceRequirementCopy:
|
||||
}
|
||||
|
||||
with patch.object(MainRouter, "admin_db", "admin"):
|
||||
_copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
|
||||
_copy_compliance_requirement_rows(
|
||||
str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
|
||||
)
|
||||
|
||||
# Verify commit was called and rollback was not
|
||||
connection.commit.assert_called_once()
|
||||
@@ -2966,9 +3123,10 @@ class TestComplianceRequirementCopy:
|
||||
@patch("tasks.jobs.scan._copy_compliance_requirement_rows")
|
||||
def test_persist_compliance_requirement_rows_success(self, mock_copy):
|
||||
"""Test successful COPY path without fallback to ORM."""
|
||||
mock_copy.return_value = None # Success, no exception
|
||||
mock_copy.return_value = 1 # Success, no exception
|
||||
|
||||
tenant_id = str(uuid.uuid4())
|
||||
scan_id = str(uuid.uuid4())
|
||||
rows = [
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
@@ -2984,16 +3142,21 @@ class TestComplianceRequirementCopy:
|
||||
"passed_checks": 1,
|
||||
"failed_checks": 0,
|
||||
"total_checks": 1,
|
||||
"scan_id": uuid.uuid4(),
|
||||
"scan_id": scan_id,
|
||||
}
|
||||
]
|
||||
|
||||
_persist_compliance_requirement_rows(tenant_id, rows)
|
||||
total = _persist_compliance_requirement_rows(tenant_id, scan_id, lambda: rows)
|
||||
|
||||
# Verify COPY was called
|
||||
mock_copy.assert_called_once_with(tenant_id, rows)
|
||||
assert total == 1
|
||||
mock_copy.assert_called_once()
|
||||
copy_args = mock_copy.call_args[0]
|
||||
assert copy_args[0] == tenant_id
|
||||
assert copy_args[1] == scan_id
|
||||
assert list(copy_args[2]) == rows
|
||||
|
||||
@patch("tasks.jobs.scan.logger")
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.filter")
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
@patch(
|
||||
@@ -3001,7 +3164,12 @@ class TestComplianceRequirementCopy:
|
||||
side_effect=Exception("COPY failed"),
|
||||
)
|
||||
def test_persist_compliance_requirement_rows_fallback_logging(
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_logger
|
||||
self,
|
||||
mock_copy,
|
||||
mock_rls_transaction,
|
||||
mock_bulk_create,
|
||||
mock_filter,
|
||||
mock_logger,
|
||||
):
|
||||
"""Test logger.exception is called when COPY fails and fallback occurs."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
@@ -3027,7 +3195,9 @@ class TestComplianceRequirementCopy:
|
||||
ctx.__exit__.return_value = False
|
||||
mock_rls_transaction.return_value = ctx
|
||||
|
||||
_persist_compliance_requirement_rows(tenant_id, [row])
|
||||
_persist_compliance_requirement_rows(
|
||||
tenant_id, str(row["scan_id"]), lambda: [row]
|
||||
)
|
||||
|
||||
# Verify logger.exception was called
|
||||
mock_logger.exception.assert_called_once()
|
||||
@@ -3036,6 +3206,7 @@ class TestComplianceRequirementCopy:
|
||||
assert "falling back to ORM" in args[0]
|
||||
assert kwargs.get("exc_info") is not None
|
||||
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.filter")
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
@patch(
|
||||
@@ -3043,7 +3214,7 @@ class TestComplianceRequirementCopy:
|
||||
side_effect=Exception("copy failed"),
|
||||
)
|
||||
def test_persist_compliance_requirement_rows_fallback_multiple_rows(
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_filter
|
||||
):
|
||||
"""Test ORM fallback with multiple rows."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
@@ -3090,10 +3261,14 @@ class TestComplianceRequirementCopy:
|
||||
ctx.__exit__.return_value = False
|
||||
mock_rls_transaction.return_value = ctx
|
||||
|
||||
_persist_compliance_requirement_rows(tenant_id, rows)
|
||||
total = _persist_compliance_requirement_rows(
|
||||
tenant_id, str(scan_id), lambda: rows
|
||||
)
|
||||
|
||||
mock_copy.assert_called_once_with(tenant_id, rows)
|
||||
assert total == 2
|
||||
mock_copy.assert_called_once()
|
||||
mock_rls_transaction.assert_called_once_with(tenant_id)
|
||||
mock_filter.assert_called_once_with(scan_id=str(scan_id))
|
||||
mock_bulk_create.assert_called_once()
|
||||
|
||||
args, kwargs = mock_bulk_create.call_args
|
||||
@@ -3117,6 +3292,7 @@ class TestComplianceRequirementCopy:
|
||||
assert objects[1].passed_checks == 2
|
||||
assert objects[1].failed_checks == 3
|
||||
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.filter")
|
||||
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
|
||||
@patch("tasks.jobs.scan.rls_transaction")
|
||||
@patch(
|
||||
@@ -3124,7 +3300,7 @@ class TestComplianceRequirementCopy:
|
||||
side_effect=Exception("copy failed"),
|
||||
)
|
||||
def test_persist_compliance_requirement_rows_fallback_all_fields(
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create
|
||||
self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_filter
|
||||
):
|
||||
"""Test ORM fallback correctly maps all fields from row dict to model."""
|
||||
tenant_id = str(uuid.uuid4())
|
||||
@@ -3154,7 +3330,7 @@ class TestComplianceRequirementCopy:
|
||||
ctx.__exit__.return_value = False
|
||||
mock_rls_transaction.return_value = ctx
|
||||
|
||||
_persist_compliance_requirement_rows(tenant_id, [row])
|
||||
_persist_compliance_requirement_rows(tenant_id, str(scan_id), lambda: [row])
|
||||
|
||||
args, kwargs = mock_bulk_create.call_args
|
||||
objects = args[0]
|
||||
|
||||
@@ -4673,8 +4673,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.32.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#5dac8a0a53272e4db68c476fb969dc03e88beb68" }
|
||||
version = "5.35.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f5ea116763aeffede9f399c8934fc280eaccd315" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4762,7 +4762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.36.0"
|
||||
version = "1.37.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "Prowler API key",
|
||||
"description": "API key token used to authenticate with Prowler Cloud / Prowler App via the Prowler MCP server. Create one at https://cloud.prowler.com.",
|
||||
"description": "API key token used to authenticate with Prowler (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server) via the Prowler MCP server. Create one at https://cloud.prowler.com.",
|
||||
"sensitive": true,
|
||||
"required": true
|
||||
}
|
||||
|
||||
@@ -38,12 +38,12 @@ If the framework is not supported, tell the user, suggest they request it or con
|
||||
|
||||
### 1.1 Connect to Prowler Cloud
|
||||
|
||||
Verify the Prowler MCP connection by calling `prowler_app_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account.
|
||||
Verify the Prowler MCP connection by calling `prowler_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account.
|
||||
For getting accurate information about configurations use `prowler_docs_search` to pull relevant instructions from the Prowler documentation.
|
||||
|
||||
### 1.2 Verify the provider is configured (or configure it)
|
||||
|
||||
Call `prowler_app_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found:
|
||||
Call `prowler_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found:
|
||||
|
||||
- **Provider not present.** Guide the user through adding and configuring it. Retrieve the relevant connection, credential, and permission instructions with `prowler_docs_search`.
|
||||
- **Provider present but misconfigured** (missing credentials, insufficient permissions, etc.). Walk the user through fixing the configuration, pulling the relevant guidance with `prowler_docs_search`.
|
||||
@@ -57,15 +57,15 @@ Call `prowler_app_search_providers` to check whether the target provider (AWS ac
|
||||
|
||||
The flow needs at least one completed scan with a compliance report available.
|
||||
|
||||
Look for a completed scan first: call `prowler_app_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_app_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section.
|
||||
Look for a completed scan first: call `prowler_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section.
|
||||
|
||||
If no completed scan has a report, call `prowler_app_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress.
|
||||
If no completed scan has a report, call `prowler_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress.
|
||||
|
||||
> **Checkpoint — Scan-in-progress decision** *(conditional: an in-progress scan was detected)*
|
||||
>
|
||||
> Tell the user a scan is already running and ask whether to wait for it to complete or start a fresh one. Wait for the answer.
|
||||
|
||||
If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_app_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress.
|
||||
If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress.
|
||||
|
||||
When a scan is in progress (either pre-existing and elected to wait, or just triggered), stop the flow and ask the user to return when it's completed — restart this section to re-check the results.
|
||||
|
||||
@@ -85,7 +85,7 @@ Status taxonomy for failed requirements and their findings:
|
||||
|
||||
### Report template
|
||||
|
||||
A fresh report is rendered like this (substituting values from the `prowler_app_get_compliance_framework_state_details` Prowler MCP tool response):
|
||||
A fresh report is rendered like this (substituting values from the `prowler_get_compliance_framework_state_details` Prowler MCP tool response):
|
||||
|
||||
````markdown
|
||||
# Compliance report: <compliance_id>
|
||||
@@ -120,7 +120,7 @@ A fresh report is rendered like this (substituting values from the `prowler_app_
|
||||
|
||||
Resolve the report path for the current `compliance_id` and provider account.
|
||||
|
||||
If the file does not exist, call `prowler_app_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log.
|
||||
If the file does not exist, call `prowler_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log.
|
||||
|
||||
If the file exists, read it and compare its `Scan ID` to the target scan from section 1.3. When the scan matches, reuse the file and summarize remaining `[FAIL]` and `[IN PROGRESS]` items in chat.
|
||||
|
||||
@@ -128,7 +128,7 @@ If the file exists, read it and compare its `Scan ID` to the target scan from se
|
||||
>
|
||||
> Tell the user the report on disk was generated from a different scan and ask whether to refresh it from the new scan. Wait for the answer.
|
||||
|
||||
On confirmation, regenerate the failed-requirements section from the new `prowler_app_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change.
|
||||
On confirmation, regenerate the failed-requirements section from the new `prowler_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change.
|
||||
|
||||
Once the file is current, surface the top failing requirements in chat: sort by finding count descending, show the top 5 with their codes and counts, and point to the file path for the full list.
|
||||
|
||||
@@ -174,7 +174,7 @@ Once approved, the loop proceeds through the batch without further prompts unles
|
||||
|
||||
Pick the first `[FAIL]` requirement at the top of the failed-requirements section. Move its status and every finding under it to `[IN PROGRESS]`, and add a `**Fix plan**:` sub-bullet describing what will be done.
|
||||
|
||||
Call `prowler_app_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding.
|
||||
Call `prowler_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding.
|
||||
|
||||
If a finding does not apply to the target resource (Organization-only check on a User account, paid-tier feature, missing resource type, etc.), set the requirement status to `[SKIPPED]` with the reason, log it in the activity log, and move on without attempting the fix — even if it was missed during §3.2.
|
||||
|
||||
@@ -194,6 +194,6 @@ Move to the next `[FAIL]` requirement and repeat from section 3.3.
|
||||
|
||||
> **Checkpoint — Rescan trigger** *(conditional: no `[FAIL]` requirements remain; all are `[FIXED-UNVERIFIED]` or `[SKIPPED]`)*
|
||||
>
|
||||
> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_app_trigger_scan` to verify the fixes end-to-end. Wait for the answer.
|
||||
> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_trigger_scan` to verify the fixes end-to-end. Wait for the answer.
|
||||
|
||||
On confirmation, trigger the rescan. When it completes, restart section 2.1 with the carry-forward path — requirements no longer in the new FAIL list move to `[PASS]`, anything still failing reverts to `[FAIL]` with the previous fix attempt visible in the activity log.
|
||||
|
||||
@@ -132,7 +132,7 @@ The MCP client manages connections to the Prowler MCP Server using a singleton p
|
||||
|
||||
- **Connection Management**: Retry logic with configurable attempts and delays
|
||||
- **Tool Discovery**: Fetches available tools from MCP server on initialization
|
||||
- **Authentication Injection**: Automatically adds JWT tokens to `prowler_app_*` tool calls
|
||||
- **Authentication Injection**: Automatically adds JWT tokens to `prowler_*` tool calls
|
||||
- **Reconnection**: Supports forced reconnection after server restarts
|
||||
|
||||
Key constants:
|
||||
@@ -141,10 +141,14 @@ Key constants:
|
||||
- `RECONNECT_INTERVAL_MS`: 5 minutes before retry after failure
|
||||
|
||||
```typescript
|
||||
// Authentication injection for prowler_app tools
|
||||
// Authentication injection for core prowler_ tools (Hub/Docs excluded)
|
||||
private handleBeforeToolCall = ({ name, args }) => {
|
||||
// Only inject auth for prowler_app_* tools (user-specific data)
|
||||
if (!name.startsWith("prowler_app_")) {
|
||||
// Only inject auth for prowler_* tools (user-specific data).
|
||||
// The legacy prowler_app_ prefix is also accepted for a resilient rollout.
|
||||
if (
|
||||
!name.startsWith("prowler_") &&
|
||||
!name.startsWith("prowler_app_")
|
||||
) {
|
||||
return { args };
|
||||
}
|
||||
|
||||
@@ -307,7 +311,7 @@ MCP tools are organized into three namespaces based on authentication requiremen
|
||||
|
||||
| Namespace | Auth Required | Description |
|
||||
|-----------|---------------|-------------|
|
||||
| `prowler_app_*` | Yes (JWT) | Prowler Cloud and Prowler Local Server tools for findings, providers, scans, resources |
|
||||
| `prowler_*` | Yes (JWT) | Prowler Cloud, Prowler Private Cloud, and Prowler Local Server tools for findings, providers, scans, resources |
|
||||
| `prowler_hub_*` | No | Security checks catalog, compliance frameworks |
|
||||
| `prowler_docs_*` | No | Documentation search and retrieval |
|
||||
|
||||
@@ -315,7 +319,7 @@ MCP tools are organized into three namespaces based on authentication requiremen
|
||||
|
||||
1. User authenticates with Prowler Local Server, receiving a JWT token
|
||||
2. Token is stored in session and propagated via `authContextStorage`
|
||||
3. MCP client injects `Authorization: Bearer <token>` header for `prowler_app_*` calls
|
||||
3. MCP client injects `Authorization: Bearer <token>` header for `prowler_*` calls
|
||||
4. MCP Server validates token and applies RLS filtering
|
||||
|
||||
### Tool Execution Pattern
|
||||
@@ -323,7 +327,7 @@ MCP tools are organized into three namespaces based on authentication requiremen
|
||||
The agent uses meta-tools rather than direct tool registration:
|
||||
|
||||
```
|
||||
Agent needs data → describe_tool("prowler_app_search_findings")
|
||||
Agent needs data → describe_tool("prowler_search_findings")
|
||||
→ Returns parameter schema → execute_tool with parameters
|
||||
→ MCP client adds auth header → MCP Server executes
|
||||
→ Results returned to agent → Agent continues reasoning
|
||||
|
||||
@@ -18,11 +18,15 @@ The Prowler MCP Server brings the entire Prowler ecosystem to AI assistants thro
|
||||
|
||||
The server follows a modular architecture with three independent sub-servers:
|
||||
|
||||
| Sub-Server | Auth Required | Description |
|
||||
|------------|---------------|-------------|
|
||||
| `prowler_app` | Yes | Full access to Prowler Cloud and Prowler Local Server features |
|
||||
| Prowler Hub | No | Security checks catalog with **over 2,000 checks**, fixers, and **70+ compliance frameworks** |
|
||||
| Prowler Documentation | No | Full-text search and retrieval of official documentation |
|
||||
| Sub-Server | Tool Prefix | Auth Required | Description |
|
||||
|------------|-------------|---------------|-------------|
|
||||
| Prowler | `prowler_` | Yes | Full access to Prowler Cloud, Prowler Private Cloud, and Prowler Local Server features |
|
||||
| Prowler Hub | `prowler_hub_` | No | Security checks catalog with **over 2,000 checks**, fixers, and **70+ compliance frameworks** |
|
||||
| Prowler Documentation | `prowler_docs_` | No | Full-text search and retrieval of official documentation |
|
||||
|
||||
<Note>
|
||||
The core Prowler sub-server is served under the `prowler_` tool prefix, while its source lives in the `prowler_app/` module for historical reasons. Tool names use the prefix; import paths use the module.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
For a complete list of tools and their descriptions, see the [Tools Reference](/getting-started/basic-usage/prowler-mcp-tools).
|
||||
@@ -413,7 +417,7 @@ uv run prowler-mcp
|
||||
uv run prowler-mcp --transport http --host 0.0.0.0 --port 8000
|
||||
|
||||
# Run with environment variables
|
||||
PROWLER_APP_API_KEY="pk_xxx" uv run prowler-mcp
|
||||
PROWLER_API_KEY="pk_xxx" uv run prowler-mcp
|
||||
```
|
||||
|
||||
For complete installation and deployment options, see:
|
||||
|
||||
@@ -72,9 +72,9 @@
|
||||
"group": "Prowler MCP",
|
||||
"pages": [
|
||||
"getting-started/products/prowler-mcp",
|
||||
"getting-started/installation/prowler-mcp",
|
||||
"getting-started/basic-usage/prowler-mcp",
|
||||
"getting-started/basic-usage/prowler-mcp-tools"
|
||||
"getting-started/basic-usage/prowler-mcp-tools",
|
||||
"getting-started/installation/prowler-mcp"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool
|
||||
|----------|------------|------------------------|
|
||||
| Prowler Hub | 10 tools | No |
|
||||
| Prowler Documentation | 2 tools | No |
|
||||
| Prowler Cloud & Prowler Local Server | 32 tools | Yes |
|
||||
| Prowler Cloud, Private Cloud & Local Server | 32 tools | Yes |
|
||||
|
||||
## Tool Naming Convention
|
||||
|
||||
@@ -18,11 +18,11 @@ All tools follow a consistent naming pattern with prefixes:
|
||||
|
||||
- `prowler_hub_*` - Prowler Hub catalog and compliance tools
|
||||
- `prowler_docs_*` - Prowler documentation search and retrieval
|
||||
- `prowler_app_*` - Prowler Cloud and App (Self-Managed) management tools
|
||||
- `prowler_*` - Prowler Cloud, Prowler Private Cloud & Prowler Local Server management tools
|
||||
|
||||
## Prowler Cloud and Prowler Local Server Tools
|
||||
## Prowler Tools
|
||||
|
||||
Manage Prowler Cloud or Prowler Local Server features. **Requires authentication.**
|
||||
Manage your Prowler deployment — Prowler Cloud, Prowler Private Cloud, or Prowler Local Server. **Requires authentication.**
|
||||
|
||||
<Note>
|
||||
These tools require a valid API key. See the [Configuration Guide](/getting-started/basic-usage/prowler-mcp) for authentication setup.
|
||||
@@ -32,44 +32,44 @@ These tools require a valid API key. See the [Configuration Guide](/getting-star
|
||||
|
||||
Tools for searching, viewing, and analyzing security findings across all cloud providers.
|
||||
|
||||
- **`prowler_app_search_security_findings`** - Search and filter security findings with advanced filtering options (severity, status, provider, region, service, check ID, date range, muted status)
|
||||
- **`prowler_app_get_finding_details`** - Get comprehensive details about a specific finding including remediation guidance, check metadata, and resource relationships
|
||||
- **`prowler_app_get_findings_overview`** - Get aggregate statistics and trends about security findings as a markdown report
|
||||
- **`prowler_search_security_findings`** - Search and filter security findings with advanced filtering options (severity, status, provider, region, service, check ID, date range, muted status)
|
||||
- **`prowler_get_finding_details`** - Get comprehensive details about a specific finding including remediation guidance, check metadata, and resource relationships
|
||||
- **`prowler_get_findings_overview`** - Get aggregate statistics and trends about security findings as a markdown report
|
||||
|
||||
### Finding Groups Management
|
||||
|
||||
Tools for listing finding groups aggregated by check ID, viewing complete group counters, and drilling down into affected resources.
|
||||
|
||||
- **`prowler_app_list_finding_groups`** - List latest or historical finding groups with filters for provider, region, service, resource, category, check, severity, status, muted state, delta, date range, and sorting
|
||||
- **`prowler_app_get_finding_group_details`** - Get complete details for a specific finding group including counters, description, timestamps, and impacted providers
|
||||
- **`prowler_app_list_finding_group_resources`** - List actionable unmuted resources affected by a finding group by default, including nested resource and provider data plus the `finding_id` for remediation details. Set `include_muted` to include suppressed resources
|
||||
- **`prowler_list_finding_groups`** - List latest or historical finding groups with filters for provider, region, service, resource, category, check, severity, status, muted state, delta, date range, and sorting
|
||||
- **`prowler_get_finding_group_details`** - Get complete details for a specific finding group including counters, description, timestamps, and impacted providers
|
||||
- **`prowler_list_finding_group_resources`** - List actionable unmuted resources affected by a finding group by default, including nested resource and provider data plus the `finding_id` for remediation details. Set `include_muted` to include suppressed resources
|
||||
|
||||
### Provider Management
|
||||
|
||||
Tools for managing cloud provider connections in Prowler.
|
||||
|
||||
- **`prowler_app_search_providers`** - Search and view configured providers with their connection status
|
||||
- **`prowler_app_connect_provider`** - Register and connect a provider with credentials for security scanning
|
||||
- **`prowler_app_delete_provider`** - Permanently remove a provider from Prowler
|
||||
- **`prowler_search_providers`** - Search and view configured providers with their connection status
|
||||
- **`prowler_connect_provider`** - Register and connect a provider with credentials for security scanning
|
||||
- **`prowler_delete_provider`** - Permanently remove a provider from Prowler
|
||||
|
||||
### Scan Management
|
||||
|
||||
Tools for managing and monitoring security scans.
|
||||
|
||||
- **`prowler_app_list_scans`** - List and filter security scans across all providers
|
||||
- **`prowler_app_get_scan`** - Get comprehensive details about a specific scan (progress, duration, resource counts)
|
||||
- **`prowler_app_trigger_scan`** - Trigger a manual security scan for a provider
|
||||
- **`prowler_app_schedule_daily_scan`** - Schedule automated daily scans for continuous monitoring
|
||||
- **`prowler_app_update_scan`** - Update scan name for better organization
|
||||
- **`prowler_list_scans`** - List and filter security scans across all providers
|
||||
- **`prowler_get_scan`** - Get comprehensive details about a specific scan (progress, duration, resource counts)
|
||||
- **`prowler_trigger_scan`** - Trigger a manual security scan for a provider
|
||||
- **`prowler_schedule_daily_scan`** - Schedule automated daily scans for continuous monitoring
|
||||
- **`prowler_update_scan`** - Update scan name for better organization
|
||||
|
||||
### Resources Management
|
||||
|
||||
Tools for searching, viewing, and analyzing cloud resources discovered by Prowler.
|
||||
|
||||
- **`prowler_app_list_resources`** - List and filter cloud resources with advanced filtering options (provider, region, service, resource type, tags)
|
||||
- **`prowler_app_get_resource`** - Get comprehensive details about a specific resource including configuration, metadata, and finding relationships
|
||||
- **`prowler_app_get_resource_events`** - Get the timeline of cloud API actions performed on a resource (AWS CloudTrail). Shows who did what and when, with full request/response payloads
|
||||
- **`prowler_app_get_resources_overview`** - Get aggregate statistics about cloud resources as a markdown report
|
||||
- **`prowler_list_resources`** - List and filter cloud resources with advanced filtering options (provider, region, service, resource type, tags)
|
||||
- **`prowler_get_resource`** - Get comprehensive details about a specific resource including configuration, metadata, and finding relationships
|
||||
- **`prowler_get_resource_events`** - Get the timeline of cloud API actions performed on a resource (AWS CloudTrail). Shows who did what and when, with full request/response payloads
|
||||
- **`prowler_get_resources_overview`** - Get aggregate statistics about cloud resources as a markdown report
|
||||
|
||||
### Muting Management
|
||||
|
||||
@@ -77,33 +77,33 @@ Tools for managing finding muting, including pattern-based bulk muting (mutelist
|
||||
|
||||
#### Mutelist (Pattern-Based Muting)
|
||||
|
||||
- **`prowler_app_get_mutelist`** - Retrieve the current mutelist configuration for the tenant
|
||||
- **`prowler_app_set_mutelist`** - Create or update the mutelist configuration for pattern-based bulk muting
|
||||
- **`prowler_app_delete_mutelist`** - Remove the mutelist configuration from the tenant
|
||||
- **`prowler_get_mutelist`** - Retrieve the current mutelist configuration for the tenant
|
||||
- **`prowler_set_mutelist`** - Create or update the mutelist configuration for pattern-based bulk muting
|
||||
- **`prowler_delete_mutelist`** - Remove the mutelist configuration from the tenant
|
||||
|
||||
#### Mute Rules (Finding-Specific Muting)
|
||||
|
||||
- **`prowler_app_list_mute_rules`** - Search and filter mute rules with pagination support
|
||||
- **`prowler_app_get_mute_rule`** - Retrieve comprehensive details about a specific mute rule
|
||||
- **`prowler_app_create_mute_rule`** - Create a new mute rule to mute specific findings with documentation and audit trail
|
||||
- **`prowler_app_update_mute_rule`** - Update a mute rule's name, reason, or enabled status
|
||||
- **`prowler_app_delete_mute_rule`** - Delete a mute rule from the system
|
||||
- **`prowler_list_mute_rules`** - Search and filter mute rules with pagination support
|
||||
- **`prowler_get_mute_rule`** - Retrieve comprehensive details about a specific mute rule
|
||||
- **`prowler_create_mute_rule`** - Create a new mute rule to mute specific findings with documentation and audit trail
|
||||
- **`prowler_update_mute_rule`** - Update a mute rule's name, reason, or enabled status
|
||||
- **`prowler_delete_mute_rule`** - Delete a mute rule from the system
|
||||
|
||||
### Attack Paths Analysis
|
||||
|
||||
Tools for analyzing privilege escalation chains and security misconfigurations using graph-based analysis. Attack Paths maps relationships between cloud resources, permissions, and security findings to detect how privileges can be escalated and how misconfigurations can be exploited.
|
||||
|
||||
- **`prowler_app_list_attack_paths_scans`** - List Attack Paths scans with filtering by provider, provider type, and scan state (available, scheduled, executing, completed, failed, cancelled)
|
||||
- **`prowler_app_list_attack_paths_queries`** - Discover available Attack Paths queries for a completed scan, including query names, descriptions, and required parameters
|
||||
- **`prowler_app_run_attack_paths_query`** - Execute an Attack Paths query against a completed scan and retrieve graph results with nodes (cloud resources, findings, virtual nodes) and relationships (access paths, role assumptions, security group memberships)
|
||||
- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema (node labels, relationships, properties) for writing accurate custom openCypher queries
|
||||
- **`prowler_list_attack_paths_scans`** - List Attack Paths scans with filtering by provider, provider type, and scan state (available, scheduled, executing, completed, failed, cancelled)
|
||||
- **`prowler_list_attack_paths_queries`** - Discover available Attack Paths queries for a completed scan, including query names, descriptions, and required parameters
|
||||
- **`prowler_run_attack_paths_query`** - Execute an Attack Paths query against a completed scan and retrieve graph results with nodes (cloud resources, findings, virtual nodes) and relationships (access paths, role assumptions, security group memberships)
|
||||
- **`prowler_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema (node labels, relationships, properties) for writing accurate custom openCypher queries
|
||||
|
||||
### Compliance Management
|
||||
|
||||
Tools for viewing compliance status and framework details across all cloud providers.
|
||||
|
||||
- **`prowler_app_get_compliance_overview`** - Get high-level compliance status across all frameworks for a specific scan or provider, including pass/fail statistics per framework
|
||||
- **`prowler_app_get_compliance_framework_state_details`** - Get detailed requirement-level breakdown for a specific compliance framework, including failed requirements and associated finding IDs
|
||||
- **`prowler_get_compliance_overview`** - Get high-level compliance status across all frameworks for a specific scan or provider, including pass/fail statistics per framework
|
||||
- **`prowler_get_compliance_framework_state_details`** - Get detailed requirement-level breakdown for a specific compliance framework, including failed requirements and associated finding IDs
|
||||
|
||||
## Prowler Hub Tools
|
||||
|
||||
@@ -145,7 +145,7 @@ Search and access official Prowler documentation. **No authentication required.*
|
||||
- Use natural language to interact with the tools through your AI assistant
|
||||
- Tools can be combined for complex workflows
|
||||
- Filter options are available on most list tools
|
||||
- Authentication is only required for Prowler Cloud and Prowler Local Server tools
|
||||
- Authentication is only required for Prowler tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server)
|
||||
|
||||
## Additional Resources
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ Configure your MCP client to connect to Prowler MCP Server.
|
||||
## Step 1: Get Your API Key
|
||||
|
||||
<Note>
|
||||
**Authentication is optional**: Prowler Hub and Prowler Documentation features work without authentication. An API key is only required for Prowler Cloud and Prowler Local Server features.
|
||||
**Authentication is optional**: Prowler Hub and Prowler Documentation features work without authentication. An API key is only required for Prowler tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server).
|
||||
</Note>
|
||||
|
||||
To use Prowler Cloud or Prowler Local Server features. To get the API key, please refer to the [API Keys](/user-guide/tutorials/prowler-app-api-keys) guide.
|
||||
An API key authenticates the Prowler tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server). To get the API key, please refer to the [API Keys](/user-guide/tutorials/prowler-app-api-keys) guide.
|
||||
|
||||
<Warning>
|
||||
Keep the API key secure. Never share it publicly or commit it to version control.
|
||||
@@ -18,12 +18,14 @@ Keep the API key secure. Never share it publicly or commit it to version control
|
||||
|
||||
## Step 2: Configure Your MCP Host/Client
|
||||
|
||||
Choose the configuration based on your deployment:
|
||||
Most users should use the **Cloud MCP Server** — it needs no installation and is maintained by Prowler. The [Local MCP Server](#local-mcp-server-configuration) configuration is provided afterwards for users who run the server themselves.
|
||||
|
||||
- **HTTP Mode**: Prowler Cloud MCP Server or self-hosted Prowler MCP Server.
|
||||
- **STDIO Mode**: Local installation only (runs as subprocess of your MCP client).
|
||||
- **Cloud MCP Server (HTTP)**: the managed server at `https://mcp.prowler.com/mcp` (or your own self-hosted HTTP server).
|
||||
- **Local MCP Server (STDIO)**: local installation only (runs as a subprocess of your MCP client).
|
||||
|
||||
### HTTP Mode
|
||||
## Cloud MCP Server Configuration (Recommended)
|
||||
|
||||
Connect to the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` over HTTP. This is the recommended path — no installation, always up to date. The same configuration works for a self-hosted HTTP server: just swap the URL.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Generic Native HTTP Support">
|
||||
@@ -61,10 +63,10 @@ Choose the configuration based on your deployment:
|
||||
"args": [
|
||||
"https://mcp.prowler.com/mcp", // or your self-hosted Prowler MCP Server URL
|
||||
"--header",
|
||||
"Authorization: Bearer ${PROWLER_APP_API_KEY}"
|
||||
"Authorization: Bearer ${PROWLER_API_KEY}"
|
||||
],
|
||||
"env": {
|
||||
"PROWLER_APP_API_KEY": "<your-api-key-here>"
|
||||
"PROWLER_API_KEY": "<your-api-key-here>"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,10 +98,10 @@ Choose the configuration based on your deployment:
|
||||
"args": [
|
||||
"https://mcp.prowler.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer ${PROWLER_APP_API_KEY}"
|
||||
"Authorization: Bearer ${PROWLER_API_KEY}"
|
||||
],
|
||||
"env": {
|
||||
"PROWLER_APP_API_KEY": "<your-api-key-here>"
|
||||
"PROWLER_API_KEY": "<your-api-key-here>"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,8 +112,8 @@ Choose the configuration based on your deployment:
|
||||
<Tab title="Claude Code">
|
||||
Run the following command:
|
||||
```bash
|
||||
export PROWLER_APP_API_KEY="<your-api-key-here>"
|
||||
claude mcp add --transport http prowler https://mcp.prowler.com/mcp --header "Authorization: Bearer $PROWLER_APP_API_KEY" --scope user
|
||||
export PROWLER_API_KEY="<your-api-key-here>"
|
||||
claude mcp add --transport http prowler https://mcp.prowler.com/mcp --header "Authorization: Bearer $PROWLER_API_KEY" --scope user
|
||||
```
|
||||
</Tab>
|
||||
|
||||
@@ -137,9 +139,9 @@ Choose the configuration based on your deployment:
|
||||
|
||||
</Tabs>
|
||||
|
||||
### STDIO Mode
|
||||
## Local MCP Server Configuration
|
||||
|
||||
STDIO mode is only available when running the MCP server locally.
|
||||
STDIO mode is only available when running the **Local MCP Server** on your own machine. See the [Installation guide](/getting-started/installation/prowler-mcp) to set it up first.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Generic uvx installation">
|
||||
@@ -152,7 +154,7 @@ STDIO mode is only available when running the MCP server locally.
|
||||
"command": "uvx",
|
||||
"args": ["/absolute/path/to/prowler/mcp_server/"],
|
||||
"env": {
|
||||
"PROWLER_APP_API_KEY": "<your-api-key-here>",
|
||||
"PROWLER_API_KEY": "<your-api-key-here>",
|
||||
"API_BASE_URL": "https://api.prowler.com/api/v1"
|
||||
}
|
||||
}
|
||||
@@ -179,7 +181,7 @@ STDIO mode is only available when running the MCP server locally.
|
||||
"--rm",
|
||||
"-i",
|
||||
"--env",
|
||||
"PROWLER_APP_API_KEY=<your-api-key-here>",
|
||||
"PROWLER_API_KEY=<your-api-key-here>",
|
||||
"--env",
|
||||
"API_BASE_URL=https://api.prowler.com/api/v1",
|
||||
"prowlercloud/prowler-mcp"
|
||||
@@ -205,7 +207,7 @@ Restart your MCP client and start asking questions:
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
Prowler MCP Server supports two authentication methods to connect to Prowler Cloud or Prowler Local Server:
|
||||
Prowler MCP Server supports two authentication methods to connect to Prowler (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server):
|
||||
|
||||
### API Key (Recommended)
|
||||
|
||||
|
||||
@@ -128,8 +128,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.34.0"
|
||||
PROWLER_API_VERSION="5.34.0"
|
||||
PROWLER_UI_VERSION="5.35.0"
|
||||
PROWLER_API_VERSION="5.35.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -5,12 +5,12 @@ title: "Installation"
|
||||
There are **two ways** to use Prowler MCP Server:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Option 1: Managed by Prowler" icon="cloud" color="#10B981">
|
||||
<Card title="Cloud MCP Server (Recommended)" icon="cloud" color="#10B981">
|
||||
**No installation required** - Just configuration
|
||||
|
||||
Use `https://mcp.prowler.com/mcp`
|
||||
</Card>
|
||||
<Card title="Option 2: Run Locally" icon="server" color="#6366F1">
|
||||
<Card title="Local MCP Server" icon="server" color="#6366F1">
|
||||
**Local installation** - Full control
|
||||
|
||||
Install via Docker, PyPI, or source code
|
||||
@@ -18,8 +18,8 @@ There are **two ways** to use Prowler MCP Server:
|
||||
</CardGroup>
|
||||
|
||||
|
||||
For "Option 1: Managed by Prowler", go directly to the [Configuration Guide](/getting-started/basic-usage/prowler-mcp#hosted-server-configuration-recommended) to set up your Claude Desktop, Cursor, or other MCP client.
|
||||
**This guide is focused on local installation, "Option 2: Run Locally"**.
|
||||
For the Cloud MCP Server, go directly to the [Configuration Guide](/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended) to set up your Claude Desktop, Cursor, or other MCP client.
|
||||
**This guide is focused on local installation, the Local MCP Server**.
|
||||
|
||||
## Installation Methods
|
||||
|
||||
@@ -51,7 +51,7 @@ Choose one of the following installation methods:
|
||||
|
||||
```bash
|
||||
docker run --rm -i \
|
||||
-e PROWLER_APP_API_KEY="pk_your_api_key" \
|
||||
-e PROWLER_API_KEY="pk_your_api_key" \
|
||||
-e API_BASE_URL="https://api.prowler.com/api/v1" \
|
||||
prowlercloud/prowler-mcp
|
||||
```
|
||||
@@ -143,7 +143,7 @@ Choose one of the following installation methods:
|
||||
|
||||
## Updating Prowler MCP Server
|
||||
|
||||
When running Prowler MCP Server locally ("Option 2: Run Locally"), upgrade to the latest version using the same method chosen for installation. The hosted server (`https://mcp.prowler.com/mcp`) is always kept up to date by Prowler and requires no action.
|
||||
When running the Local MCP Server, upgrade to the latest version using the same method chosen for installation. The Cloud MCP Server (`https://mcp.prowler.com/mcp`) is always kept up to date by Prowler and requires no action.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Docker">
|
||||
@@ -219,19 +219,19 @@ Configure the server using environment variables:
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
|----------|-------------|----------|---------|
|
||||
| `PROWLER_APP_API_KEY` | Prowler API key | Only for STDIO mode | - |
|
||||
| `PROWLER_API_KEY` | Prowler API key | Only for STDIO mode | - |
|
||||
| `API_BASE_URL` | Custom Prowler API endpoint | No | `https://api.prowler.com/api/v1` |
|
||||
| `PROWLER_MCP_TRANSPORT_MODE` | Default transport mode (overwritten by `--transport` argument) | No | `stdio` |
|
||||
|
||||
<CodeGroup>
|
||||
```bash macOS/Linux
|
||||
export PROWLER_APP_API_KEY="pk_your_api_key_here"
|
||||
export PROWLER_API_KEY="pk_your_api_key_here"
|
||||
export API_BASE_URL="https://api.prowler.com/api/v1"
|
||||
export PROWLER_MCP_TRANSPORT_MODE="http"
|
||||
```
|
||||
|
||||
```bash Windows PowerShell
|
||||
$env:PROWLER_APP_API_KEY="pk_your_api_key_here"
|
||||
$env:PROWLER_API_KEY="pk_your_api_key_here"
|
||||
$env:API_BASE_URL="https://api.prowler.com/api/v1"
|
||||
$env:PROWLER_MCP_TRANSPORT_MODE="http"
|
||||
```
|
||||
@@ -246,7 +246,7 @@ Never commit your API key to version control. Use environment variables or secur
|
||||
For convenience, create a `.env` file in the `mcp_server` directory:
|
||||
|
||||
```bash .env
|
||||
PROWLER_APP_API_KEY=pk_your_api_key_here
|
||||
PROWLER_API_KEY=pk_your_api_key_here
|
||||
API_BASE_URL=https://api.prowler.com/api/v1
|
||||
PROWLER_MCP_TRANSPORT_MODE=stdio
|
||||
```
|
||||
|
||||
@@ -24,6 +24,9 @@ The Agentic Cloud Defender does more than answer questions, it helps teams **fin
|
||||
<Card title="Normal and Agentic Views" icon="table-columns">
|
||||
Switch between the standard interface and a chat-first agentic view.
|
||||
</Card>
|
||||
<Card title="Side Panel on Every Page" icon="comment-dots">
|
||||
Open Lighthouse AI as a side panel from any page to get help in context.
|
||||
</Card>
|
||||
<Card title="Provider Connection Checks" icon="plug-circle-check">
|
||||
Credentials are validated automatically when a provider is configured.
|
||||
</Card>
|
||||
@@ -37,6 +40,20 @@ Promoting the chat to a top-level view gives Lighthouse AI the room it needs for
|
||||
|
||||
<img src="/images/prowler-app/lighthouse/prowler-cloud/main-chat-page.png" alt="Lighthouse AI chat view in Prowler Cloud" />
|
||||
|
||||
### Side Panel
|
||||
|
||||
You do not have to switch to the full chat view to reach Lighthouse AI. A side panel is available on every page of Prowler Cloud. While collapsed it stays out of the way; open it from any dashboard, findings list, or configuration screen to ask questions without leaving what you are working on. Open it using the Lighthouse AI button, circled in red in the image below.
|
||||
|
||||
<img src="/images/prowler-app/lighthouse/prowler-cloud/side-panel-closed.png" alt="Collapsed Lighthouse AI side panel on a Prowler Cloud page, with the button to open it circled in red" />
|
||||
|
||||
Once open, the panel slides in alongside your current page and shares the same agent, tools, and persistent chat sessions as the full Chat View, so a conversation started in the panel can be reopened and continued later from either place.
|
||||
|
||||
<img src="/images/prowler-app/lighthouse/prowler-cloud/side-panel-open.png" alt="Lighthouse AI side panel open alongside a Prowler Cloud page" />
|
||||
|
||||
- **Available everywhere:** Summon the assistant from any page while you keep working in the normal view.
|
||||
- **Context-aware help:** Ask about the findings, resources, or compliance data you are currently looking at.
|
||||
- **Continuous sessions:** Conversations opened in the side panel are saved alongside the rest of your chat history.
|
||||
|
||||
### Tool Usage
|
||||
|
||||
Lighthouse AI on Prowler Cloud renders the agent's work as it happens, so responses are easier to follow and to trust. Tool calls and reasoning steps appear in the order they occur within the conversation.
|
||||
@@ -63,6 +80,53 @@ At the top of the configuration page, the optional **Business Context** field le
|
||||
|
||||
Lighthouse AI on Prowler Cloud supports OpenAI, Amazon Bedrock, and OpenAI-compatible providers, with GPT-5.5 as the default. For per-provider setup and how to switch the default provider or model, see [Using Multiple LLM Providers](/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm).
|
||||
|
||||
## Capabilities
|
||||
|
||||
Lighthouse AI works through the [Prowler MCP Server](/getting-started/products/prowler-mcp), which gives the agent a growing catalog of tools to explore and act on your security data. These actions run inside Prowler and never modify your cloud resources. Everything the agent can do maps to one of the following capability areas.
|
||||
|
||||
### Findings and Finding Groups
|
||||
|
||||
- Search and filter security findings across every connected provider by severity, status, region, service, check, date range, and muted state.
|
||||
- Retrieve full finding details, including remediation guidance, check metadata, and affected resources.
|
||||
- Summarize findings with aggregate statistics and trends.
|
||||
- Browse finding groups aggregated by check and drill down into the specific resources each group affects.
|
||||
|
||||
### Resources
|
||||
|
||||
- List and filter cloud resources by provider, region, service, resource type, and tags.
|
||||
- Inspect a resource's configuration, metadata, and related findings.
|
||||
- Review the timeline of cloud API actions performed on a resource (AWS CloudTrail), including who did what and when.
|
||||
- Get an aggregate overview of the resources Prowler has discovered.
|
||||
|
||||
### Compliance
|
||||
|
||||
- Review high-level compliance status across all frameworks, with pass/fail statistics per framework.
|
||||
- Get a requirement-level breakdown for a specific framework, including failed requirements and their associated findings.
|
||||
|
||||
### Attack Paths
|
||||
|
||||
- List Attack Paths scans and discover the queries available for each completed scan.
|
||||
- Run graph-based queries to reveal privilege-escalation chains and exploitable misconfigurations.
|
||||
- Retrieve the Cartography graph schema to build accurate custom queries.
|
||||
|
||||
### Scans and Providers
|
||||
|
||||
- List, inspect, and rename security scans across providers.
|
||||
- Trigger manual scans and schedule automated daily scans for continuous monitoring.
|
||||
- Search connected providers and check their connection status, connect new providers, or remove existing ones.
|
||||
|
||||
### Muting
|
||||
|
||||
- Manage the mutelist for pattern-based bulk muting.
|
||||
- Create, update, list, and delete finding-specific mute rules, each with a documented reason and audit trail.
|
||||
|
||||
### Security Check Catalog and Documentation
|
||||
|
||||
- Browse and search the Prowler Hub catalog of security checks and compliance frameworks, including check code and automated fixers.
|
||||
- Search and retrieve official Prowler documentation to answer how-to and product questions.
|
||||
|
||||
For the complete list of underlying tools, see the [Prowler MCP Tools Reference](/getting-started/basic-usage/prowler-mcp-tools).
|
||||
|
||||
## FAQ
|
||||
|
||||
**Which LLM providers are supported?**
|
||||
@@ -71,7 +135,11 @@ OpenAI (GPT models, including the default GPT-5.5), Amazon Bedrock (Claude, Llam
|
||||
|
||||
**Can Lighthouse AI change my cloud environment?**
|
||||
|
||||
No. Lighthouse AI has read-only access to security data and no tools to modify resources, even when the connected cloud credentials would allow changes.
|
||||
No. Lighthouse AI cannot modify the resources in your connected cloud providers (AWS, Azure, GCP, and others). It has read-only access to that environment and no tools to change it, even when the connected cloud credentials would allow it.
|
||||
|
||||
**Can Lighthouse AI change my Prowler Cloud environment?**
|
||||
|
||||
Yes. Lighthouse AI can take action within Prowler Cloud itself, such as connecting or removing providers, triggering and scheduling scans, and managing mute rules and the mutelist. See [Capabilities](#capabilities) for the full list of what it can do. These actions only affect your Prowler Cloud workspace, never the resources in your cloud providers.
|
||||
|
||||
## Looking for the Open Source Version?
|
||||
|
||||
|
||||
@@ -8,8 +8,29 @@ title: "Overview"
|
||||
**Preview Feature**: This MCP server is currently under active development. Features and functionality may change. We welcome your feedback—please report any issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join our [Slack community](https://goto.prowler.com/slack) to discuss and share your thoughts.
|
||||
</Warning>
|
||||
|
||||
## Quickest Way to Connect: Cloud MCP Server
|
||||
|
||||
The fastest way to get started is the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` — no installation, always up to date, and maintained by Prowler. Just point your MCP client at the URL and authenticate with a [Prowler API key](/user-guide/tutorials/prowler-app-api-keys) as a Bearer token:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"prowler": {
|
||||
"url": "https://mcp.prowler.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <your-api-key-here>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Card title="Connect Your MCP Client to the Cloud MCP Server" icon="cloud" href="/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended" horizontal>
|
||||
Step-by-step setup for Claude Desktop, Claude Code, Cursor, and other clients.
|
||||
</Card>
|
||||
|
||||
<Note>
|
||||
Prowler MCP Server can run as a local instance, or as the hosted **Prowler MCP** at `https://mcp.prowler.com/mcp`. The hosted server also provides tools for Prowler Cloud-specific features such as [Alerts](/user-guide/tutorials/prowler-alerts), [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling), and [Findings Triage](/user-guide/tutorials/prowler-app-findings-triage). See [Deployment Options](#deployment-options).
|
||||
Prefer to run it yourself? The **Local MCP Server** runs on your own machine or infrastructure. The Cloud MCP Server additionally provides tools for Prowler Cloud-specific features such as [Alerts](/user-guide/tutorials/prowler-alerts), [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling), and [Findings Triage](/user-guide/tutorials/prowler-app-findings-triage). See [Cloud vs Local MCP Server](#cloud-vs-local-mcp-server).
|
||||
</Note>
|
||||
|
||||
## What is the Model Context Protocol?
|
||||
@@ -20,9 +41,9 @@ The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) is an open s
|
||||
|
||||
The Prowler MCP Server provides three main integration points:
|
||||
|
||||
### 1. Prowler Cloud and Prowler Local Server
|
||||
### 1. Prowler Cloud, Private Cloud & Local Server
|
||||
|
||||
Full access to Prowler Cloud and Prowler Local Server for:
|
||||
Full access to your Prowler deployment — Prowler Cloud, Prowler Private Cloud, or Prowler Local Server — for:
|
||||
- **Findings Analysis**: Query, filter, and analyze security findings across all your cloud environments
|
||||
- **Provider Management**: Create, configure, and manage your configured Prowler providers (AWS, Azure, GCP, etc.)
|
||||
- **Scan Orchestration**: Trigger on-demand scans and schedule recurring security assessments
|
||||
@@ -48,12 +69,53 @@ Search and retrieve official Prowler documentation:
|
||||
|
||||
## MCP Server Architecture
|
||||
|
||||
The following diagram illustrates the Prowler MCP Server architecture and its integration points:
|
||||
The following diagram illustrates the Prowler MCP Server architecture and its integration points. MCP clients connect to either the **Cloud MCP Server** (recommended) or a **Local MCP Server**; both expose the same tools and reach the same Prowler backends:
|
||||
|
||||

|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph HOSTS["MCP Clients"]
|
||||
chat["Chat Interfaces<br/>(Claude Desktop, LobeChat)"]
|
||||
ide["IDEs and Code Editors<br/>(Claude Code, Cursor)"]
|
||||
apps["Other AI Applications<br/>(5ire, custom agents)"]
|
||||
end
|
||||
|
||||
subgraph SERVERS["Prowler MCP Server"]
|
||||
direction TB
|
||||
cloud["☁️ Cloud MCP Server (Recommended)<br/>mcp.prowler.com/mcp · HTTP<br/>Managed by Prowler · always up to date<br/>Adds Cloud-only tools (Alerts,<br/>Scan Scheduling, Findings Triage)"]
|
||||
local["💻 Local MCP Server<br/>Self-run · STDIO or HTTP<br/>Python 3.12+ or Docker<br/>You manage updates"]
|
||||
end
|
||||
|
||||
subgraph TOOLS["Prowler MCP Tools"]
|
||||
prowler_tools["prowler_* tools<br/>(API key or JWT auth)<br/>Findings · Providers · Scans<br/>Resources · Muting · Compliance<br/>Attack Paths"]
|
||||
hub_tools["prowler_hub_* tools<br/>(no auth)<br/>Checks Catalog · Check Code<br/>Fixers · Compliance Frameworks"]
|
||||
docs_tools["prowler_docs_* tools<br/>(no auth)<br/>Search · Document Retrieval"]
|
||||
end
|
||||
|
||||
api["Prowler API (REST)<br/>Cloud · Private Cloud · Local Server"]
|
||||
hub["hub.prowler.com<br/>(REST)"]
|
||||
docs["docs.prowler.com<br/>(Mintlify)"]
|
||||
|
||||
chat -->|HTTP| cloud
|
||||
ide -->|HTTP| cloud
|
||||
apps -->|HTTP| cloud
|
||||
chat -->|STDIO or HTTP| local
|
||||
ide -->|STDIO or HTTP| local
|
||||
apps -->|STDIO or HTTP| local
|
||||
|
||||
cloud --> prowler_tools
|
||||
cloud --> hub_tools
|
||||
cloud --> docs_tools
|
||||
local --> prowler_tools
|
||||
local --> hub_tools
|
||||
local --> docs_tools
|
||||
|
||||
prowler_tools -->|REST| api
|
||||
hub_tools -->|REST| hub
|
||||
docs_tools -->|REST| docs
|
||||
```
|
||||
|
||||
The architecture shows how AI assistants connect through the MCP protocol to access Prowler's three main components:
|
||||
- Prowler Cloud and Prowler Local Server for security operations
|
||||
- Prowler Cloud, Prowler Private Cloud, or Prowler Local Server for security operations
|
||||
- Prowler Hub for security knowledge
|
||||
- Prowler Documentation for guidance and reference.
|
||||
|
||||
@@ -92,8 +154,8 @@ REQUIREMENTS:
|
||||
|
||||
DATA TO FETCH:
|
||||
Use these MCP tools in this order:
|
||||
1. Prowler app list providers - To get all available configured provider in the account
|
||||
2. Prowler app get latest findings - To get findings information, if there are so many you can use the filter_fields to get less information, or pagination to get in different batches
|
||||
1. Prowler list providers - To get all available configured provider in the account
|
||||
2. Prowler get latest findings - To get findings information, if there are so many you can use the filter_fields to get less information, or pagination to get in different batches
|
||||
3. For most critical findings you can get more context and remediation with Prowler Hub to get remediations for example
|
||||
|
||||
DESIGN REQUIREMENTS:
|
||||
@@ -130,66 +192,48 @@ Generate the complete HTML file and display it
|
||||
></iframe>
|
||||
|
||||
|
||||
## Deployment Options
|
||||
## Cloud vs Local MCP Server
|
||||
|
||||
Prowler MCP Server can be used in three ways:
|
||||
There are two ways to run the Prowler MCP Server. For almost everyone, the **Cloud MCP Server** is the right choice — it needs no installation and is maintained by Prowler. The **Local MCP Server** exists for users who need to run it on their own machine or infrastructure.
|
||||
|
||||
### 1. Prowler Cloud MCP Server
|
||||
| | ☁️ **Cloud MCP Server** (Recommended) | 💻 **Local MCP Server** |
|
||||
|---|---|---|
|
||||
| **Endpoint** | `https://mcp.prowler.com/mcp` | Runs on your machine or infrastructure |
|
||||
| **Setup** | Just configure your MCP client | Install via Docker, or source |
|
||||
| **Transport** | HTTP | STDIO (subprocess) or self-hosted HTTP |
|
||||
| **Maintenance** | Managed by Prowler, always up to date | You manage updates |
|
||||
| **Requirements** | None (just an MCP client) | Python 3.12+ or Docker |
|
||||
| **Cloud-only tools** | ✅ Alerts, Scan Scheduling, Findings Triage | ❌ Not available |
|
||||
| **Authentication** | API key or JWT token | API key/JWT (HTTP) or env vars (STDIO) |
|
||||
|
||||
**Use Prowler's managed MCP server at `https://mcp.prowler.com/mcp`**
|
||||
### ☁️ Cloud MCP Server (Recommended)
|
||||
|
||||
- No installation required.
|
||||
- Managed and maintained by Prowler team.
|
||||
- Authentication to Prowler Cloud or Prowler Local Server via API key or JWT token.
|
||||
- Includes tools for Prowler Cloud-specific features such as Alerts, Scan Scheduling, and Findings Triage.
|
||||
Prowler's managed MCP server at `https://mcp.prowler.com/mcp`. No installation, always up to date, and it includes tools for Prowler Cloud-specific features such as Alerts, Scan Scheduling, and Findings Triage. This is the path we recommend for nearly all users — go straight to the [Configuration guide](/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended).
|
||||
|
||||
### 2. Local STDIO Mode
|
||||
### 💻 Local MCP Server
|
||||
|
||||
**Run the server locally on your machine**
|
||||
Run the server yourself when you need full control over the deployment. It connects to Prowler Cloud, Prowler Private Cloud, or Prowler Local Server and can run in two modes:
|
||||
|
||||
- Runs as a subprocess of the MCP client.
|
||||
- Possibility to connect to Prowler Local Server.
|
||||
- Authentication to Prowler Cloud or Prowler Local Server via environment variables.
|
||||
- Requires Python 3.12+ or Docker.
|
||||
- **STDIO mode** — the server runs as a subprocess of your MCP client. Authentication via environment variables.
|
||||
- **Self-hosted HTTP mode** — deploy your own remote HTTP server. Authentication via API key or JWT token.
|
||||
|
||||
### 3. Self-Hosted HTTP Mode
|
||||
|
||||
**Deploy your own remote MCP server**
|
||||
|
||||
- Full control over deployment.
|
||||
- Possibility to connect to Prowler Local Server.
|
||||
- Authentication to Prowler Local Server via API key or JWT token.
|
||||
- Requires Python 3.12+ or Docker.
|
||||
|
||||
## Requirements
|
||||
|
||||
Requirements vary based on deployment option:
|
||||
|
||||
**For Prowler Cloud MCP Server:**
|
||||
- Prowler Cloud account and API key (only for Prowler Cloud and Prowler Local Server features)
|
||||
|
||||
**For self-hosted STDIO/HTTP Mode:**
|
||||
- Python 3.12+ or Docker
|
||||
- Network access to:
|
||||
- `https://hub.prowler.com` (for Prowler Hub)
|
||||
- `https://docs.prowler.com` (for Prowler Documentation)
|
||||
- Prowler Cloud API or Prowler Local Server API (for Prowler Cloud and Prowler Local Server features)
|
||||
Both require Python 3.12+ or Docker, plus network access to `https://hub.prowler.com` (Prowler Hub), `https://docs.prowler.com` (Prowler Documentation), and the Prowler API or Prowler Local Server API (Prowler features). See the [Installation guide](/getting-started/installation/prowler-mcp) to get started.
|
||||
|
||||
<Note>
|
||||
**No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication in both deployment options. A Prowler API key is only required to access Prowler Cloud or Prowler Local Server features.
|
||||
**No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication on both the Cloud and Local MCP Server. A Prowler API key is only required to access Prowler features (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server).
|
||||
</Note>
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Installation" icon="download" href="/getting-started/installation/prowler-mcp">
|
||||
Install the Prowler MCP Server using uv or Docker
|
||||
</Card>
|
||||
<Card title="Configuration" icon="gear" href="/getting-started/basic-usage/prowler-mcp">
|
||||
Configure your MCP client to connect to the server
|
||||
Connect your MCP client to the Cloud MCP Server
|
||||
</Card>
|
||||
<Card title="Tools Reference" icon="wrench" href="/getting-started/basic-usage/prowler-mcp-tools">
|
||||
Explore all available tools and capabilities
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Card title="Tools Reference" icon="wrench" href="/getting-started/basic-usage/prowler-mcp-tools" horizontal>
|
||||
Explore all available tools and capabilities
|
||||
<Card title="Local Installation" icon="download" href="/getting-started/installation/prowler-mcp" horizontal>
|
||||
Run the Local MCP Server yourself using Docker, source, or uvx
|
||||
</Card>
|
||||
|
||||
@@ -15,7 +15,7 @@ flowchart TB
|
||||
llm["LLM Provider<br/>(OpenAI / Bedrock / OpenAI-compatible)"]
|
||||
|
||||
subgraph MCP["Prowler MCP Server"]
|
||||
app_tools["prowler_app_* tools<br/>(auth required)"]
|
||||
app_tools["prowler_* tools<br/>(auth required)"]
|
||||
hub_tools["prowler_hub_* tools<br/>(no auth)"]
|
||||
docs_tools["prowler_docs_* tools<br/>(no auth)"]
|
||||
end
|
||||
@@ -29,7 +29,7 @@ flowchart TB
|
||||
agent <-->|LLM API| llm
|
||||
agent --> metatools
|
||||
metatools --> mcpclient
|
||||
mcpclient -->|MCP HTTP · Bearer token<br/>for prowler_app_* only| app_tools
|
||||
mcpclient -->|MCP HTTP · Bearer token<br/>for prowler_* only| app_tools
|
||||
mcpclient -->|MCP HTTP| hub_tools
|
||||
mcpclient -->|MCP HTTP| docs_tools
|
||||
app_tools -->|REST| api
|
||||
|
||||
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 432 KiB |
@@ -3,41 +3,37 @@
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="108%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.08"/>
|
||||
</filter>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#9aa0a6"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="550" y="40" text-anchor="middle" font-size="22" font-weight="700" fill="#4285F4">Onboarding Flow</text>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<rect x="30" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#4285F4" stroke-width="2.5" stroke-dasharray="8 4" filter="url(#shadow)"/>
|
||||
<rect x="30" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#4285F4" stroke-width="2.5" filter="url(#shadow)"/>
|
||||
<circle cx="140" cy="100" r="22" fill="#4285F4"/>
|
||||
<text x="140" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">1</text>
|
||||
<text x="140" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Create Management</text>
|
||||
<text x="140" y="165" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Account Role</text>
|
||||
<rect x="60" y="185" width="160" height="24" rx="12" fill="#E8F0FE"/>
|
||||
<text x="140" y="201" text-anchor="middle" font-size="11" font-weight="600" fill="#4285F4">Quick Create or Manual</text>
|
||||
<text x="140" y="232" text-anchor="middle" font-size="12" fill="#5f6368">Allows Prowler to</text>
|
||||
<text x="140" y="248" text-anchor="middle" font-size="12" fill="#5f6368">discover your org</text>
|
||||
<text x="140" y="264" text-anchor="middle" font-size="12" fill="#5f6368">structure</text>
|
||||
<text x="140" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Start the Wizard</text>
|
||||
<rect x="75" y="168" width="130" height="24" rx="12" fill="#E8F0FE"/>
|
||||
<text x="140" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#4285F4">In Prowler Cloud</text>
|
||||
<text x="140" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Enter your Org ID</text>
|
||||
<text x="140" y="232" text-anchor="middle" font-size="12" fill="#5f6368">and OU/root target</text>
|
||||
|
||||
<!-- Arrow 1→2 -->
|
||||
<path d="M260 180 L290 180" stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#9aa0a6"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<rect x="300" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#7B61FF" stroke-width="2.5" filter="url(#shadow)"/>
|
||||
<circle cx="410" cy="100" r="22" fill="#7B61FF"/>
|
||||
<text x="410" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">2</text>
|
||||
<text x="410" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Deploy StackSet</text>
|
||||
<rect x="340" y="165" width="140" height="24" rx="12" fill="#F3F0FF"/>
|
||||
<text x="410" y="181" text-anchor="middle" font-size="11" font-weight="600" fill="#7B61FF">In AWS Console</text>
|
||||
<text x="410" y="212" text-anchor="middle" font-size="12" fill="#5f6368">Creates ProwlerScan</text>
|
||||
<text x="410" y="228" text-anchor="middle" font-size="12" fill="#5f6368">role in every</text>
|
||||
<text x="410" y="244" text-anchor="middle" font-size="12" fill="#5f6368">member account</text>
|
||||
<text x="410" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Deploy the Roles</text>
|
||||
<rect x="350" y="168" width="120" height="24" rx="12" fill="#F3F0FF"/>
|
||||
<text x="410" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#7B61FF">Single CF Stack</text>
|
||||
<text x="410" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Management role +</text>
|
||||
<text x="410" y="232" text-anchor="middle" font-size="12" fill="#5f6368">StackSet to members</text>
|
||||
<text x="410" y="248" text-anchor="middle" font-size="12" fill="#5f6368">in one CF stack</text>
|
||||
|
||||
<!-- Arrow 2→3 -->
|
||||
<path d="M530 180 L560 180" stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
|
||||
@@ -46,11 +42,11 @@
|
||||
<rect x="570" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#00BFA5" stroke-width="2.5" filter="url(#shadow)"/>
|
||||
<circle cx="680" cy="100" r="22" fill="#00BFA5"/>
|
||||
<text x="680" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">3</text>
|
||||
<text x="680" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Run the Wizard</text>
|
||||
<rect x="615" y="165" width="130" height="24" rx="12" fill="#E0F7F4"/>
|
||||
<text x="680" y="181" text-anchor="middle" font-size="11" font-weight="600" fill="#00BFA5">In Prowler Cloud</text>
|
||||
<text x="680" y="212" text-anchor="middle" font-size="12" fill="#5f6368">Discovers accounts,</text>
|
||||
<text x="680" y="228" text-anchor="middle" font-size="12" fill="#5f6368">tests connections</text>
|
||||
<text x="680" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Discover & Connect</text>
|
||||
<rect x="615" y="168" width="130" height="24" rx="12" fill="#E0F7F4"/>
|
||||
<text x="680" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#00BFA5">In Prowler Cloud</text>
|
||||
<text x="680" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Discovers accounts,</text>
|
||||
<text x="680" y="232" text-anchor="middle" font-size="12" fill="#5f6368">tests connections</text>
|
||||
|
||||
<!-- Arrow 3→4 -->
|
||||
<path d="M800 180 L830 180" stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
|
||||
@@ -59,13 +55,13 @@
|
||||
<rect x="840" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#F9AB00" stroke-width="2.5" filter="url(#shadow)"/>
|
||||
<circle cx="950" cy="100" r="22" fill="#F9AB00"/>
|
||||
<text x="950" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">4</text>
|
||||
<text x="950" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Launch Scans</text>
|
||||
<rect x="898" y="165" width="104" height="24" rx="12" fill="#FEF7E0"/>
|
||||
<text x="950" y="181" text-anchor="middle" font-size="11" font-weight="600" fill="#F9AB00">Automatic</text>
|
||||
<text x="950" y="212" text-anchor="middle" font-size="12" fill="#5f6368">Scans run on all</text>
|
||||
<text x="950" y="228" text-anchor="middle" font-size="12" fill="#5f6368">connected accounts</text>
|
||||
<text x="950" y="244" text-anchor="middle" font-size="12" fill="#5f6368">on your schedule</text>
|
||||
<text x="950" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Launch Scans</text>
|
||||
<rect x="898" y="168" width="104" height="24" rx="12" fill="#FEF7E0"/>
|
||||
<text x="950" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#F9AB00">Automatic</text>
|
||||
<text x="950" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Scans run on all</text>
|
||||
<text x="950" y="232" text-anchor="middle" font-size="12" fill="#5f6368">connected accounts</text>
|
||||
<text x="950" y="248" text-anchor="middle" font-size="12" fill="#5f6368">on your schedule</text>
|
||||
|
||||
<!-- Footer -->
|
||||
<text x="550" y="340" text-anchor="middle" font-size="13" fill="#9aa0a6">Steps 1 and 2 are done once in AWS | Steps 3 and 4 are done in Prowler Cloud</text>
|
||||
<text x="550" y="340" text-anchor="middle" font-size="13" fill="#9aa0a6">Step 2 runs once in AWS | Steps 1, 3 and 4 are in Prowler Cloud</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -47,7 +47,7 @@
|
||||
|
||||
<!-- Deploy badge -->
|
||||
<rect x="115" y="400" width="270" height="28" rx="14" fill="#FFF3E0" stroke="#F9AB00" stroke-width="1.5"/>
|
||||
<text x="250" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#E65100">Deploy: Quick Create link or Manual</text>
|
||||
<text x="250" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#E65100">Deploy: single stack or standalone</text>
|
||||
|
||||
<!-- ===== Prowler Cloud connector ===== -->
|
||||
<rect x="490" y="195" width="120" height="36" rx="8" fill="#F5F5F5" stroke="#E0E0E0" stroke-width="1"/>
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
<!-- Deploy badge -->
|
||||
<rect x="735" y="400" width="230" height="28" rx="14" fill="#E8F5E9" stroke="#66BB6A" stroke-width="1.5"/>
|
||||
<text x="850" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#2E7D32">Deploy: via CloudFormation StackSet</text>
|
||||
<text x="850" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#2E7D32">Deploy: StackSet (single stack)</text>
|
||||
|
||||
<!-- Footer labels -->
|
||||
<text x="250" y="478" text-anchor="middle" font-size="14" font-weight="700" fill="#4285F4">Prowler discovers</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 662 KiB |
|
After Width: | Height: | Size: 878 KiB |
@@ -1,29 +1,40 @@
|
||||
flowchart LR
|
||||
subgraph HOSTS["MCP Hosts"]
|
||||
subgraph HOSTS["MCP Clients"]
|
||||
chat["Chat Interfaces<br/>(Claude Desktop, LobeChat)"]
|
||||
ide["IDEs and Code Editors<br/>(Claude Code, Cursor)"]
|
||||
apps["Other AI Applications<br/>(5ire, custom agents)"]
|
||||
end
|
||||
|
||||
subgraph MCP["Prowler MCP Server"]
|
||||
app_tools["prowler_app_* tools<br/>(JWT or API key auth)<br/>Findings · Providers · Scans<br/>Resources · Muting · Compliance<br/>Attack Paths"]
|
||||
subgraph SERVERS["Prowler MCP Server"]
|
||||
direction TB
|
||||
cloud["☁️ Cloud MCP Server (Recommended)<br/>mcp.prowler.com/mcp · HTTP<br/>Managed by Prowler · always up to date<br/>Adds Cloud-only tools (Alerts,<br/>Scan Scheduling, Findings Triage)"]
|
||||
local["💻 Local MCP Server<br/>Self-run · STDIO or HTTP<br/>Python 3.12+ or Docker<br/>You manage updates"]
|
||||
end
|
||||
|
||||
subgraph TOOLS["Prowler MCP Tools"]
|
||||
prowler_tools["prowler_* tools<br/>(API key or JWT auth)<br/>Findings · Providers · Scans<br/>Resources · Muting · Compliance<br/>Attack Paths"]
|
||||
hub_tools["prowler_hub_* tools<br/>(no auth)<br/>Checks Catalog · Check Code<br/>Fixers · Compliance Frameworks"]
|
||||
docs_tools["prowler_docs_* tools<br/>(no auth)<br/>Search · Document Retrieval"]
|
||||
end
|
||||
|
||||
api["Prowler API<br/>(REST)"]
|
||||
api["Prowler API (REST)<br/>Cloud · Private Cloud · Local Server"]
|
||||
hub["hub.prowler.com<br/>(REST)"]
|
||||
docs["docs.prowler.com<br/>(Mintlify)"]
|
||||
|
||||
chat -->|STDIO or HTTP| app_tools
|
||||
chat -->|STDIO or HTTP| hub_tools
|
||||
chat -->|STDIO or HTTP| docs_tools
|
||||
ide -->|STDIO or HTTP| app_tools
|
||||
ide -->|STDIO or HTTP| hub_tools
|
||||
ide -->|STDIO or HTTP| docs_tools
|
||||
apps -->|STDIO or HTTP| app_tools
|
||||
apps -->|STDIO or HTTP| hub_tools
|
||||
apps -->|STDIO or HTTP| docs_tools
|
||||
app_tools -->|REST| api
|
||||
chat -->|HTTP| cloud
|
||||
ide -->|HTTP| cloud
|
||||
apps -->|HTTP| cloud
|
||||
chat -->|STDIO or HTTP| local
|
||||
ide -->|STDIO or HTTP| local
|
||||
apps -->|STDIO or HTTP| local
|
||||
|
||||
cloud --> prowler_tools
|
||||
cloud --> hub_tools
|
||||
cloud --> docs_tools
|
||||
local --> prowler_tools
|
||||
local --> hub_tools
|
||||
local --> docs_tools
|
||||
|
||||
prowler_tools -->|REST| api
|
||||
hub_tools -->|REST| hub
|
||||
docs_tools -->|REST| docs
|
||||
|
||||
|
Before Width: | Height: | Size: 371 KiB |
@@ -184,7 +184,7 @@ $combinedCsv | Export-Csv -Path "CombinedCSV.csv" -NoTypeInformation
|
||||
|
||||
## TODO: Additional Improvements
|
||||
|
||||
Some services need to instantiate another service to perform a check. For instance, `cloudwatch` will instantiate Prowler's `iam` service to perform the `cloudwatch_cross_account_sharing_disabled` check. When the `iam` service is instantiated, it will perform the `__init__` function, and pull all the information required for that service. This provides an opportunity for an improvement in the above script to group related services together so that the `iam` services (or any other cross-service references) isn't repeatedily instantiated by grouping dependant services together. A complete mapping between these services still needs to be further investigated, but these are the cross-references that have been noted:
|
||||
Some services need to instantiate another service to perform a check. For instance, `cloudwatch` will instantiate Prowler's `iam` service to perform the `cloudwatch_cross_account_sharing_disabled` check. When the `iam` service is instantiated, it will perform the `__init__` function, and pull all the information required for that service. This provides an opportunity for an improvement in the above script to group related services together so that the `iam` services (or any other cross-service references) aren't repeatedly instantiated by grouping dependent services together. A complete mapping between these services still needs to be further investigated, but these are the cross-references that have been noted:
|
||||
|
||||
* inspector2 needs lambda and ec2
|
||||
* cloudwatch needs iam
|
||||
|
||||
@@ -114,7 +114,7 @@ The CSV format follows a standardized structure across all providers. The follow
|
||||
|
||||
#### CSV Headers Mapping
|
||||
|
||||
The following table shows the mapping between the CSV headers and the the providers fields:
|
||||
The following table shows the mapping between the CSV headers and the providers fields:
|
||||
|
||||
| Open Source Consolidated| AWS| GCP| AZURE| KUBERNETES
|
||||
|----------|----------|----------|----------|----------
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
title: 'AWS Organizations in Prowler'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<Info>
|
||||
**Using Prowler Cloud?** You can onboard your entire AWS Organization through the UI with automatic account discovery, OU-aware tree selection, and bulk connection testing — no scripts or YAML files required.
|
||||
|
||||
@@ -71,11 +73,43 @@ The additional fields in CSV header output are as follows:
|
||||
|
||||
## Deploying Prowler IAM Roles Across AWS Organizations
|
||||
|
||||
<VersionBadge version="5.35.0" />
|
||||
|
||||
When onboarding multiple AWS accounts into Prowler Cloud, it is important to deploy the Prowler Scan IAM Role in each account. The most efficient way to do this across an AWS Organization is by leveraging AWS CloudFormation StackSets, which rolls out infrastructure—like IAM roles—to all accounts centrally from the Management or Delegated Admin account.
|
||||
|
||||
When using Infrastructure as Code (IaC), Terraform is recommended to manage this deployment systematically.
|
||||
### Native CloudFormation StackSet Deployment (Recommended)
|
||||
|
||||
### Recommended Approach
|
||||
The [Prowler Scan IAM Role CloudFormation template](https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml) can deploy the role across your entire AWS Organization on its own—no third-party modules required. When launched in the **Management Account** (or a **Delegated Administrator** account) with `DeployStackSet=true` and `EnableOrganizations=true`, it creates a service-managed CloudFormation StackSet that rolls the ProwlerScan role out to every account under the target Organizational Unit (or the organization root), and keeps new accounts covered automatically through auto-deployment.
|
||||
|
||||
To deploy from the CloudFormation console: open **CloudFormation → Create stack → With new resources**, choose **Upload a template file** and select `prowler-scan-role.yml` (or paste its S3 URL), then set the parameters below on the **Specify stack details** step. Leave the **Configure stack options** step at its defaults.
|
||||
|
||||
Deploy a single CloudFormation Stack in the Management Account with the following parameters:
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `ExternalId` | External ID provided by Prowler Cloud to secure role assumption. | — |
|
||||
| `DeployLocalRole` | Create the ProwlerScan role in this (Management) account. | `true` |
|
||||
| `DeployStackSet` | Create a service-managed StackSet that deploys the role to member accounts. | `false` |
|
||||
| `AWSOrganizationalUnitId` | Target OU (`ou-xxxx-yyyyyyyy`) or organization root (`r-xxxx`) for the StackSet. Required when `DeployStackSet=true`. | `""` |
|
||||
| `DeployFromDelegatedAdmin` | Set to `true` when deploying from a Delegated Administrator account instead of the Management Account (uses `CallAs: DELEGATED_ADMIN`). | `false` |
|
||||
| `EnableOrganizations` | Add AWS Organizations permissions to the Management Account role: read-only account discovery plus the StackSet-management permissions the deployment needs. Set to `true` when deploying in the Management Account. | `false` |
|
||||
| `FailureTolerancePercentage` | Percentage of accounts in which the StackSet operation can fail before CloudFormation stops the operation. | `10` |
|
||||
| `RetainStacksOnAccountRemoval` | Keep the role in an account after it leaves the Organization or OU. | `false` |
|
||||
|
||||
<Warning>
|
||||
On the review step, select **"I acknowledge that AWS CloudFormation might create IAM resources with custom names"** — the template provisions the named `ProwlerScan` IAM role, so the stack requires the `CAPABILITY_NAMED_IAM` capability and fails without this acknowledgment. (The quick-create link handles this for you.)
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
The service-managed StackSet does **not** deploy to the Management Account itself. Keeping `DeployLocalRole=true` ensures the role also exists there, so a single stack covers both the Management and member accounts.
|
||||
|
||||
Trusted access for CloudFormation StackSets must be enabled in the Organization (see the note at the top of this page) before `DeployStackSet` will work.
|
||||
|
||||
Deploying for the CLI or a self-hosted Prowler (not Prowler Cloud)? Also set `AccountId` to the account you assume the role from and `IAMPrincipal` to your identity — the defaults target Prowler Cloud. See [Aligning the trust policy with your identity](/user-guide/providers/aws/authentication#trust-policy-align-iamprincipal-with-your-identity).
|
||||
|
||||
</Note>
|
||||
|
||||
### Alternative: Deploy with Terraform
|
||||
|
||||
- **Use StackSets** from the **Management Account** (or a Delegated Admin/Security Account).
|
||||
- **Use Terraform** to orchestrate the deployment.
|
||||
|
||||
@@ -77,6 +77,15 @@ The template requires the following parameters:
|
||||
- **AccountId:** *(Optional)* AWS Account ID that will assume the role (default: Prowler Cloud account)
|
||||
- **IAMPrincipal:** *(Optional)* The IAM principal allowed to assume the role (default: `role/prowler*`)
|
||||
|
||||
<Warning>
|
||||
From the CLI you assume the role with **your own** identity, not from Prowler Cloud. The `AccountId` and `IAMPrincipal` defaults target Prowler Cloud, so set **`AccountId`** to the account you run Prowler from and **`IAMPrincipal`** to your identity (for example `role/<name>` or `user/<name>`). Otherwise `sts:AssumeRole` fails with `AccessDenied`. See [Aligning the trust policy with your identity](/user-guide/providers/aws/authentication#trust-policy-align-iamprincipal-with-your-identity).
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
To deploy the role across an entire AWS Organization from a single stack (Management Account role plus a service-managed StackSet for the member accounts), the template also accepts `DeployLocalRole`, `DeployStackSet`, `AWSOrganizationalUnitId`, `DeployFromDelegatedAdmin`, `EnableOrganizations`, `FailureTolerancePercentage`, and `RetainStacksOnAccountRemoval`. See [AWS Organizations in Prowler](/user-guide/providers/aws/organizations#native-cloudformation-stackset-deployment-recommended) for the full parameter reference.
|
||||
|
||||
</Note>
|
||||
|
||||
When running Prowler CLI, include the External ID using the `-I/--external-id` flag:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -434,7 +434,7 @@ prowler oci --oci-config-file /path/to/config
|
||||
|
||||
**Cause**: Insufficient IAM permissions
|
||||
|
||||
**Solution**: Add required policies (see [Required Permissions](./getting-started-oci.md#required-permissions))
|
||||
**Solution**: Add required policies (see [Required Permissions](/user-guide/providers/oci/getting-started-oci#required-permissions))
|
||||
|
||||
### Configuration Validation
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Before you begin, ensure you have:
|
||||
|
||||
### Authentication
|
||||
|
||||
Prowler supports multiple authentication methods for OCI. For detailed authentication setup, see the [OCI Authentication Guide](./authentication).
|
||||
Prowler supports multiple authentication methods for OCI. For detailed authentication setup, see the [OCI Authentication Guide](/user-guide/providers/oci/authentication).
|
||||
|
||||
**Note:** OCI Session Authentication and Config File Authentication both use the same `~/.oci/config` file. The difference is how the config file is generated - automatically via browser (session auth) or manually with API keys.
|
||||
|
||||
@@ -107,7 +107,7 @@ The easiest and most secure method is using OCI session authentication, which au
|
||||
|
||||
#### Alternative: Manual API Key Setup
|
||||
|
||||
If you prefer to manually generate API keys instead of using browser-based session authentication, see the detailed instructions in the [Authentication Guide](./authentication#config-file-authentication-manual-api-key-setup).
|
||||
If you prefer to manually generate API keys instead of using browser-based session authentication, see the detailed instructions in the [Authentication Guide](/user-guide/providers/oci/authentication#config-file-authentication-manual-api-key-setup).
|
||||
|
||||
**Note:** Both methods use the same `~/.oci/config` file - the difference is that manual setup uses static API keys while session authentication uses temporary session tokens.
|
||||
|
||||
|
||||
@@ -272,6 +272,8 @@ python aws_org_generator.py \
|
||||
4. Deploy to all organizational units
|
||||
5. Use a unique external ID (e.g., `prowler-org-2024-abc123`)
|
||||
|
||||
Alternatively, deploy the same template as a **single stack** with `DeployStackSet=true` and `AWSOrganizationalUnitId` set to your root/OU ID — it creates the StackSet for you. See [Native CloudFormation StackSet Deployment](../providers/aws/organizations#native-cloudformation-stackset-deployment-recommended).
|
||||
|
||||
{/* TODO: Add screenshot of CloudFormation StackSets deployment */}
|
||||
</Step>
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ In addition to the upstream schema, Prowler enriches the graph with:
|
||||
<Note>
|
||||
AI assistants connected through Prowler MCP Server can fetch the exact
|
||||
Cartography schema for the active scan via the
|
||||
`prowler_app_get_attack_paths_cartography_schema` tool. This guarantees that
|
||||
`prowler_get_attack_paths_cartography_schema` tool. This guarantees that
|
||||
generated queries match the schema version pinned by the running Prowler
|
||||
release.
|
||||
</Note>
|
||||
@@ -427,10 +427,10 @@ Attack Paths capabilities are also available through the [Prowler MCP Server](/g
|
||||
|
||||
The following MCP tools are available for Attack Paths:
|
||||
|
||||
- **`prowler_app_list_attack_paths_scans`** - List and filter Attack Paths scans.
|
||||
- **`prowler_app_list_attack_paths_queries`** - Discover available queries for a completed scan.
|
||||
- **`prowler_app_run_attack_paths_query`** - Execute a query and retrieve graph results with nodes and relationships.
|
||||
- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema for custom openCypher queries.
|
||||
- **`prowler_list_attack_paths_scans`** - List and filter Attack Paths scans.
|
||||
- **`prowler_list_attack_paths_queries`** - Discover available queries for a completed scan.
|
||||
- **`prowler_run_attack_paths_query`** - Execute a query and retrieve graph results with nodes and relationships.
|
||||
- **`prowler_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema for custom openCypher queries.
|
||||
|
||||
### Example Questions
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.32.0" />
|
||||
|
||||
Scan Configuration lets you override, per provider, specific values in the default configuration Prowler's checks use during a scan. Each configuration modifies how specific checks behave, e.g.: thresholds, allowed values, retention windows, and you attach it to the providers that you want to use it on their next scan.
|
||||
Scan Configuration lets you override, per provider, specific values in the default configuration Prowler's checks use during a scan. Each configuration can modify how specific checks behave, such as thresholds, allowed values, and retention windows, or exclude checks and services from the scan scope. Attach it to the providers that should use it on their next scan.
|
||||
|
||||
<SubscriptionBanner />
|
||||
|
||||
@@ -54,6 +54,24 @@ gcp:
|
||||
storage_min_retention_days: 30
|
||||
```
|
||||
|
||||
### Limiting the Scan Scope
|
||||
|
||||
<VersionBadge version="5.35.0" />
|
||||
|
||||
Use `excluded_checks` to skip individual checks and `excluded_services` to skip every check in a service for the matching provider type:
|
||||
|
||||
```yaml
|
||||
aws:
|
||||
excluded_checks:
|
||||
- s3_bucket_public_access
|
||||
excluded_services:
|
||||
- ec2
|
||||
```
|
||||
|
||||
<Warning>
|
||||
When a Scan Configuration excludes checks or services, Prowler calculates overviews, aggregations, and other result-based information from the reduced scan scope. The displayed information reflects only the checks and services that ran, not a complete assessment of the provider. Consider the applied Scan Configuration when interpreting totals and security posture.
|
||||
</Warning>
|
||||
|
||||
## Creating a Scan Configuration
|
||||
|
||||
<Steps>
|
||||
|
||||
@@ -8,12 +8,14 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.19.0" />
|
||||
|
||||
Prowler Cloud enables you to onboard all AWS accounts in your Organization through a single guided wizard. Instead of connecting accounts one by one, you can discover every account in your AWS Organization, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI.
|
||||
Prowler Cloud onboards every AWS account in your Organization through a single guided wizard. Instead of connecting accounts one by one, you can discover every account in your AWS Organization, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI.
|
||||
|
||||
<SubscriptionBanner>
|
||||
For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations).
|
||||
</SubscriptionBanner>
|
||||
|
||||
To follow this guide you need an active [Prowler Cloud](https://cloud.prowler.com) account and access to your AWS Organization [management account](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_introduction.html) (or a registered delegated administrator account).
|
||||
|
||||
## Overview
|
||||
|
||||
### Individual Accounts vs Organizations
|
||||
@@ -25,225 +27,17 @@ For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/us
|
||||
|
||||
### How It Works
|
||||
|
||||
Before using the AWS Organizations wizard, you need to deploy **two Identity and Access Management (IAM) roles** in your AWS environment. The onboarding follows this sequence:
|
||||
<VersionBadge version="5.35.0" />
|
||||
|
||||
Onboarding deploys the **ProwlerScan Identity and Access Management (IAM) role** in your management account and in every member account. A **single CloudFormation stack** — launched from the wizard's **Create Stack in Management Account** button ([Step 2](#step-2-authenticate-with-your-management-account)) — creates the management account role **and** a service-managed StackSet that rolls the role out to your member accounts in one operation. Prefer to deploy the roles yourself? See [Deploy the Roles Manually](#deploy-the-roles-manually).
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/onboarding-flow.svg" alt="Onboarding flow: 1. Create Management Account Role (Quick Create or Manual), 2. Deploy StackSet, 3. Run the Wizard, 4. Launch Scans" />
|
||||
<img src="/images/organizations/onboarding-flow.svg" alt="Onboarding flow: 1. Start the Wizard, 2. Deploy the Roles (single CloudFormation stack), 3. Discover and Connect, 4. Launch Scans" />
|
||||
</Frame>
|
||||
|
||||
## Key Concepts
|
||||
## Step 1: Start the Organization Wizard
|
||||
|
||||
### What Is an External ID?
|
||||
|
||||
An **External ID** is a security token that Prowler generates unique to your tenant. When Prowler assumes the IAM role in your AWS account, it presents this External ID to prove its identity.
|
||||
|
||||
This prevents the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html) — a scenario where an unauthorized party could trick AWS into granting access to your account. By requiring the External ID, only your specific Prowler tenant can assume the role.
|
||||
|
||||
You don't need to create the External ID yourself — Prowler generates it automatically and displays it in the wizard for you to copy.
|
||||
|
||||
### Two Roles Architecture
|
||||
|
||||
Prowler requires **two separate IAM roles** deployed in different places, each with a distinct purpose:
|
||||
|
||||
| Role | Where it lives | What it does | How to deploy it |
|
||||
|------|---------------|--------------|------------------|
|
||||
| **ProwlerScan** (management account) | Your management (root) account only | Discovers the Organization structure **and** scans the management account. Has additional Organizations discovery permissions. | Via **Quick Create** link or **manually** in the IAM Console ([Step 1](#step-1-create-the-management-account-role)). Cannot be deployed via StackSet. |
|
||||
| **ProwlerScan** (member accounts) | Every member account | Scans the account for security findings. | Via **CloudFormation StackSet** ([Step 2](#step-2-deploy-the-cloudformation-stackset)). Automated across all accounts. |
|
||||
|
||||
<Frame caption="Both roles share the same name `ProwlerScan`. The management account role includes additional Organization discovery permissions.">
|
||||
<img src="/images/organizations/two-roles-architecture.svg" alt="Two Roles Architecture: ProwlerScan in management account (Quick Create or Manual, discovery + scanning) and ProwlerScan in member accounts (via StackSet, scanning only)" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
**Same name, different permissions.** Both roles are named `ProwlerScan` — Prowler expects a consistent role name across all accounts. The management account role has the same scanning permissions as member accounts, plus additional Organizations discovery permissions (see [Step 1](#step-1-create-the-management-account-role) for the full list).
|
||||
</Note>
|
||||
|
||||
### What Is a CloudFormation StackSet?
|
||||
|
||||
A [CloudFormation StackSet](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/what-is-cfnstacksets.html) lets you deploy the same CloudFormation template across multiple AWS accounts in a single operation. Prowler uses a StackSet to deploy the **ProwlerScan** IAM role into every member account of your organization, so you don't have to create the role manually in each account.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Prowler Cloud Account
|
||||
|
||||
You need an active [Prowler Cloud](https://cloud.prowler.com) account. Each AWS account you connect will count as a provider in your subscription. See [Billing Impact](#billing-impact) for details.
|
||||
|
||||
### AWS Organization Enabled
|
||||
|
||||
Your AWS environment must have [AWS Organizations](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_introduction.html) enabled. You will need access to the **management account** (or a delegated administrator account) to provide the Organization ID and IAM Role ARN.
|
||||
|
||||
## Step 1: Create the Management Account Role
|
||||
|
||||
The first role you need to create is the **management account role**. This role allows Prowler to discover your Organization structure — listing accounts, OUs, and hierarchy.
|
||||
|
||||
<Warning>
|
||||
**StackSets do not deploy to the management account.** Organizational CloudFormation StackSets with service-managed permissions only target member accounts — this is an AWS limitation, not a Prowler one. You must create the management account role separately, either via the Quick Create link ([Option A](#option-a-quick-create-link-fastest)) or manually ([Option B](#option-b-create-the-role-manually)).
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
**The role must be named `ProwlerScan`** — the same name as the role deployed to member accounts via StackSet. Prowler expects a consistent role name across all accounts in the Organization. If you use a different name, connection tests and scans will fail for the management account.
|
||||
</Note>
|
||||
|
||||
### Option A: Quick Create Link (Fastest)
|
||||
|
||||
The Prowler wizard provides a one-click link that opens the AWS Console with the CloudFormation template pre-configured. This creates a **CloudFormation Stack** (not a StackSet) that deploys the ProwlerScan role with Organizations permissions enabled in your management account.
|
||||
|
||||
<Tip>
|
||||
**[Open Quick Create Stack in AWS Console →](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=Prowler¶m_EnableOrganizations=true)**
|
||||
|
||||
Opens the CloudFormation Console with the Prowler scan role template and `EnableOrganizations=true` pre-filled. You will need to enter the **ExternalId** parameter manually — copy it from the Prowler wizard ([Step 4](#step-4-authenticate-with-your-management-account)).
|
||||
</Tip>
|
||||
|
||||
1. Click **[Open Quick Create Stack in AWS Console →](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=Prowler¶m_EnableOrganizations=true)** or use the **Create Stack in Management Account** button in the Prowler wizard (which also pre-fills the ExternalId).
|
||||
2. Enter the **ExternalId** parameter if not pre-filled.
|
||||
3. Check **"I acknowledge that AWS CloudFormation might create IAM resources with custom names"** and click **Create stack**.
|
||||
4. Wait for the stack to reach **CREATE_COMPLETE** status.
|
||||
|
||||
Take note of the **Role ARN** from the stack's **Outputs** tab — you will need it in the wizard.
|
||||
|
||||
### Option B: Create the Role Manually
|
||||
|
||||
1. Sign in to the [AWS IAM Console](https://console.aws.amazon.com/iam/) in your **management account**.
|
||||
|
||||
2. Go to **Roles > Create role** and select **Custom trust policy**.
|
||||
|
||||
3. Paste the following trust policy. This allows Prowler Cloud to assume the role using your tenant's External ID (you will get this from the Prowler wizard in [Step 3](#step-3-start-the-organization-wizard)):
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": "arn:aws:iam::232136659152:root"
|
||||
},
|
||||
"Action": "sts:AssumeRole",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"sts:ExternalId": "<YOUR_EXTERNAL_ID>"
|
||||
},
|
||||
"StringLike": {
|
||||
"aws:PrincipalArn": "arn:aws:iam::232136659152:role/prowler*"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Replace `<YOUR_EXTERNAL_ID>` with the External ID shown in the Prowler wizard.
|
||||
|
||||
4. Attach the following AWS managed policies:
|
||||
- **SecurityAudit**
|
||||
- **ViewOnlyAccess**
|
||||
|
||||
This allows Prowler to also scan the management account for security findings, just like any other account.
|
||||
|
||||
5. Create an additional inline policy with the following permissions. These are specific to the management account and allow Prowler to discover your Organization structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ProwlerOrganizationDiscovery",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"organizations:DescribeAccount",
|
||||
"organizations:DescribeOrganization",
|
||||
"organizations:ListAccounts",
|
||||
"organizations:ListAccountsForParent",
|
||||
"organizations:ListOrganizationalUnitsForParent",
|
||||
"organizations:ListRoots",
|
||||
"organizations:ListTagsForResource"
|
||||
],
|
||||
"Resource": "*"
|
||||
},
|
||||
{
|
||||
"Sid": "ProwlerStackSetManagement",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"organizations:RegisterDelegatedAdministrator",
|
||||
"iam:CreateServiceLinkedRole"
|
||||
],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
<Tip>
|
||||
You can optionally restrict the `Resource` field to your specific Organization ARN (e.g., `arn:aws:organizations::123456789012:organization/o-abc123def4`) instead of `"*"` to minimize the blast radius.
|
||||
</Tip>
|
||||
|
||||
6. Name the role **`ProwlerScan`** and click **Create role**. Take note of the **Role ARN** — you will need it in the Prowler wizard.
|
||||
|
||||
The ARN follows this format: `arn:aws:iam::<account-id>:role/ProwlerScan`
|
||||
|
||||
<Warning>
|
||||
The role **must** be named `ProwlerScan`. Do not use a different name.
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
If you just created the role, it may take up to **60 seconds** for AWS to propagate it. If you get an error in the Prowler wizard, wait a moment and try again.
|
||||
</Note>
|
||||
|
||||
## Step 2: Deploy the CloudFormation StackSet
|
||||
|
||||
After creating the management account role, the next step is to deploy the **ProwlerScan** role to your member accounts using a CloudFormation StackSet. This is the recommended method for consistent, scalable deployment across your entire organization.
|
||||
|
||||
The StackSet uses **service-managed permissions**, which means AWS Organizations handles the cross-account deployment automatically — you don't need to create execution roles manually in each account. The StackSet deploys the ProwlerScan IAM role in every target member account, enabling Prowler to assume that role for cross-account scanning.
|
||||
|
||||
<Note>
|
||||
**Trusted access required:** CloudFormation StackSets must have trusted access enabled in your management account. Verify this in the AWS Console under **AWS Organizations > Settings > Trusted access for AWS CloudFormation StackSets**.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
**The Quick Create link creates a Stack, not a StackSet.** The link in the Prowler wizard creates a CloudFormation **Stack** that deploys the ProwlerScan role in your management account only ([Step 1](#step-1-create-the-management-account-role)). To deploy the role across **member accounts**, you must create a StackSet manually as described below. AWS does not support Quick Create links for StackSets.
|
||||
</Warning>
|
||||
|
||||
<Tip>
|
||||
**[Open StackSets Console →](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacksets/create)**
|
||||
|
||||
Opens the CloudFormation StackSets creation page directly. You will need to paste the template URL and ExternalId manually.
|
||||
</Tip>
|
||||
|
||||
1. Click the link above or navigate to **CloudFormation > StackSets > Create StackSet** in your management account.
|
||||
2. Choose **Service-managed permissions**.
|
||||
3. Select **Amazon S3 URL** as the template source and paste the following URL:
|
||||
```
|
||||
https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml
|
||||
```
|
||||
4. Set the **ExternalId** parameter to the External ID shown in the Prowler wizard.
|
||||
5. Choose your deployment targets (entire organization or specific OUs).
|
||||
6. Select the AWS regions where you want the role deployed.
|
||||
7. Click **Create StackSet**.
|
||||
|
||||
### Verify StackSet Deployment
|
||||
|
||||
After deploying, verify that all stack instances completed successfully:
|
||||
|
||||
1. In the CloudFormation Console, go to **StackSets** and select your Prowler StackSet.
|
||||
2. Click the **Stack instances** tab.
|
||||
3. Confirm that all instances show **Status: CURRENT** and **Stack status: CREATE_COMPLETE**.
|
||||
|
||||
Deployment typically takes **2–5 minutes** for medium-sized organizations. Large organizations (500+ accounts) may take longer.
|
||||
|
||||
<Note>
|
||||
**Prefer Terraform?** You can deploy the ProwlerScan role using Terraform instead. See the [StackSets deployment guide](/user-guide/providers/aws/organizations#deploying-prowler-iam-roles-across-aws-organizations) for the Terraform module.
|
||||
</Note>
|
||||
|
||||
### Key Considerations
|
||||
|
||||
- **Service-managed permissions**: Always select **Service-managed permissions** when creating the StackSet. This lets AWS Organizations manage the deployment automatically across current and future member accounts.
|
||||
- **Least privilege**: The ProwlerScan role deployed by the StackSet uses `SecurityAudit` and `ViewOnlyAccess` — AWS managed policies that grant read-only access — plus a small set of additional read-only permissions for services not covered by those policies. See the [CloudFormation template](https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml) for the full list. Prowler does not make any changes to your accounts.
|
||||
- **New accounts**: When you add new accounts to your AWS Organization, the StackSet automatically deploys the ProwlerScan role to them if you targeted the organization root or the relevant OU. Combined with Prowler's 6-hour automatic sync, new accounts are onboarded end-to-end without manual intervention.
|
||||
- **Management account**: Organizational StackSets **do not deploy to the management account itself**. If you want to scan the management account, you need to create the ProwlerScan role there separately using a regular CloudFormation Stack.
|
||||
|
||||
## Step 3: Start the Organization Wizard
|
||||
|
||||
Now that both roles are deployed — the management account role (Step 1) and the ProwlerScan role in member accounts (Step 2) — you can start the Prowler wizard.
|
||||
The Prowler wizard walks you through the entire flow: deploying both roles from a single CloudFormation stack, discovering your accounts, testing connectivity, and launching scans.
|
||||
|
||||
### Open the Wizard
|
||||
|
||||
@@ -280,29 +74,50 @@ Now that both roles are deployed — the management account role (Step 1) and th
|
||||
|
||||
Click **Next** to proceed to the authentication phase.
|
||||
|
||||
## Step 4: Authenticate with Your Management Account
|
||||
## Step 2: Authenticate with Your Management Account
|
||||
|
||||
The wizard's **Authentication Details** page guides you through three actions: deploying the roles in AWS, entering the management account Role ARN, and confirming the deployment.
|
||||
The **Authentication Details** page guides you through three actions: deploying the roles in AWS, entering the deployment account Role ARN, and confirming the deployment. The deployment account is either the management account or, when delegated administrator mode is selected, the delegated administrator account.
|
||||
|
||||
### External ID
|
||||
|
||||
The wizard displays a **Prowler External ID** at the top — auto-generated and unique to your tenant. Click the copy icon to copy it. You will need this External ID for both the management account Stack and the member accounts StackSet.
|
||||
The wizard displays a **Prowler External ID** at the top — auto-generated and unique to your tenant. Click the copy icon to copy it. The External ID is pre-filled into the deployment link, and the single stack applies it to both the management account role and the member-account StackSet. Learn more in [What Is an External ID?](#what-is-an-external-id).
|
||||
|
||||
### Deploy the Roles
|
||||
|
||||
The wizard provides two deployment actions:
|
||||
<VersionBadge version="5.35.0" />
|
||||
|
||||
1. **Create Stack in Management Account** — opens a Quick Create link that deploys the ProwlerScan role with `EnableOrganizations=true` in your management account ([Step 1](#step-1-create-the-management-account-role)). The External ID is pre-filled.
|
||||
The wizard deploys the deployment account role and the member-account StackSet in a **single** CloudFormation Stack:
|
||||
|
||||
2. **Open StackSets Console** — links to the CloudFormation StackSets console where you create a StackSet for member accounts ([Step 2](#step-2-deploy-the-cloudformation-stackset)). Copy the template URL shown in the wizard and paste the External ID manually.
|
||||
<Note>
|
||||
**Prefer to use your own role?** You do not have to use the Quick Create template. Create the ProwlerScan role yourself — through the IAM Console, Terraform, or your own CloudFormation [(Following this guide)](#deploy-the-roles-manually) — and paste its ARN into the Role ARN field below. The role must use the external ID from the earlier step and include the trust policy and permissions described in [Deploy the Roles Manually](#deploy-the-roles-manually).
|
||||
</Note>
|
||||
|
||||
1. **Organizational Unit or Root ID** — enter the AWS OU (`ou-xxxx-yyyyyyyy`) or organization root (`r-xxxx`) you want to onboard. Prowler rolls the ProwlerScan role out to every member account under this target. Find it in the [AWS Organizations Console](https://console.aws.amazon.com/organizations/); use the **root ID** (`r-`) to cover the entire organization or an **OU ID** (`ou-`) to target a specific unit.
|
||||
|
||||
2. *(Optional)* Check **"I'm deploying from a delegated administrator account"** if you launch the stack from a delegated administrator account instead of the management account.
|
||||
|
||||
3. **Create Stack in Management Account** — or **Create Stack in Delegated Administrator Account** when delegated administrator mode is selected — opens a Quick Create link that deploys, in a single stack: the ProwlerScan role in the account where you launch the stack (`DeployLocalRole`, with `EnableOrganizations=true`) **and** a service-managed StackSet (`DeployStackSet`) that rolls the role out to your member accounts. The External ID, OU/Root ID, and deployment options are pre-filled.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/authentication-details.png" alt="Authentication Details form showing External ID, two deployment buttons (Create Stack in Management Account and Open StackSets Console), Management Account Role ARN field, and deployment confirmation checkbox" />
|
||||
<img src="/images/organizations/authentication-details.png" alt="Authentication Details form showing External ID, Organizational Unit or Root ID field, delegated administrator checkbox, deployment account stack button, deployment account Role ARN field, and deployment confirmation checkbox" />
|
||||
</Frame>
|
||||
|
||||
### Enter the Management Account Role ARN
|
||||
<Tip>
|
||||
**Finding your Organizational Unit or Root ID.** In the [AWS Organizations Console](https://console.aws.amazon.com/organizations/) the root (`r-…`) and OU (`ou-…`) IDs appear in the account tree, or run these from your management account:
|
||||
|
||||
Paste the **Role ARN** of the management account role you created in [Step 1](#step-1-create-the-management-account-role) into the **Management Account Role ARN** field.
|
||||
```bash
|
||||
# Root ID — deploys the role to the entire organization
|
||||
aws organizations list-roots --query 'Roots[0].Id' --output text
|
||||
|
||||
# OU IDs under the root — to target a specific unit instead
|
||||
aws organizations list-organizational-units-for-parent --parent-id r-xxxx \
|
||||
--query 'OrganizationalUnits[].{Name:Name,Id:Id}' --output table
|
||||
```
|
||||
</Tip>
|
||||
|
||||
### Enter the Deployment Account Role ARN
|
||||
|
||||
Paste the **Role ARN** created by the stack above into the **Management Account Role ARN** field or, when delegated administrator mode is selected, the **Delegated Administrator Account Role ARN** field.
|
||||
|
||||
The ARN follows this format:
|
||||
```
|
||||
@@ -312,12 +127,16 @@ arn:aws:iam::<account-id>:role/ProwlerScan
|
||||
For example: `arn:aws:iam::123456789012:role/ProwlerScan`
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/role-arn-field.png" alt="Management Account Role ARN field in the Authentication Details form" />
|
||||
<img src="/images/organizations/role-arn-field.png" alt="Deployment account Role ARN field in the Authentication Details form" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
It may take up to **60 seconds** for AWS to generate the IAM Role ARN after the stack completes. If the wizard reports an error, wait a moment and try again.
|
||||
</Note>
|
||||
|
||||
### Confirm and Discover
|
||||
|
||||
1. Check the box: **"The Stack and StackSet have been successfully deployed in AWS"**.
|
||||
1. Check the box: **"The Stack has been successfully deployed in AWS"**.
|
||||
2. Click **Authenticate**.
|
||||
|
||||
Here's what happens behind the scenes:
|
||||
@@ -325,7 +144,7 @@ Here's what happens behind the scenes:
|
||||
- An asynchronous discovery is triggered to query your AWS Organization structure.
|
||||
- You will see a **"Gathering AWS Accounts..."** spinner — this typically takes **30 seconds to 2 minutes** depending on your organization size.
|
||||
|
||||
## Step 5: Select Accounts to Scan
|
||||
## Step 3: Select Accounts to Scan
|
||||
|
||||
### Understanding the Tree View
|
||||
|
||||
@@ -336,6 +155,7 @@ Once discovery completes, the wizard displays a **hierarchical tree view** of yo
|
||||
</Frame>
|
||||
|
||||
- The tree supports up to **5 levels of nesting** (Root > OUs > Sub-OUs > Accounts).
|
||||
- If you deployed the stack for just one OU, that OU will be preselected in the tree.
|
||||
- **Selecting an OU** automatically selects all accounts within it.
|
||||
- **Individual overrides**: deselect specific accounts even if the parent OU is selected.
|
||||
- The header shows **"X of Y accounts selected"** to track your selection.
|
||||
@@ -352,14 +172,12 @@ Only **ACTIVE** accounts can be selected for scanning:
|
||||
| **CLOSED** | No | Account has been closed. |
|
||||
|
||||
<Note>
|
||||
**Your existing data is safe.** If an AWS account is already connected to Prowler as an individual provider, it will appear in the tree with a checkmark indicator.
|
||||
**Your existing data is safe.** If an AWS account is already connected to Prowler as an individual provider, it appears in the tree with a checkmark indicator.
|
||||
|
||||
When you proceed:
|
||||
- The existing provider is **linked** to the organization — it is **not** duplicated.
|
||||
- All your **historical scan data and findings are preserved** — nothing is overwritten.
|
||||
- There is **no additional billing** — the existing provider is reused.
|
||||
|
||||
This is completely safe. You are simply associating the account with the organization for easier management.
|
||||
</Note>
|
||||
|
||||
### Custom Aliases
|
||||
@@ -368,14 +186,9 @@ You can edit the display name for each account before connecting. This alias is
|
||||
|
||||
### Blocked Accounts
|
||||
|
||||
Some accounts may appear as **blocked** (grayed out, not selectable). This happens when:
|
||||
- The account is **already linked to a different organization** in Prowler (`linked_to_other_organization`).
|
||||
Some accounts may appear as **blocked** (grayed out, not selectable) when the account is **already linked to a different organization** in Prowler (`linked_to_other_organization`). Hover over the blocked account to see the specific reason.
|
||||
|
||||
Hover over the blocked account to see the specific reason.
|
||||
|
||||
## Step 6: Test Connections
|
||||
|
||||
### How Connection Testing Works
|
||||
## Step 4: Test Connections
|
||||
|
||||
Click **Test Connections** to verify that Prowler can assume the **ProwlerScan** role in each selected member account.
|
||||
|
||||
@@ -383,154 +196,225 @@ Click **Test Connections** to verify that Prowler can assume the **ProwlerScan**
|
||||
<img src="/images/organizations/test-connections.png" alt="Connection testing in progress with spinners on each account" />
|
||||
</Frame>
|
||||
|
||||
- Each account shows a real-time status indicator:
|
||||
- **Spinner** — test in progress
|
||||
- **Green checkmark (✓)** — connection successful
|
||||
- **Red icon (✗)** — connection failed (hover to see the error)
|
||||
|
||||
### All Tests Pass
|
||||
Each account shows a real-time status indicator:
|
||||
- **Spinner** — test in progress
|
||||
- **Green checkmark (✓)** — connection successful
|
||||
- **Red icon (✗)** — connection failed (hover to see the error)
|
||||
|
||||
If every account connects successfully, you automatically advance to the next step.
|
||||
|
||||
### Some Tests Fail
|
||||
### When Some Tests Fail
|
||||
|
||||
An error banner appears: **"There was a problem connecting to some accounts."**
|
||||
|
||||
You have two options:
|
||||
An error banner appears: **"There was a problem connecting to some accounts."** You have two options:
|
||||
|
||||
**a) Fix and retry:**
|
||||
1. Go to the AWS Console and verify the StackSet deployed to the failing accounts.
|
||||
2. Check that the External ID in the StackSet matches the one shown in Prowler.
|
||||
3. Return to Prowler and click **Test Connections** — only the **failed accounts are re-tested** (smart retry). Accounts that already passed are not tested again.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/test-connections-button.png" alt="Test Connections button" />
|
||||
</Frame>
|
||||
|
||||
**b) Skip and continue:**
|
||||
Click **Skip Connection Validation** to proceed with only the accounts that connected successfully. The failed accounts will not be scanned.
|
||||
Click **Skip Connection Validation** to proceed with only the accounts that connected successfully. The failed accounts will not be scanned. This option is only available when at least one account connected successfully.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/connection-failures-skip.png" alt="Connection test results showing failed accounts with error banner and Skip Connection Validation button" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
**Skip Connection Validation** is only available when at least one account connected successfully.
|
||||
</Note>
|
||||
If **no accounts** connected successfully, you cannot proceed. Fix the underlying connection issues — see [Troubleshooting](#troubleshooting) — and retry before launching scans.
|
||||
|
||||
### All Tests Fail
|
||||
|
||||
If **no accounts** connected successfully, you cannot proceed:
|
||||
|
||||
> *"No accounts connected successfully. Fix the connection errors and retry before launching scans."*
|
||||
|
||||
You must fix the underlying connection issues before continuing. See [Updating Credentials](#updating-credentials) below.
|
||||
|
||||
### Updating Credentials
|
||||
|
||||
If connection tests fail, here's how to fix common issues:
|
||||
|
||||
1. Open the [CloudFormation Console](https://console.aws.amazon.com/cloudformation/) and check that your StackSet instances show **CREATE_COMPLETE** for the failing accounts. If not, update the StackSet to include the missing OUs.
|
||||
2. Compare the **ExternalId** parameter in your StackSet with the External ID displayed in the Prowler wizard. They must match exactly.
|
||||
3. After fixing the issue in AWS, return to Prowler and click **Test Connections**. Only the previously failed accounts will be re-tested.
|
||||
|
||||
## Step 7: Launch Scans
|
||||
|
||||
### Choose Scan Schedule
|
||||
## Step 5: Launch Scans
|
||||
|
||||
The Organizations wizard uses the same schedule controls described in [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling#schedule-options).
|
||||
|
||||
### Launch
|
||||
|
||||
Click **Save**, **Save and launch scan**, or **Launch scan**, depending on the selected schedule option. A toast notification confirms whether the schedule was saved, scans were launched, or both. The toast includes a link to the **Scans** page. Prowler redirects to the **Providers** page.
|
||||
|
||||
Scans are only launched for accounts that are accessible (passed connection testing) and were selected.
|
||||
Click **Save**, **Save and launch scan**, or **Launch scan**, depending on the selected schedule option. A toast notification confirms whether the schedule was saved, scans were launched, or both, and includes a link to the **Scans** page. Prowler then redirects to the **Providers** page. Scans launch only for accounts that passed connection testing and were selected.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/organizations/launch-scan.png" alt="Launch Scan step showing Accounts Connected confirmation, scan schedule selector, and Launch scan button" />
|
||||
</Frame>
|
||||
|
||||
### What Happens Next
|
||||
|
||||
After launching:
|
||||
- Scans appear in the **Scans** page as they start and complete.
|
||||
- Results populate the **Overview** and **Findings** pages.
|
||||
- Prowler runs an **automatic sync every 6 hours** to detect new accounts added to your Organization or accounts that have been removed. New accounts are onboarded automatically based on the parent OU configuration.
|
||||
- Prowler runs an **automatic sync every 6 hours** to detect accounts added to or removed from your Organization. New accounts under the targeted OU or root are onboarded automatically.
|
||||
|
||||
## Billing Impact
|
||||
|
||||
Each AWS account you connect through the Organizations wizard counts as one **provider** in your Prowler Cloud subscription.
|
||||
|
||||
- **Already-connected accounts**: if an account was already linked as a provider, adding it to the organization does **not** incur additional billing. The existing provider is reused.
|
||||
- **Large organizations**: connecting a 500-account organization will result in up to 500 providers on your subscription. Review your plan limits before proceeding.
|
||||
- **Large organizations**: connecting a 500-account organization results in up to 500 providers on your subscription. Review your plan limits before proceeding.
|
||||
- **Deleted providers**: if you later remove an account, the deleted provider no longer counts toward your subscription.
|
||||
|
||||
For pricing details, see [Prowler Cloud Pricing](https://prowler.com/pricing).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Invalid AWS Organization ID
|
||||
### Only Some Accounts Connect
|
||||
|
||||
*"Must be a valid AWS Organization ID"*
|
||||
Discovery succeeds and the tree view appears, but only one account — or a handful — passes the connection test. This almost always means the ProwlerScan role reached the deployment account but not every member account.
|
||||
|
||||
- Verify the Organization ID format: `o-` followed by 10–32 lowercase alphanumeric characters (e.g., `o-abc123def4`)
|
||||
- Copy it directly from the [AWS Organizations Console](https://console.aws.amazon.com/organizations/) to avoid typos
|
||||
- **Confirm the StackSet deployed.** Open the [CloudFormation Console](https://console.aws.amazon.com/cloudformation/) in the deployment account, select your Prowler StackSet, open the **Stack instances** tab, and confirm every instance shows **Status: CURRENT** and **Stack status: CREATE_COMPLETE**. Instances still in progress or in a failed state explain the missing accounts.
|
||||
- **Check the targeted OU or root.** The single stack only rolls the role out to accounts under the **Organizational Unit or Root ID** you entered in [Step 2](#step-2-authenticate-with-your-management-account). Accounts in other OUs are not covered — redeploy targeting the organization root (`r-`) or add the missing OUs.
|
||||
- **Verify the deployment account.** The role is created only in the account where you launched the stack. If you deployed from a **delegated administrator account**, confirm that account is a **registered delegated administrator** for CloudFormation StackSets (registered through AWS Organizations), not just a regular member account. A regular member account cannot create a service-managed StackSet, so only its own role is created — leaving every other account without the role.
|
||||
- **Suspended accounts** cannot be scanned. Deselect them and proceed.
|
||||
|
||||
### Invalid IAM Role ARN
|
||||
### No Accounts Connect
|
||||
|
||||
*"Must be a valid IAM Role ARN"*
|
||||
No account passes the connection test.
|
||||
|
||||
- Verify the ARN format: `arn:aws:iam::<12-digit-account-id>:role/<role-name>`
|
||||
- Copy the ARN directly from the [IAM Console](https://console.aws.amazon.com/iam/) in your management account
|
||||
- **External ID mismatch.** Compare the **ExternalId** parameter in your StackSet with the External ID shown in the Prowler wizard. They must match exactly.
|
||||
- **StackSet not deployed.** Confirm the StackSet exists and its instances reached **CREATE_COMPLETE**. If you deployed the roles manually, verify [trusted access for CloudFormation StackSets](#member-account-role-stackset) is enabled.
|
||||
- **IP-based policies.** If your accounts restrict access by IP, allow the [Prowler Cloud egress IPs](/security/networking).
|
||||
|
||||
### Authentication Failed
|
||||
### Authentication Fails or Times Out
|
||||
|
||||
*"Authentication failed. Please verify the StackSet deployment and Role ARN"*
|
||||
*"Authentication failed. Please verify the StackSet deployment and Role ARN"* or *"Authentication timed out"*
|
||||
|
||||
- Verify the management account role exists and was created in [Step 1](#step-1-create-the-management-account-role)
|
||||
- Confirm the trust policy includes the correct External ID from the wizard
|
||||
- Check the role has all Organizations discovery permissions listed in [Step 1](#step-1-create-the-management-account-role)
|
||||
- Double-check the Role ARN format and account ID for typos
|
||||
- Verify the deployment account role exists and is named exactly `ProwlerScan`.
|
||||
- Confirm the trust policy includes the correct External ID from the wizard.
|
||||
- Check the role has the Organizations discovery permissions listed in [Deploy the Roles Manually](#management-account-role).
|
||||
- Double-check the Role ARN format and account ID for typos.
|
||||
- Retry — the role can take up to **60 seconds** to propagate, and a second attempt often succeeds. For very large organizations (500+ accounts), allow extra time for discovery.
|
||||
|
||||
### Authentication Timed Out
|
||||
### Invalid Organization ID or Role ARN
|
||||
|
||||
*"Authentication timed out"*
|
||||
*"Must be a valid AWS Organization ID"* or *"Must be a valid IAM Role ARN"*
|
||||
|
||||
- Retry the authentication step — the second attempt often succeeds
|
||||
- Check for AWS API rate limiting on the Organizations service
|
||||
- For very large organizations (500+ accounts), allow extra time for discovery
|
||||
|
||||
### Connection Test Fails for All Accounts
|
||||
|
||||
No accounts pass the connection test.
|
||||
|
||||
- Verify the CloudFormation StackSet was deployed — complete [Step 2](#step-2-deploy-the-cloudformation-stackset) and wait for stack instances to reach **CREATE_COMPLETE**
|
||||
- Check that the **ExternalId** parameter in the StackSet matches the External ID shown in the Prowler wizard
|
||||
- If your accounts use IP-based IAM policies, allow [Prowler Cloud egress IPs](/security/networking)
|
||||
|
||||
### Connection Test Fails for Some Accounts
|
||||
|
||||
Some accounts show a red icon while others pass.
|
||||
|
||||
- Expand the StackSet deployment to include the OUs containing the failing accounts
|
||||
- Suspended accounts cannot be scanned — deselect them and proceed
|
||||
- Ensure the [STS regional endpoint](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_enable-regions.html) is enabled in the account's region
|
||||
- After fixing, click **Test Connections** — only the failed accounts will be re-tested
|
||||
|
||||
### No Accounts Connected Successfully
|
||||
|
||||
*"No accounts connected successfully. Fix the connection errors and retry before launching scans."*
|
||||
|
||||
- Hover over the red icon on each account to see the specific error
|
||||
- Fix the underlying issues using the guidance above
|
||||
- Click **Test Connections** to retry
|
||||
- Organization ID format: `o-` followed by 10–32 lowercase alphanumeric characters (e.g., `o-abc123def4`).
|
||||
- Role ARN format: `arn:aws:iam::<12-digit-account-id>:role/ProwlerScan`.
|
||||
- Copy both directly from the AWS Console to avoid typos.
|
||||
|
||||
### Failed to Apply Discovery
|
||||
|
||||
*"Failed to apply discovery"*
|
||||
|
||||
- Check the `blocked_reasons` field for any blocked accounts
|
||||
- Retry the operation
|
||||
- If the error persists, contact [Prowler Support](mailto:support@prowler.com)
|
||||
- Check the `blocked_reasons` field for any blocked accounts and retry the operation.
|
||||
- If the error persists, contact [Prowler Support](mailto:support@prowler.com).
|
||||
|
||||
## Deploy the Roles Manually
|
||||
|
||||
The wizard's **Create Stack** button is the fastest path, but you can create both roles yourself — for example with Terraform or your own CloudFormation — and paste the management account Role ARN into [Step 2](#step-2-authenticate-with-your-management-account). Both roles must be named `ProwlerScan`, since Prowler expects a consistent role name across all accounts.
|
||||
|
||||
<Note>
|
||||
**Prefer Terraform?** You can deploy the ProwlerScan role across the organization with Terraform instead of CloudFormation. See the [StackSets deployment guide](/user-guide/providers/aws/organizations#deploying-prowler-iam-roles-across-aws-organizations) for the module.
|
||||
</Note>
|
||||
|
||||
### Management Account Role
|
||||
|
||||
The management account role lets Prowler discover your Organization structure — listing accounts, OUs, and hierarchy — and scan the management account itself. StackSets with service-managed permissions do not deploy to the management account, so this role is always created separately from the member-account StackSet.
|
||||
|
||||
1. Sign in to the [AWS IAM Console](https://console.aws.amazon.com/iam/) in your **management account** (or delegated administrator account).
|
||||
2. Go to **Roles > Create role** and select **Custom trust policy**.
|
||||
3. Paste the following trust policy, replacing `<YOUR_EXTERNAL_ID>` with the External ID shown in the Prowler wizard:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": "arn:aws:iam::232136659152:root"
|
||||
},
|
||||
"Action": "sts:AssumeRole",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"sts:ExternalId": "<YOUR_EXTERNAL_ID>"
|
||||
},
|
||||
"StringLike": {
|
||||
"aws:PrincipalArn": "arn:aws:iam::232136659152:role/prowler*"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
4. Attach the AWS managed policies **SecurityAudit** and **ViewOnlyAccess** so Prowler can scan the management account for security findings.
|
||||
5. Add an inline policy with the Organizations discovery permissions:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "ProwlerOrganizationDiscovery",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"organizations:DescribeAccount",
|
||||
"organizations:DescribeOrganization",
|
||||
"organizations:ListAccounts",
|
||||
"organizations:ListAccountsForParent",
|
||||
"organizations:ListOrganizationalUnitsForParent",
|
||||
"organizations:ListRoots",
|
||||
"organizations:ListTagsForResource"
|
||||
],
|
||||
"Resource": "*"
|
||||
},
|
||||
{
|
||||
"Sid": "ProwlerStackSetManagement",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"organizations:RegisterDelegatedAdministrator",
|
||||
"iam:CreateServiceLinkedRole"
|
||||
],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
<Tip>
|
||||
You can restrict the `Resource` field to your specific Organization ARN (e.g., `arn:aws:organizations::123456789012:organization/o-abc123def4`) instead of `"*"` to minimize the blast radius.
|
||||
</Tip>
|
||||
|
||||
6. Name the role **`ProwlerScan`** and click **Create role**. The ARN follows the format `arn:aws:iam::<account-id>:role/ProwlerScan` — paste it into the wizard.
|
||||
|
||||
### Member Account Role (StackSet)
|
||||
|
||||
Deploy the ProwlerScan role to every member account with a [CloudFormation StackSet](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/what-is-cfnstacksets.html), so you don't create the role manually in each account.
|
||||
|
||||
<Note>
|
||||
**Trusted access required.** CloudFormation StackSets must have trusted access enabled in your management account. Verify this under **AWS Organizations > Settings > Trusted access for AWS CloudFormation StackSets**.
|
||||
</Note>
|
||||
|
||||
1. In your management account, navigate to **CloudFormation > StackSets > Create StackSet** ([open directly](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacksets/create)).
|
||||
2. Choose **Service-managed permissions** so AWS Organizations deploys the role automatically across current and future member accounts.
|
||||
3. Select **Amazon S3 URL** as the template source and paste:
|
||||
```
|
||||
https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml
|
||||
```
|
||||
4. Set the **ExternalId** parameter to the External ID shown in the Prowler wizard.
|
||||
5. Choose your deployment targets (entire organization or specific OUs) and regions, then click **Create StackSet**.
|
||||
6. Open the **Stack instances** tab and confirm every instance shows **Status: CURRENT** and **Stack status: CREATE_COMPLETE**. Deployment typically takes **2–5 minutes**; large organizations (500+ accounts) may take longer.
|
||||
|
||||
The StackSet role uses read-only access only (`SecurityAudit`, `ViewOnlyAccess`, plus a small set of additional read-only permissions). Prowler makes no changes to your accounts. See the [CloudFormation template](https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml) for the full list. When you add new accounts under the targeted OU or root, the StackSet deploys the role automatically, and Prowler's 6-hour sync onboards them end-to-end.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### What Is an External ID?
|
||||
|
||||
An **External ID** is a security token that Prowler generates unique to your tenant. When Prowler assumes the IAM role in your AWS account, it presents this External ID to prove its identity.
|
||||
|
||||
This prevents the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html) — a scenario where an unauthorized party could trick AWS into granting access to your account. By requiring the External ID, only your specific Prowler tenant can assume the role. Prowler generates it automatically and displays it in the wizard for you to copy.
|
||||
|
||||
### Two Roles Architecture
|
||||
|
||||
Prowler uses **two IAM roles**, both named `ProwlerScan` but deployed in different places:
|
||||
|
||||
| Role | Where it lives | What it does |
|
||||
|------|---------------|--------------|
|
||||
| **ProwlerScan** (management account) | Your management (or delegated administrator) account | Discovers the Organization structure **and** scans that account. Includes additional Organizations discovery permissions. |
|
||||
| **ProwlerScan** (member accounts) | Every member account | Scans the account for security findings. |
|
||||
|
||||
Both roles share the name `ProwlerScan` because Prowler expects a consistent role name across all accounts. The single CloudFormation stack in [Step 2](#step-2-authenticate-with-your-management-account) deploys both at once.
|
||||
|
||||
<Frame caption="Both roles share the same name `ProwlerScan`. The management account role includes additional Organization discovery permissions.">
|
||||
<img src="/images/organizations/two-roles-architecture.svg" alt="Two Roles Architecture: ProwlerScan in management account (discovery + scanning) and ProwlerScan in member accounts (via StackSet, scanning only)" />
|
||||
</Frame>
|
||||
|
||||
### What Is a CloudFormation StackSet?
|
||||
|
||||
A [CloudFormation StackSet](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/what-is-cfnstacksets.html) deploys the same CloudFormation template across multiple AWS accounts in a single operation. Prowler uses a service-managed StackSet to deploy the **ProwlerScan** IAM role into every member account of your organization, so you don't create the role manually in each account. StackSets do not deploy to the management account, which is why that role is created separately.
|
||||
|
||||
## What's Next
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
PROWLER_APP_API_KEY="pk_your_api_key_here"
|
||||
PROWLER_API_KEY="pk_your_api_key_here"
|
||||
API_BASE_URL="https://api.prowler.com/api/v1"
|
||||
PROWLER_MCP_TRANSPORT_MODE="stdio"
|
||||
|
||||
@@ -25,7 +25,7 @@ The Prowler MCP Server provides AI agents access to the Prowler ecosystem throug
|
||||
## CRITICAL RULES
|
||||
|
||||
### Tool Implementation
|
||||
- ALWAYS: Extend `BaseTool` ABC for Prowler App tools (auto-registration)
|
||||
- ALWAYS: Extend `BaseTool` ABC for Prowler tools (auto-registration)
|
||||
- ALWAYS: Use `@mcp.tool()` decorator for Hub/Docs tools
|
||||
- NEVER: Manually register BaseTool subclasses
|
||||
- NEVER: Import tools directly in server.py
|
||||
@@ -56,7 +56,7 @@ await prowler_mcp_server.import_server(docs_mcp_server, prefix="prowler_docs")
|
||||
### Tool Naming
|
||||
- `prowler_hub_*` - Catalog and compliance (no auth)
|
||||
- `prowler_docs_*` - Documentation search (no auth)
|
||||
- `prowler_app_*` - Cloud/App management (auth required)
|
||||
- `prowler_*` - Prowler Cloud, Private Cloud & Local Server management (auth required)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [0.8.0] (Prowler v5.35.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Core Prowler tool namespace from the `prowler_app_*` prefix to `prowler_*` [(#12017)](https://github.com/prowler-cloud/prowler/pull/12017)
|
||||
|
||||
---
|
||||
|
||||
## [0.7.2] (Prowler v5.28.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
## Key Capabilities
|
||||
|
||||
### Prowler Cloud and Prowler App (Self-Managed)
|
||||
### Prowler Cloud, Prowler Private Cloud & Prowler Local Server
|
||||
|
||||
Full access to Prowler Cloud platform and self-managed Prowler App for:
|
||||
Full access to your Prowler data (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server) for:
|
||||
- **Findings Analysis**: Query, filter, and analyze security findings across all your cloud environments
|
||||
- **Finding Groups Analysis**: Triage findings grouped by check ID and drill down into affected resources
|
||||
- **Provider Management**: Create, configure, and manage your configured Prowler providers (AWS, Azure, GCP, etc.)
|
||||
@@ -49,7 +49,7 @@ For comprehensive guides and tutorials, see the official documentation:
|
||||
|
||||
Prowler MCP Server can be used in three ways:
|
||||
|
||||
### 1. Prowler Cloud MCP Server (Recommended)
|
||||
### 1. Hosted Prowler MCP (Recommended)
|
||||
|
||||
**Use Prowler's managed MCP server at `https://mcp.prowler.com/mcp`**
|
||||
|
||||
@@ -126,7 +126,7 @@ For complete tool descriptions and parameters, see the [Tools Reference](https:/
|
||||
### Tool Naming Convention
|
||||
|
||||
All tools follow a consistent naming pattern with prefixes:
|
||||
- `prowler_app_*` - Prowler Cloud and App (Self-Managed) management tools
|
||||
- `prowler_*` - Prowler Cloud, Prowler Private Cloud & Prowler Local Server management tools
|
||||
- `prowler_hub_*` - Prowler Hub catalog and compliance tools
|
||||
- `prowler_docs_*` - Prowler documentation search and retrieval
|
||||
|
||||
@@ -146,7 +146,7 @@ prowler_mcp_server/
|
||||
|
||||
**Key Features:**
|
||||
- **Modular Design**: Three independent sub-servers with prefixed namespacing
|
||||
- **Auto-Discovery**: Prowler App tools are automatically discovered and registered
|
||||
- **Auto-Discovery**: Prowler tools are automatically discovered and registered
|
||||
- **LLM Optimization**: Response models minimize token usage by excluding empty values
|
||||
- **Dual Transport**: Supports both STDIO (local) and HTTP (remote) modes
|
||||
|
||||
@@ -174,17 +174,17 @@ The Prowler MCP Server enables powerful workflows through AI assistants:
|
||||
|
||||
## Requirements
|
||||
|
||||
**For Prowler Cloud MCP Server:**
|
||||
- Prowler Cloud account and API key (only for Prowler Cloud/App features)
|
||||
**For the hosted Prowler MCP:**
|
||||
- Prowler Cloud account and API key (only for Prowler features)
|
||||
|
||||
**For self-hosted STDIO/HTTP Mode:**
|
||||
- Python 3.12+ or Docker
|
||||
- Network access to:
|
||||
- `https://hub.prowler.com` (for Prowler Hub)
|
||||
- `https://docs.prowler.com` (for Prowler Documentation)
|
||||
- Prowler Cloud API or self-hosted Prowler App API (for Prowler Cloud/App features)
|
||||
- Prowler Cloud API or Prowler Local Server API (for Prowler features)
|
||||
|
||||
> **No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication. A Prowler API key is only required to access Prowler Cloud or Prowler App (Self-Managed) features.
|
||||
> **No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication. A Prowler API key is only required to access Prowler features (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server).
|
||||
|
||||
## Configuring MCP Hosts
|
||||
|
||||
@@ -200,7 +200,7 @@ For developers looking to extend the MCP server with new tools or features:
|
||||
## Related Products
|
||||
|
||||
- **[Prowler Hub](https://hub.prowler.com)**: Browse security checks and compliance frameworks
|
||||
- **[Prowler Cloud](https://cloud.prowler.com)**: Managed Prowler platform
|
||||
- **[Prowler Cloud](https://cloud.prowler.com)**: Fully managed Prowler in the cloud
|
||||
- **[Lighthouse AI](https://docs.prowler.com/getting-started/products/prowler-lighthouse-ai)**: AI security analyst
|
||||
|
||||
## License
|
||||
|
||||
@@ -5,7 +5,7 @@ This package provides MCP tools for accessing:
|
||||
- Prowler Hub: All security artifacts (detections, remediations and frameworks) supported by Prowler
|
||||
"""
|
||||
|
||||
__version__ = "0.5.0"
|
||||
__version__ = "0.8.0"
|
||||
__author__ = "Prowler Team"
|
||||
__email__ = "engineering@prowler.com"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Pydantic models for Prowler App MCP Server."""
|
||||
"""Pydantic models for Prowler MCP Server."""
|
||||
|
||||
from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
|
||||
from prowler_mcp_server.prowler_app.models.findings import (
|
||||
|
||||
@@ -228,7 +228,7 @@ class FindingGroupResource(MinimalSerializerMixin):
|
||||
resource: FindingGroupResourceInfo = Field(description="Affected resource")
|
||||
provider: FindingGroupProviderInfo = Field(description="Affected provider")
|
||||
finding_id: str = Field(
|
||||
description="Finding UUID to use with prowler_app_get_finding_details"
|
||||
description="Finding UUID to use with prowler_get_finding_details"
|
||||
)
|
||||
status: FindingStatus = Field(description="Finding status for this resource")
|
||||
severity: FindingSeverity = Field(description="Finding severity")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Domain-specific tools for Prowler App MCP Server.
|
||||
"""Domain-specific tools for Prowler MCP Server.
|
||||
|
||||
Each module in this package contains a BaseTool subclass that registers
|
||||
and implements tools for a specific domain (findings, providers, scans, etc.).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Attack Paths tools for Prowler App MCP Server.
|
||||
"""Attack Paths tools for Prowler MCP Server.
|
||||
|
||||
This module provides tools for analyzing Attack Paths data from Neo4j graph database.
|
||||
Attack Paths help identify security risks by tracing potential attack vectors
|
||||
@@ -22,16 +22,16 @@ class AttackPathsTools(BaseTool):
|
||||
"""Tools for Attack Paths analysis.
|
||||
|
||||
Provides tools for:
|
||||
- prowler_app_list_attack_paths_scans: Find completed scans ready for analysis
|
||||
- prowler_app_list_attack_paths_queries: Discover available queries for a scan
|
||||
- prowler_app_run_attack_paths_query: Execute query and analyze attack paths
|
||||
- prowler_list_attack_paths_scans: Find completed scans ready for analysis
|
||||
- prowler_list_attack_paths_queries: Discover available queries for a scan
|
||||
- prowler_run_attack_paths_query: Execute query and analyze attack paths
|
||||
"""
|
||||
|
||||
async def list_attack_paths_scans(
|
||||
self,
|
||||
provider_id: list[str] = Field(
|
||||
default=[],
|
||||
description="Filter by Prowler's internal UUID(s) (v4) for specific provider(s). Use `prowler_app_search_providers` tool to find provider IDs",
|
||||
description="Filter by Prowler's internal UUID(s) (v4) for specific provider(s). Use `prowler_search_providers` tool to find provider IDs",
|
||||
),
|
||||
provider_type: list[str] = Field(
|
||||
default=[],
|
||||
@@ -73,8 +73,8 @@ class AttackPathsTools(BaseTool):
|
||||
|
||||
Workflow:
|
||||
1. Use this tool to find completed attack paths scans
|
||||
2. Use prowler_app_list_attack_paths_queries to see available queries for a scan
|
||||
3. Use prowler_app_run_attack_paths_query to execute analysis
|
||||
2. Use prowler_list_attack_paths_queries to see available queries for a scan
|
||||
3. Use prowler_run_attack_paths_query to execute analysis
|
||||
"""
|
||||
try:
|
||||
# Validate pagination
|
||||
@@ -113,7 +113,7 @@ class AttackPathsTools(BaseTool):
|
||||
async def list_attack_paths_queries(
|
||||
self,
|
||||
scan_id: str = Field(
|
||||
description="UUID of a COMPLETED attack paths scan. Use `prowler_app_list_attack_paths_scans` with state=['completed'] to find scan IDs"
|
||||
description="UUID of a COMPLETED attack paths scan. Use `prowler_list_attack_paths_scans` with state=['completed'] to find scan IDs"
|
||||
),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Discover available Attack Paths queries for a completed scan.
|
||||
@@ -133,9 +133,9 @@ class AttackPathsTools(BaseTool):
|
||||
- aws-ec2-instances-internet-exposed: Find internet-exposed EC2 instances
|
||||
|
||||
Workflow:
|
||||
1. Use prowler_app_list_attack_paths_scans to find a completed scan
|
||||
1. Use prowler_list_attack_paths_scans to find a completed scan
|
||||
2. Use this tool to discover available queries
|
||||
3. Use prowler_app_run_attack_paths_query with query_id and any required parameters
|
||||
3. Use prowler_run_attack_paths_query with query_id and any required parameters
|
||||
"""
|
||||
try:
|
||||
api_response = await self.api_client.get(
|
||||
@@ -158,7 +158,7 @@ class AttackPathsTools(BaseTool):
|
||||
description="UUID of a COMPLETED attack paths scan. The scan must be in 'completed' state"
|
||||
),
|
||||
query_id: str = Field(
|
||||
description="Query ID to execute (e.g., 'aws-internet-exposed-ec2-sensitive-s3-access'). Use `prowler_app_list_attack_paths_queries` to discover available queries"
|
||||
description="Query ID to execute (e.g., 'aws-internet-exposed-ec2-sensitive-s3-access'). Use `prowler_list_attack_paths_queries` to discover available queries"
|
||||
),
|
||||
parameters: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
@@ -194,7 +194,7 @@ class AttackPathsTools(BaseTool):
|
||||
|
||||
Workflow:
|
||||
1. Ensure scan is completed
|
||||
2. List available queries (use prowler_app_list_attack_paths_queries)
|
||||
2. List available queries (use prowler_list_attack_paths_queries)
|
||||
3. Execute this tool with appropriate parameters
|
||||
4. Analyze the returned graph for security insights
|
||||
"""
|
||||
@@ -231,7 +231,7 @@ class AttackPathsTools(BaseTool):
|
||||
async def get_attack_paths_cartography_schema(
|
||||
self,
|
||||
scan_id: str = Field(
|
||||
description="UUID of a COMPLETED attack paths scan. Use `prowler_app_list_attack_paths_scans` with state=['completed'] to find scan IDs"
|
||||
description="UUID of a COMPLETED attack paths scan. Use `prowler_list_attack_paths_scans` with state=['completed'] to find scan IDs"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""Retrieve the Cartography graph schema for a completed attack paths scan.
|
||||
@@ -253,10 +253,10 @@ class AttackPathsTools(BaseTool):
|
||||
- schema_content: Full Cartography schema markdown with node/relationship definitions
|
||||
|
||||
Workflow:
|
||||
1. Use prowler_app_list_attack_paths_scans to find a completed scan
|
||||
1. Use prowler_list_attack_paths_scans to find a completed scan
|
||||
2. Use this tool to get the schema for the scan's provider
|
||||
3. Use the schema to craft custom openCypher queries
|
||||
4. Execute queries with prowler_app_run_attack_paths_query
|
||||
4. Execute queries with prowler_run_attack_paths_query
|
||||
"""
|
||||
try:
|
||||
api_response = await self.api_client.get(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Compliance framework tools for Prowler App MCP Server.
|
||||
"""Compliance framework tools for Prowler MCP Server.
|
||||
|
||||
This module provides tools for viewing compliance status and requirement details
|
||||
across all cloud providers.
|
||||
@@ -50,7 +50,7 @@ class ComplianceTools(BaseTool):
|
||||
if not scans_data:
|
||||
raise ValueError(
|
||||
f"No completed scans found for provider {provider_id}. "
|
||||
"Run a scan first using prowler_app_trigger_scan."
|
||||
"Run a scan first using prowler_trigger_scan."
|
||||
)
|
||||
|
||||
scan_id = scans_data[0]["id"]
|
||||
@@ -60,11 +60,11 @@ class ComplianceTools(BaseTool):
|
||||
self,
|
||||
scan_id: str | None = Field(
|
||||
default=None,
|
||||
description="UUID of a specific scan to get compliance data for. Required if provider_id is not specified. Use `prowler_app_list_scans` to find scan IDs.",
|
||||
description="UUID of a specific scan to get compliance data for. Required if provider_id is not specified. Use `prowler_list_scans` to find scan IDs.",
|
||||
),
|
||||
provider_id: str | None = Field(
|
||||
default=None,
|
||||
description="Prowler's internal UUID (v4) for a specific provider. If provided without scan_id, the tool will automatically find the latest completed scan for this provider. Use `prowler_app_search_providers` tool to find provider IDs.",
|
||||
description="Prowler's internal UUID (v4) for a specific provider. If provided without scan_id, the tool will automatically find the latest completed scan for this provider. Use `prowler_search_providers` tool to find provider IDs.",
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""Get high-level compliance overview across all frameworks for a specific scan.
|
||||
@@ -90,11 +90,11 @@ class ComplianceTools(BaseTool):
|
||||
|
||||
Workflow:
|
||||
1. Use this tool to get an overview of all compliance frameworks
|
||||
2. Use prowler_app_get_compliance_framework_state_details with a specific compliance_id to see which requirements failed
|
||||
2. Use prowler_get_compliance_framework_state_details with a specific compliance_id to see which requirements failed
|
||||
"""
|
||||
if not scan_id and not provider_id:
|
||||
return {
|
||||
"error": "Either scan_id or provider_id must be provided. Use prowler_app_search_providers to find provider IDs or prowler_app_list_scans to find scan IDs."
|
||||
"error": "Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs."
|
||||
}
|
||||
elif scan_id and provider_id:
|
||||
return {
|
||||
@@ -254,7 +254,7 @@ class ComplianceTools(BaseTool):
|
||||
async def get_compliance_framework_state_details(
|
||||
self,
|
||||
compliance_id: str = Field(
|
||||
description="Compliance framework ID to get details for (e.g., 'cis_1.5_aws', 'pci_dss_v4.0_aws'). You can get compliance IDs from prowler_app_get_compliance_overview or consulting Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server",
|
||||
description="Compliance framework ID to get details for (e.g., 'cis_1.5_aws', 'pci_dss_v4.0_aws'). You can get compliance IDs from prowler_get_compliance_overview or consulting Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server",
|
||||
),
|
||||
scan_id: str | None = Field(
|
||||
default=None,
|
||||
@@ -262,14 +262,14 @@ class ComplianceTools(BaseTool):
|
||||
),
|
||||
provider_id: str | None = Field(
|
||||
default=None,
|
||||
description="Prowler's internal UUID (v4) for a specific provider. If provided without scan_id, the tool will automatically find the latest completed scan for this provider. Use `prowler_app_search_providers` tool to find provider IDs.",
|
||||
description="Prowler's internal UUID (v4) for a specific provider. If provided without scan_id, the tool will automatically find the latest completed scan for this provider. Use `prowler_search_providers` tool to find provider IDs.",
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""Get detailed requirement-level breakdown for a specific compliance framework.
|
||||
|
||||
IMPORTANT: This tool returns DETAILED requirement information for a single compliance framework,
|
||||
focusing on FAILED requirements and their associated FAILED finding IDs.
|
||||
Use this after prowler_app_get_compliance_overview to drill down into specific frameworks.
|
||||
Use this after prowler_get_compliance_overview to drill down into specific frameworks.
|
||||
|
||||
The markdown report includes:
|
||||
|
||||
@@ -280,7 +280,7 @@ class ComplianceTools(BaseTool):
|
||||
2. Failed Requirements Breakdown:
|
||||
- Each failed requirement's ID and description
|
||||
- Associated failed finding IDs for each failed requirement
|
||||
- Use prowler_app_get_finding_details with these finding IDs for more details and remediation guidance
|
||||
- Use prowler_get_finding_details with these finding IDs for more details and remediation guidance
|
||||
|
||||
Default behavior:
|
||||
- Requires either scan_id OR provider_id
|
||||
@@ -289,14 +289,14 @@ class ComplianceTools(BaseTool):
|
||||
- Only shows failed requirements with their associated failed finding IDs
|
||||
|
||||
Workflow:
|
||||
1. Use prowler_app_get_compliance_overview to identify frameworks with failures
|
||||
1. Use prowler_get_compliance_overview to identify frameworks with failures
|
||||
2. Use this tool with the compliance_id to see failed requirements and their finding IDs
|
||||
3. Use prowler_app_get_finding_details with the finding IDs to get remediation guidance
|
||||
3. Use prowler_get_finding_details with the finding IDs to get remediation guidance
|
||||
"""
|
||||
# Validate that either scan_id or provider_id is provided
|
||||
if not scan_id and not provider_id:
|
||||
return {
|
||||
"error": "Either scan_id or provider_id must be provided. Use prowler_app_search_providers to find provider IDs or prowler_app_list_scans to find scan IDs."
|
||||
"error": "Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs."
|
||||
}
|
||||
|
||||
# Resolve provider_id to latest scan_id if needed
|
||||
@@ -395,7 +395,7 @@ class ComplianceTools(BaseTool):
|
||||
report_lines.append("**Failed Finding IDs**: None found")
|
||||
report_lines.append("")
|
||||
report_lines.append(
|
||||
"*Use `prowler_app_get_finding_details` with these finding IDs to get remediation guidance.*"
|
||||
"*Use `prowler_get_finding_details` with these finding IDs to get remediation guidance.*"
|
||||
)
|
||||
report_lines.append("")
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Finding Groups tools for Prowler App MCP Server.
|
||||
"""Finding Groups tools for Prowler MCP Server.
|
||||
|
||||
This module provides read-only tools for finding group triage and drill-downs.
|
||||
"""
|
||||
@@ -233,8 +233,8 @@ class FindingGroupsTools(BaseTool):
|
||||
`date_to`, this uses `/finding-groups` with a maximum 2-day date window.
|
||||
|
||||
Use this tool to find noisy or high-impact checks, then call
|
||||
prowler_app_get_finding_group_details for complete counters or
|
||||
prowler_app_list_finding_group_resources to drill into affected resources.
|
||||
prowler_get_finding_group_details for complete counters or
|
||||
prowler_list_finding_group_resources to drill into affected resources.
|
||||
"""
|
||||
try:
|
||||
self.api_client.validate_page_size(page_size)
|
||||
@@ -423,7 +423,7 @@ class FindingGroupsTools(BaseTool):
|
||||
Default behavior returns FAIL, unmuted resources so the result is
|
||||
actionable. Set `include_muted=True` to include accepted/suppressed
|
||||
resources too. Each row includes nested resource and provider data plus
|
||||
`finding_id`. Use `prowler_app_get_finding_details(finding_id)` to
|
||||
`finding_id`. Use `prowler_get_finding_details(finding_id)` to
|
||||
retrieve complete remediation guidance for a specific resource finding.
|
||||
"""
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Security Findings tools for Prowler App MCP Server.
|
||||
"""Security Findings tools for Prowler MCP Server.
|
||||
|
||||
This module provides tools for searching, viewing, and analyzing security findings
|
||||
across all cloud providers.
|
||||
@@ -92,7 +92,7 @@ class FindingsTools(BaseTool):
|
||||
"""Search and filter security findings across all cloud providers with rich filtering capabilities.
|
||||
|
||||
IMPORTANT: This tool returns LIGHTWEIGHT findings. Use this for fast searching and filtering across many findings.
|
||||
For complete details use prowler_app_get_finding_details on specific findings.
|
||||
For complete details use prowler_get_finding_details on specific findings.
|
||||
|
||||
Default behavior:
|
||||
- Returns latest findings from most recent scans (no date parameters needed)
|
||||
@@ -111,7 +111,7 @@ class FindingsTools(BaseTool):
|
||||
|
||||
Workflow:
|
||||
1. Use this tool to search and filter findings by severity, status, provider, service, region, etc.
|
||||
2. Use prowler_app_get_finding_details with the finding 'id' to get complete information about the finding
|
||||
2. Use prowler_get_finding_details with the finding 'id' to get complete information about the finding
|
||||
"""
|
||||
# Validate page_size parameter
|
||||
self.api_client.validate_page_size(page_size)
|
||||
@@ -187,9 +187,9 @@ class FindingsTools(BaseTool):
|
||||
"""Retrieve comprehensive details about a specific security finding by its ID.
|
||||
|
||||
IMPORTANT: This tool returns COMPLETE finding details.
|
||||
Use this after finding a specific finding via prowler_app_search_security_findings
|
||||
Use this after finding a specific finding via prowler_search_security_findings
|
||||
|
||||
This tool provides ALL information that prowler_app_search_security_findings returns PLUS:
|
||||
This tool provides ALL information that prowler_search_security_findings returns PLUS:
|
||||
|
||||
1. Check Metadata (information about the check script that generated the finding):
|
||||
- title: Human-readable phrase used to summarize the check
|
||||
@@ -217,7 +217,7 @@ class FindingsTools(BaseTool):
|
||||
- resource_ids: List of UUIDs for cloud resources associated with this finding
|
||||
|
||||
Workflow:
|
||||
1. Use prowler_app_search_security_findings to browse and filter findings
|
||||
1. Use prowler_search_security_findings to browse and filter findings
|
||||
2. Use this tool with the finding 'id' to get remediation guidance and complete context
|
||||
"""
|
||||
params = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Muting tools for Prowler App MCP Server.
|
||||
"""Muting tools for Prowler MCP Server.
|
||||
|
||||
This module provides tools for managing finding muting in Prowler, including:
|
||||
- Mutelist management (pattern-based bulk muting)
|
||||
@@ -43,7 +43,7 @@ class MutingTools(BaseTool):
|
||||
Workflow:
|
||||
1. Use this tool to check if a mutelist is configured
|
||||
2. Examine current muting patterns before making updates
|
||||
3. Use prowler_app_set_mutelist to create or update the configuration
|
||||
3. Use prowler_set_mutelist to create or update the configuration
|
||||
"""
|
||||
self.logger.info("Retrieving mutelist configuration...")
|
||||
|
||||
@@ -61,7 +61,7 @@ class MutingTools(BaseTool):
|
||||
if len(data) == 0:
|
||||
return {
|
||||
"error": "No mutelist found",
|
||||
"message": "No mutelist configuration exists for this tenant. Use prowler_app_set_mutelist to create one.",
|
||||
"message": "No mutelist configuration exists for this tenant. Use prowler_set_mutelist to create one.",
|
||||
}
|
||||
|
||||
# Return the first (and only) mutelist
|
||||
@@ -116,10 +116,10 @@ Structure:
|
||||
- Exceptions: Accounts, Regions, Resources to exclude from muting
|
||||
|
||||
Workflow:
|
||||
1. Use prowler_app_get_mutelist to check existing configuration
|
||||
1. Use prowler_get_mutelist to check existing configuration
|
||||
2. Build configuration object following Prowler mutelist format
|
||||
3. Use this tool to create or update the mutelist
|
||||
4. Verify with prowler_app_get_mutelist
|
||||
4. Verify with prowler_get_mutelist
|
||||
"""
|
||||
self.logger.info("Setting mutelist configuration...")
|
||||
|
||||
@@ -171,12 +171,12 @@ Structure:
|
||||
"""Remove the mutelist configuration from the tenant.
|
||||
|
||||
WARNING: This is a destructive operation that cannot be undone.
|
||||
- The mutelist will need to be re-created with prowler_app_set_mutelist
|
||||
- The mutelist will need to be re-created with prowler_set_mutelist
|
||||
- New findings from future scans will NOT be muted by the deleted mutelist
|
||||
- Previously muted findings remain muted (deletion doesn't un-mute them)
|
||||
|
||||
Workflow:
|
||||
1. Use prowler_app_get_mutelist to confirm what will be deleted
|
||||
1. Use prowler_get_mutelist to confirm what will be deleted
|
||||
2. Use this tool to permanently remove the mutelist
|
||||
3. New scans will no longer apply mutelist-based muting
|
||||
"""
|
||||
@@ -229,7 +229,7 @@ Structure:
|
||||
"""Search and filter mute rules with pagination support.
|
||||
|
||||
IMPORTANT: This tool returns LIGHTWEIGHT mute rules without the full list of finding UIDs.
|
||||
Use prowler_app_get_mute_rule to get complete details including all finding UIDs and creator information.
|
||||
Use prowler_get_mute_rule to get complete details including all finding UIDs and creator information.
|
||||
|
||||
Default behavior:
|
||||
- Returns all mute rules (both enabled and disabled)
|
||||
@@ -237,15 +237,15 @@ Structure:
|
||||
- Includes basic rule information without full finding UID lists
|
||||
|
||||
Each mute rule includes:
|
||||
- Core identification: id (UUID for prowler_app_get_mute_rule), name
|
||||
- Core identification: id (UUID for prowler_get_mute_rule), name
|
||||
- Contextual information: reason, enabled status
|
||||
- State tracking: finding_count (number of findings currently muted)
|
||||
- Temporal data: inserted_at, updated_at timestamps
|
||||
|
||||
Workflow:
|
||||
1. Use this tool to search and filter mute rules by name, enabled status, or keywords
|
||||
2. Use prowler_app_get_mute_rule with the mute rule 'id' to get complete details including all finding UIDs
|
||||
3. Use prowler_app_update_mute_rule or prowler_app_delete_mute_rule to modify rules
|
||||
2. Use prowler_get_mute_rule with the mute rule 'id' to get complete details including all finding UIDs
|
||||
3. Use prowler_update_mute_rule or prowler_delete_mute_rule to modify rules
|
||||
"""
|
||||
self.logger.info("Listing mute rules...")
|
||||
self.api_client.validate_page_size(page_size)
|
||||
@@ -289,17 +289,17 @@ Structure:
|
||||
"""Retrieve comprehensive details about a specific mute rule by its ID.
|
||||
|
||||
IMPORTANT: This tool returns COMPLETE mute rule details including the full list of finding UIDs.
|
||||
Use this after finding a rule via prowler_app_list_mute_rules.
|
||||
Use this after finding a rule via prowler_list_mute_rules.
|
||||
|
||||
This tool provides ALL information that prowler_app_list_mute_rules returns PLUS:
|
||||
This tool provides ALL information that prowler_list_mute_rules returns PLUS:
|
||||
- finding_uids: Complete list of finding UIDs that are muted by this rule
|
||||
- user_creator_id: UUID of the user who created the rule (audit trail)
|
||||
|
||||
Workflow:
|
||||
1. Use prowler_app_list_mute_rules to find rules by name or filter criteria
|
||||
1. Use prowler_list_mute_rules to find rules by name or filter criteria
|
||||
2. Use this tool with the rule 'id' to get complete details
|
||||
3. Examine finding_uids list to understand which findings are muted
|
||||
4. Use prowler_app_update_mute_rule or prowler_app_delete_mute_rule to modify if needed
|
||||
4. Use prowler_update_mute_rule or prowler_delete_mute_rule to modify if needed
|
||||
"""
|
||||
self.logger.info(f"Retrieving mute rule {rule_id}...")
|
||||
|
||||
@@ -323,7 +323,7 @@ Structure:
|
||||
description="Reason for muting these findings. Document why this security issue is acceptable or intentional (e.g., 'Development environment with controlled access', 'Legacy application requires IMDSv1')."
|
||||
),
|
||||
finding_ids: list[str] = Field(
|
||||
description="List of finding IDs (UUIDs) to mute. Get these from the prowler_app_search_security_findings tool. Must provide at least 1 finding ID."
|
||||
description="List of finding IDs (UUIDs) to mute. Get these from the prowler_search_security_findings tool. Must provide at least 1 finding ID."
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new mute rule to mute specific findings with documentation and audit trail.
|
||||
@@ -337,15 +337,15 @@ Structure:
|
||||
- Records creator for audit trail
|
||||
|
||||
The mute rule includes:
|
||||
- Core identification: id (UUID for prowler_app_get_mute_rule), name, reason
|
||||
- Core identification: id (UUID for prowler_get_mute_rule), name, reason
|
||||
- Configuration: enabled status, finding_uids list
|
||||
- Audit trail: user_creator_id (UUID of the Prowler user from the tenant that created the rule), timestamps when the rule was created and last modified
|
||||
|
||||
Workflow:
|
||||
1. Use prowler_app_search_security_findings to identify findings to mute
|
||||
1. Use prowler_search_security_findings to identify findings to mute
|
||||
2. Use this tool with finding IDs, descriptive name, and documented reason
|
||||
3. Verify with prowler_app_get_mute_rule to confirm rule creation
|
||||
4. Check findings are muted with prowler_app_search_security_findings (filter by muted=true)
|
||||
3. Verify with prowler_get_mute_rule to confirm rule creation
|
||||
4. Check findings are muted with prowler_search_security_findings (filter by muted=true)
|
||||
"""
|
||||
self.logger.info(f"Creating mute rule '{name}'...")
|
||||
|
||||
@@ -399,9 +399,9 @@ Structure:
|
||||
- enabled: Toggle rule active status (doesn't affect already-muted findings)
|
||||
|
||||
Workflow:
|
||||
1. Use prowler_app_get_mute_rule to see current rule state
|
||||
1. Use prowler_get_mute_rule to see current rule state
|
||||
2. Use this tool to update name, reason, or enabled status
|
||||
3. Verify changes with prowler_app_get_mute_rule
|
||||
3. Verify changes with prowler_get_mute_rule
|
||||
"""
|
||||
self.logger.info(f"Updating mute rule {rule_id}...")
|
||||
|
||||
@@ -451,9 +451,9 @@ Structure:
|
||||
- Cannot be undone - rule must be recreated to restore
|
||||
|
||||
Workflow:
|
||||
1. Use prowler_app_get_mute_rule to review what will be deleted
|
||||
1. Use prowler_get_mute_rule to review what will be deleted
|
||||
2. Use this tool to permanently remove the rule
|
||||
3. Verify deletion with prowler_app_list_mute_rules (rule should no longer appear)
|
||||
3. Verify deletion with prowler_list_mute_rules (rule should no longer appear)
|
||||
"""
|
||||
self.logger.info(f"Deleting mute rule {rule_id}...")
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Provider Management tools for Prowler App MCP Server.
|
||||
"""Provider Management tools for Prowler MCP Server.
|
||||
|
||||
This module provides tools for managing provider connections,
|
||||
including searching, connecting, and deleting providers.
|
||||
@@ -19,9 +19,9 @@ class ProvidersTools(BaseTool):
|
||||
"""Tools for provider management operations
|
||||
|
||||
Provides tools for:
|
||||
- prowler_app_search_providers: Search and view configured providers with their connection status
|
||||
- prowler_app_connect_provider: Connect or register a provider for security scanning in Prowler
|
||||
- prowler_app_delete_provider: Permanently remove a provider from Prowler
|
||||
- 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(
|
||||
@@ -145,7 +145,7 @@ class ProvidersTools(BaseTool):
|
||||
) -> dict[str, Any]:
|
||||
"""Register a provider to be scanned with Prowler.
|
||||
|
||||
This tool will register a provider in Prowler App, even if the UID is wrong.
|
||||
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.
|
||||
@@ -292,13 +292,13 @@ class ProvidersTools(BaseTool):
|
||||
async def delete_provider(
|
||||
self,
|
||||
provider_id: str = Field(
|
||||
description="Prowler's internal UUID (v4) for the provider to permanently remove, generated when the provider was registered in the system. Use `prowler_app_search_providers` tool to find the provider_id if you only know the alias or the provider's own identifier (provider_uid)"
|
||||
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_app_connect_provider if you want to scan it again.
|
||||
re-added with prowler_connect_provider if you want to scan it again.
|
||||
|
||||
The tool always returns the deletion status and message.
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Cloud Resources tools for Prowler App MCP Server.
|
||||
"""Cloud Resources tools for Prowler MCP Server.
|
||||
|
||||
This module provides tools for searching, viewing, and analyzing cloud resources
|
||||
across all providers.
|
||||
@@ -86,7 +86,7 @@ class ResourcesTools(BaseTool):
|
||||
|
||||
IMPORTANT: This tool returns LIGHTWEIGHT resource information. Use this for fast searching
|
||||
and filtering across many resources. For complete configuration details, metadata, and finding
|
||||
relationships, use prowler_app_get_resource on specific resources of interest.
|
||||
relationships, use prowler_get_resource on specific resources of interest.
|
||||
|
||||
This is the primary tool for browsing resources with rich filtering capabilities.
|
||||
Returns current state by default (latest scan per provider). Specify dates to query
|
||||
@@ -102,16 +102,16 @@ class ResourcesTools(BaseTool):
|
||||
- With dates: queries historical resource state (2-day maximum range between date_from and date_to)
|
||||
|
||||
Each resource includes:
|
||||
- Core identification: id (UUID for prowler_app_get_resource), uid, name
|
||||
- Core identification: id (UUID for prowler_get_resource), uid, name
|
||||
- Location context: region, service, type
|
||||
- Security context: failed_findings_count (number of active security issues)
|
||||
- Tags: tags associated with the resource
|
||||
|
||||
Useful Workflow:
|
||||
1. Use this tool to search and filter resources by provider, region, service, tags, etc.
|
||||
2. Use prowler_app_get_resource with the resource 'id' to get complete configuration and metadata
|
||||
3. Use prowler_app_search_security_findings to find security issues for specific resources
|
||||
4. Use prowler_app_get_finding_details to get details about the security issues for specific resources
|
||||
2. Use prowler_get_resource with the resource 'id' to get complete configuration and metadata
|
||||
3. Use prowler_search_security_findings to find security issues for specific resources
|
||||
4. Use prowler_get_finding_details to get details about the security issues for specific resources
|
||||
"""
|
||||
# Validate page_size parameter
|
||||
self.api_client.validate_page_size(page_size)
|
||||
@@ -177,15 +177,15 @@ class ResourcesTools(BaseTool):
|
||||
async def get_resource(
|
||||
self,
|
||||
resource_id: str = Field(
|
||||
description="Prowler's internal UUID (v4) for the resource to retrieve, generated when the resource was discovered in the system. Use `prowler_app_list_resources` tool to find the right ID"
|
||||
description="Prowler's internal UUID (v4) for the resource to retrieve, generated when the resource was discovered in the system. Use `prowler_list_resources` tool to find the right ID"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""Retrieve comprehensive details about a specific resource by its ID.
|
||||
|
||||
IMPORTANT: This tool provides COMPLETE resource details with all available information.
|
||||
Use this after finding a specific resource via prowler_app_list_resources.
|
||||
Use this after finding a specific resource via prowler_list_resources.
|
||||
|
||||
This tool provides ALL information that prowler_app_list_resources returns PLUS:
|
||||
This tool provides ALL information that prowler_list_resources returns PLUS:
|
||||
|
||||
1. Configuration Details:
|
||||
- metadata: Provider-specific configuration (tags, policies, encryption settings, network rules)
|
||||
@@ -197,12 +197,12 @@ class ResourcesTools(BaseTool):
|
||||
|
||||
3. Security Relationships:
|
||||
- finding_ids: Prowler's internal UUIDs (v4) of all security findings associated with this resource
|
||||
- Use prowler_app_get_finding_details on these IDs to get remediation guidance
|
||||
- Use prowler_get_finding_details on these IDs to get remediation guidance
|
||||
|
||||
Useful Workflow:
|
||||
1. Use prowler_app_list_resources to browse and filter across many resources
|
||||
1. Use prowler_list_resources to browse and filter across many resources
|
||||
2. Use this tool to drill down into specific resources of interest
|
||||
3. Use prowler_app_get_finding_details to get details about the security issues for specific resources
|
||||
3. Use prowler_get_finding_details to get details about the security issues for specific resources
|
||||
"""
|
||||
params = {}
|
||||
|
||||
@@ -348,7 +348,7 @@ class ResourcesTools(BaseTool):
|
||||
async def get_resource_events(
|
||||
self,
|
||||
resource_id: str = Field(
|
||||
description="Prowler's internal UUID (v4) for the resource. Use `prowler_app_list_resources` to find the right ID, or get it from a finding's resource relationship via `prowler_app_get_finding_details`."
|
||||
description="Prowler's internal UUID (v4) for the resource. Use `prowler_list_resources` to find the right ID, or get it from a finding's resource relationship via `prowler_get_finding_details`."
|
||||
),
|
||||
lookback_days: int = Field(
|
||||
default=90,
|
||||
@@ -386,8 +386,8 @@ class ResourcesTools(BaseTool):
|
||||
- Identifying unauthorized or unexpected modifications
|
||||
|
||||
Workflows:
|
||||
1. Resource browsing: prowler_app_list_resources → find resource → this tool for event history
|
||||
2. Incident investigation: prowler_app_get_finding_details → get resource ID from finding → this tool to identify who caused the issue, what they changed, and when
|
||||
1. Resource browsing: prowler_list_resources → find resource → this tool for event history
|
||||
2. Incident investigation: prowler_get_finding_details → get resource ID from finding → this tool to identify who caused the issue, what they changed, and when
|
||||
"""
|
||||
params = {
|
||||
"lookback_days": lookback_days,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Security Scans tools for Prowler App MCP Server.
|
||||
"""Security Scans tools for Prowler MCP Server.
|
||||
|
||||
This module provides tools for managing and monitoring Prowler security scans.
|
||||
"""
|
||||
@@ -20,18 +20,18 @@ class ScansTools(BaseTool):
|
||||
"""Tools for security scan operations.
|
||||
|
||||
Provides tools for:
|
||||
- prowler_app_list_scans: Search and filter scans with rich filtering capabilities
|
||||
- prowler_app_get_scan: Get comprehensive details about a specific scan
|
||||
- prowler_app_trigger_scan: Trigger manual security scans for providers
|
||||
- prowler_app_schedule_daily_scan: Schedule automated daily scans for continuous monitoring
|
||||
- prowler_app_update_scan: Update scan names for better organization
|
||||
- prowler_list_scans: Search and filter scans with rich filtering capabilities
|
||||
- prowler_get_scan: Get comprehensive details about a specific scan
|
||||
- prowler_trigger_scan: Trigger manual security scans for providers
|
||||
- prowler_schedule_daily_scan: Schedule automated daily scans for continuous monitoring
|
||||
- prowler_update_scan: Update scan names for better organization
|
||||
"""
|
||||
|
||||
async def list_scans(
|
||||
self,
|
||||
provider_id: list[str] = Field(
|
||||
default=[],
|
||||
description="Filter by Prowler's internal UUID(s) (v4) for specific provider(s), generated when the provider was registered. Use `prowler_app_search_providers` tool to find provider IDs",
|
||||
description="Filter by Prowler's internal UUID(s) (v4) for specific provider(s), generated when the provider was registered. Use `prowler_search_providers` tool to find provider IDs",
|
||||
),
|
||||
provider_type: list[str] = Field(
|
||||
default=[],
|
||||
@@ -56,7 +56,7 @@ class ScansTools(BaseTool):
|
||||
),
|
||||
trigger: Literal["manual", "scheduled"] | None = Field(
|
||||
default=None,
|
||||
description="Filter by how the scan was initiated. Options: 'manual' (user-initiated via prowler_app_trigger_scan), 'scheduled' (automated via prowler_app_schedule_daily_scan)",
|
||||
description="Filter by how the scan was initiated. Options: 'manual' (user-initiated via prowler_trigger_scan), 'scheduled' (automated via prowler_schedule_daily_scan)",
|
||||
),
|
||||
name: str | None = Field(
|
||||
default=None,
|
||||
@@ -75,7 +75,7 @@ class ScansTools(BaseTool):
|
||||
|
||||
IMPORTANT: This tool returns LIGHTWEIGHT scan information. Use this for fast searching and filtering
|
||||
across many scans. For complete scan details including progress, duration, and resource counts,
|
||||
use prowler_app_get_scan on specific scans of interest.
|
||||
use prowler_get_scan on specific scans of interest.
|
||||
|
||||
Default behavior:
|
||||
- Returns all scans
|
||||
@@ -83,15 +83,15 @@ class ScansTools(BaseTool):
|
||||
- Includes all scan states (available, scheduled, executing, completed, failed, cancelled)
|
||||
|
||||
Each scan includes:
|
||||
- Core identification: id (UUID for prowler_app_get_scan), name
|
||||
- Core identification: id (UUID for prowler_get_scan), name
|
||||
- Execution context: state, trigger (manual/scheduled)
|
||||
- Temporal data: started_at, completed_at
|
||||
- Provider relationship: provider_id
|
||||
|
||||
Workflow:
|
||||
1. Use this tool to search and filter scans by provider, state, or date range
|
||||
2. Use prowler_app_get_scan with the scan 'id' to get progress, duration, and resource counts
|
||||
3. Use prowler_app_search_security_findings filtered by scan dates to analyze scan results
|
||||
2. Use prowler_get_scan with the scan 'id' to get progress, duration, and resource counts
|
||||
3. Use prowler_search_security_findings filtered by scan dates to analyze scan results
|
||||
"""
|
||||
# Validate pagination
|
||||
self.api_client.validate_page_size(page_size)
|
||||
@@ -128,15 +128,15 @@ class ScansTools(BaseTool):
|
||||
async def get_scan(
|
||||
self,
|
||||
scan_id: str = Field(
|
||||
description="Prowler's internal UUID (v4) for the scan to retrieve, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_app_list_scans` tool to find scan IDs"
|
||||
description="Prowler's internal UUID (v4) for the scan to retrieve, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_list_scans` tool to find scan IDs"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""Retrieve comprehensive details about a specific scan by its ID.
|
||||
|
||||
IMPORTANT: This tool returns COMPLETE scan details.
|
||||
Use this after finding a specific scan via prowler_app_list_scans.
|
||||
Use this after finding a specific scan via prowler_list_scans.
|
||||
|
||||
This tool provides ALL information that prowler_app_list_scans returns PLUS:
|
||||
This tool provides ALL information that prowler_list_scans returns PLUS:
|
||||
|
||||
1. Execution Details:
|
||||
- progress: Scan completion progress as percentage (0-100%)
|
||||
@@ -155,9 +155,9 @@ class ScansTools(BaseTool):
|
||||
- Understanding scan scheduling patterns
|
||||
|
||||
Workflow:
|
||||
1. Use prowler_app_list_scans to browse and filter scans
|
||||
1. Use prowler_list_scans to browse and filter scans
|
||||
2. Use this tool with the scan 'id' to monitor progress or view detailed results
|
||||
3. For completed scans, use prowler_app_search_security_findings filtered by date to analyze findings
|
||||
3. For completed scans, use prowler_search_security_findings filtered by date to analyze findings
|
||||
"""
|
||||
# Fetch scan with all fields
|
||||
params = {
|
||||
@@ -172,7 +172,7 @@ class ScansTools(BaseTool):
|
||||
async def trigger_scan(
|
||||
self,
|
||||
provider_id: str = Field(
|
||||
description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_app_search_providers` tool to find the provider ID"
|
||||
description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_search_providers` tool to find the provider ID"
|
||||
),
|
||||
name: str | None = Field(
|
||||
default=None,
|
||||
@@ -182,14 +182,14 @@ class ScansTools(BaseTool):
|
||||
"""Trigger a manual security scan for a provider.
|
||||
|
||||
IMPORTANT: This tool returns immediately once the scan is created.
|
||||
The scan will continue running in the background. Use `prowler_app_get_scan`
|
||||
The scan will continue running in the background. Use `prowler_get_scan`
|
||||
with the returned scan ID to monitor progress and check when it completes.
|
||||
|
||||
Example Useful Workflow:
|
||||
1. Use `prowler_app_search_providers` to find the provider_id you want to scan
|
||||
1. Use `prowler_search_providers` to find the provider_id you want to scan
|
||||
2. Use this tool to trigger the scan
|
||||
3. Use `prowler_app_get_scan` with the returned scan 'id' to monitor progress
|
||||
4. Once completed, use `prowler_app_search_security_findings` to analyze results
|
||||
3. Use `prowler_get_scan` with the returned scan 'id' to monitor progress
|
||||
4. Once completed, use `prowler_search_security_findings` to analyze results
|
||||
"""
|
||||
try:
|
||||
# Build request data
|
||||
@@ -231,7 +231,7 @@ class ScansTools(BaseTool):
|
||||
return ScanCreationResult(
|
||||
scan=scan_info,
|
||||
status="success",
|
||||
message=f"Scan {scan_id} created successfully. The scan may take some time to complete. Use prowler_app_get_scan tool with this ID to monitor progress.",
|
||||
message=f"Scan {scan_id} created successfully. The scan may take some time to complete. Use prowler_get_scan tool with this ID to monitor progress.",
|
||||
).model_dump()
|
||||
|
||||
except Exception as e:
|
||||
@@ -245,7 +245,7 @@ class ScansTools(BaseTool):
|
||||
async def schedule_daily_scan(
|
||||
self,
|
||||
provider_id: str = Field(
|
||||
description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_app_search_providers` tool to find the provider ID"
|
||||
description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_search_providers` tool to find the provider ID"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""Schedule automated daily scans for a provider for continuous security monitoring.
|
||||
@@ -256,17 +256,17 @@ class ScansTools(BaseTool):
|
||||
you're not actively using the system.
|
||||
|
||||
IMPORTANT: This tool returns immediately once the daily schedule is created.
|
||||
The schedule will be set up in the background. Use `prowler_app_list_scans`
|
||||
The schedule will be set up in the background. Use `prowler_list_scans`
|
||||
filtered by provider_id and trigger='scheduled' to view scheduled scans.
|
||||
|
||||
IMPORTANT: This creates a PERSISTENT schedule. The provider will be scanned
|
||||
automatically every 24 hours until the provider is deleted.
|
||||
|
||||
Example Useful Workflow:
|
||||
1. Use `prowler_app_search_providers` to find the provider_id you want to monitor
|
||||
1. Use `prowler_search_providers` to find the provider_id you want to monitor
|
||||
2. Use this tool to create the daily schedule
|
||||
3. Use `prowler_app_list_scans` filtered by provider_id to view scheduled and completed scans
|
||||
4. Monitor findings over time with `prowler_app_search_security_findings`
|
||||
3. Use `prowler_list_scans` filtered by provider_id to view scheduled and completed scans
|
||||
4. Monitor findings over time with `prowler_search_security_findings`
|
||||
"""
|
||||
self.logger.info(f"Creating daily schedule for provider {provider_id}")
|
||||
task_response = await self.api_client.post(
|
||||
@@ -285,7 +285,7 @@ class ScansTools(BaseTool):
|
||||
)
|
||||
|
||||
if task_state == "available":
|
||||
return_message = "Daily schedule created successfully. The schedule is being set up in the background. Use prowler_app_list_scans with provider_id filter to view scheduled scans."
|
||||
return_message = "Daily schedule created successfully. The schedule is being set up in the background. Use prowler_list_scans with provider_id filter to view scheduled scans."
|
||||
else:
|
||||
return_message = "Daily schedule creation failed. Please try again later."
|
||||
|
||||
@@ -297,7 +297,7 @@ class ScansTools(BaseTool):
|
||||
async def update_scan(
|
||||
self,
|
||||
scan_id: str = Field(
|
||||
description="Prowler's internal UUID (v4) for the scan to update, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_app_list_scans` tool to find the scan ID if you only know the provider or scan name. Returns an error if the scan ID is invalid or not found."
|
||||
description="Prowler's internal UUID (v4) for the scan to update, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_list_scans` tool to find the scan ID if you only know the provider or scan name. Returns an error if the scan ID is invalid or not found."
|
||||
),
|
||||
name: str = Field(
|
||||
description="New human-friendly name for the scan (3-100 characters). Use descriptive names to improve organization and tracking, e.g., 'Production Security Audit - Q4 2025', 'Post-Deployment Compliance Check'. IMPORTANT: Only the scan name can be updated - other attributes (state, progress, duration) are read-only and managed by the system."
|
||||
@@ -309,7 +309,7 @@ class ScansTools(BaseTool):
|
||||
(state, progress, duration, etc.) are read-only and managed by the system.
|
||||
|
||||
Example Useful Workflow:
|
||||
1. Use `prowler_app_list_scans` to find the scan you want to rename
|
||||
1. Use `prowler_list_scans` to find the scan you want to rename
|
||||
2. Use this tool with the scan 'id' and new name
|
||||
"""
|
||||
api_response = await self.api_client.patch(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Shared API client utilities for Prowler App tools."""
|
||||
"""Shared API client utilities for Prowler tools."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
@@ -10,7 +10,7 @@ from prowler_mcp_server.lib.logger import logger
|
||||
|
||||
|
||||
class ProwlerAppAuth:
|
||||
"""Handles authentication for Prowler App API using API keys or JWT tokens."""
|
||||
"""Handles authentication for Prowler API using API keys or JWT tokens."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -18,19 +18,23 @@ class ProwlerAppAuth:
|
||||
base_url: str = os.getenv("API_BASE_URL", "https://api.prowler.com/api/v1"),
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
logger.info(f"Using Prowler App API base URL: {self.base_url}")
|
||||
logger.info(f"Using Prowler API base URL: {self.base_url}")
|
||||
self.mode = mode
|
||||
self.access_token: str | None = None
|
||||
self.api_key: str | None = None
|
||||
|
||||
if mode == "stdio": # STDIO mode
|
||||
self.api_key = os.getenv("PROWLER_APP_API_KEY")
|
||||
# PROWLER_API_KEY is the current variable; PROWLER_APP_API_KEY is kept
|
||||
# as a backward-compatible fallback so existing setups keep working.
|
||||
self.api_key = os.getenv("PROWLER_API_KEY") or os.getenv(
|
||||
"PROWLER_APP_API_KEY"
|
||||
)
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("PROWLER_APP_API_KEY environment variable is required")
|
||||
raise ValueError("PROWLER_API_KEY environment variable is required")
|
||||
|
||||
if not self.api_key.startswith("pk_"):
|
||||
raise ValueError("Prowler App API key format is incorrect")
|
||||
raise ValueError("Prowler API key format is incorrect")
|
||||
|
||||
def _parse_jwt(self, token: str) -> dict | None:
|
||||
"""Parse JWT token and return payload
|
||||
|
||||
@@ -13,18 +13,27 @@ from prowler_mcp_server.lib.logger import logger
|
||||
from prowler_mcp_server.prowler_app.tools.base import BaseTool
|
||||
|
||||
|
||||
def load_all_tools(mcp: FastMCP) -> None:
|
||||
"""Auto-discover and load all BaseTool subclasses from the tools package.
|
||||
def load_all_tools(
|
||||
mcp: FastMCP,
|
||||
tools_package: str = "prowler_mcp_server.prowler_app.tools",
|
||||
) -> None:
|
||||
"""Auto-discover and load all BaseTool subclasses from a tools package.
|
||||
|
||||
This function:
|
||||
1. Dynamically imports all Python modules in the tools package
|
||||
2. Discovers all concrete BaseTool subclasses
|
||||
1. Dynamically imports all Python modules in the given tools package
|
||||
2. Discovers all concrete BaseTool subclasses defined in that package
|
||||
3. Instantiates each tool class
|
||||
4. Registers all tools with the provided FastMCP instance
|
||||
|
||||
``BaseTool.__subclasses__()`` returns every subclass in the process, so the
|
||||
discovered classes are filtered by ``__module__`` prefix. This keeps sibling
|
||||
sub-servers (e.g. ``prowler_app`` and ``prowler_cloud``) from cross-registering
|
||||
each other's tools, regardless of import order.
|
||||
|
||||
Args:
|
||||
mcp: The FastMCP instance to register tools with
|
||||
TOOLS_PACKAGE: The package path containing tool modules (default: prowler_mcp_server.prowler_app.tools)
|
||||
tools_package: The package path containing tool modules
|
||||
(default: prowler_mcp_server.prowler_app.tools)
|
||||
|
||||
Example:
|
||||
from fastmcp import FastMCP
|
||||
@@ -33,7 +42,7 @@ def load_all_tools(mcp: FastMCP) -> None:
|
||||
app = FastMCP("prowler-app")
|
||||
load_all_tools(app)
|
||||
"""
|
||||
TOOLS_PACKAGE = "prowler_mcp_server.prowler_app.tools"
|
||||
TOOLS_PACKAGE = tools_package
|
||||
logger.info(f"Auto-discovering tools from package: {TOOLS_PACKAGE}")
|
||||
|
||||
# Import the tools package
|
||||
@@ -59,11 +68,14 @@ def load_all_tools(mcp: FastMCP) -> None:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import module {module_name}: {e}")
|
||||
|
||||
# Discover all concrete BaseTool subclasses
|
||||
# Discover all concrete BaseTool subclasses defined in this package only.
|
||||
# __subclasses__() is process-wide, so filter by module to avoid sibling
|
||||
# sub-servers cross-registering each other's tools.
|
||||
concrete_tools = [
|
||||
tool_class
|
||||
for tool_class in BaseTool.__subclasses__()
|
||||
if not getattr(tool_class, "__abstractmethods__", None)
|
||||
and tool_class.__module__.startswith(TOOLS_PACKAGE)
|
||||
]
|
||||
|
||||
logger.info(f"Discovered {len(concrete_tools)} tool classes")
|
||||
|
||||
@@ -19,15 +19,15 @@ def setup_main_server():
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mount Prowler Hub server: {e}")
|
||||
|
||||
# Mount Prowler App tools with prowler_app_ namespace
|
||||
# Mount core Prowler tools with prowler_ namespace
|
||||
try:
|
||||
logger.info("Mounting Prowler App server...")
|
||||
logger.info("Mounting Prowler tools server...")
|
||||
from prowler_mcp_server.prowler_app.server import app_mcp_server
|
||||
|
||||
prowler_mcp_server.mount(app_mcp_server, namespace="prowler_app")
|
||||
logger.info("Successfully mounted Prowler App server")
|
||||
prowler_mcp_server.mount(app_mcp_server, namespace="prowler")
|
||||
logger.info("Successfully mounted Prowler tools server")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mount Prowler App server: {e}")
|
||||
logger.error(f"Failed to mount Prowler tools server: {e}")
|
||||
|
||||
# Mount Prowler Documentation tools with prowler_docs_ namespace
|
||||
try:
|
||||
|
||||
@@ -19,11 +19,13 @@ description = "MCP server for Prowler ecosystem"
|
||||
name = "prowler-mcp"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
version = "0.5.0"
|
||||
version = "0.8.0"
|
||||
|
||||
[project.scripts]
|
||||
prowler-mcp = "prowler_mcp_server.main:main"
|
||||
|
||||
[tool.pytest]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
|
||||
@@ -676,7 +676,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-mcp"
|
||||
version = "0.5.0"
|
||||
version = "0.8.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastmcp" },
|
||||
|
||||
@@ -4,6 +4,18 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [5.35.0] (Prowler v5.35.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `excluded_checks` and `excluded_services` in scan configurations to narrow the execution scope [(#12028)](https://github.com/prowler-cloud/prowler/pull/12028)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Jira tenant information requests validate site names and do not follow redirects [(#12012)](https://github.com/prowler-cloud/prowler/pull/12012)
|
||||
|
||||
---
|
||||
|
||||
## [5.34.0] (Prowler v5.34.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Alibaba Cloud SSH and RDP security group checks no longer produce false negatives when allowed rules use capitalized `Policy="Accept"` values
|
||||
@@ -0,0 +1 @@
|
||||
Fix invalid escape sequence `SyntaxWarning` raised on startup by the S3 bucket name validation regex
|
||||
@@ -0,0 +1 @@
|
||||
Jira output rendering supports grouped Finding Group issues with caller-provided links and capped or uncapped finding copy
|
||||
@@ -1 +0,0 @@
|
||||
Jira tenant information requests validate site names and do not follow redirects
|
||||
@@ -0,0 +1 @@
|
||||
`sagemaker_notebook_instance_no_secrets` check for AWS provider, scanning SageMaker notebook instance lifecycle configuration scripts (`OnCreate` and `OnStart`) for hardcoded secrets such as API keys, passwords, tokens, and connection strings
|
||||