diff --git a/.env b/.env index 8294fac91c..3a666b2e9c 100644 --- a/.env +++ b/.env @@ -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" diff --git a/.github/workflows/api-container-checks.yml b/.github/workflows/api-container-checks.yml index f8d5df5417..0e6609286f 100644 --- a/.github/workflows/api-container-checks.yml +++ b/.github/workflows/api-container-checks.yml @@ -113,6 +113,15 @@ jobs: api/changelog.d/** api/AGENTS.md + # api-container-build-push.yml resolves the SDK pin to the branch tip + # before building, so match it here and scan what ships. Push only: PRs + # stay deterministic against the committed lock. + - name: Refresh prowler SDK pin to current branch tip + if: steps.check-changes.outputs.any_changed == 'true' && github.event_name == 'push' + run: | + pip install --no-cache-dir "uv==0.11.14" + (cd api && uv lock --upgrade-package prowler) + - name: Set up Docker Buildx if: steps.check-changes.outputs.any_changed == 'true' uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 diff --git a/.trivyignore b/.trivyignore index c0207c8374..da6ad95d85 100644 --- a/.trivyignore +++ b/.trivyignore @@ -35,6 +35,20 @@ CVE-2026-13221 pkg:perl-base exp:2026-08-15 CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15 CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15 +# CVE-2026-57433 — Perl Storable signed integer overflow when deserializing a +# crafted SX_HOOK record (retrieve_hook_common passes a wrapped negative count +# to av_extend). +# Packages: perl, perl-base, perl-modules-5.36, libperl5.36. +# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be +# removed without breaking dpkg. Prowler does not invoke perl at runtime and +# never calls Storable's thaw/retrieve on attacker-controlled blobs, so the +# vulnerable deserialization path is unreachable. Fixed upstream in +# Storable 3.41; no Debian bookworm fix is available yet. +CVE-2026-57433 pkg:perl exp:2026-08-15 +CVE-2026-57433 pkg:perl-base exp:2026-08-15 +CVE-2026-57433 pkg:perl-modules-5.36 exp:2026-08-15 +CVE-2026-57433 pkg:libperl5.36 exp:2026-08-15 + # CVE-2025-7458 — SQLite integer overflow. # Package: libsqlite3-0. # Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The diff --git a/AGENTS.md b/AGENTS.md index 763b3f10e5..997c4bbaff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` | diff --git a/README.md b/README.md index c58d903c08..76e6534551 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index a311614ca9..1b26c2b014 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to the **Prowler API** are documented in this file. +## [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 diff --git a/api/Dockerfile b/api/Dockerfile index 8d6923bbfc..ec8237d44a 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -102,7 +102,9 @@ ENV PATH="/home/prowler/.local/bin:$PATH" RUN uv sync --locked --no-install-project && \ rm -rf ~/.cache/uv -RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py +# Invoked as a module so the base image's Python minor version is not baked +# into a site-packages path. +RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell USER root diff --git a/api/changelog.d/attack-paths-scan-time-limit.fixed.md b/api/changelog.d/attack-paths-scan-time-limit.fixed.md deleted file mode 100644 index 3726b52668..0000000000 --- a/api/changelog.d/attack-paths-scan-time-limit.fixed.md +++ /dev/null @@ -1 +0,0 @@ -`attack-paths-scan-perform` Celery tasks now use the configurable long-task time limits instead of the six-hour defaults diff --git a/api/changelog.d/compliance-overview-single-transaction.changed.md b/api/changelog.d/compliance-overview-single-transaction.changed.md new file mode 100644 index 0000000000..0e7a6714b3 --- /dev/null +++ b/api/changelog.d/compliance-overview-single-transaction.changed.md @@ -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 diff --git a/api/changelog.d/integrations-hidden-providers-disclosure.security.md b/api/changelog.d/integrations-hidden-providers-disclosure.security.md new file mode 100644 index 0000000000..4ea169d4fe --- /dev/null +++ b/api/changelog.d/integrations-hidden-providers-disclosure.security.md @@ -0,0 +1 @@ +Integration responses no longer disclose providers outside the visibility of the role, including the resources sideloaded through `?include=providers` diff --git a/api/changelog.d/integrations-limited-visibility.fixed.md b/api/changelog.d/integrations-limited-visibility.fixed.md new file mode 100644 index 0000000000..c8f7e2c450 --- /dev/null +++ b/api/changelog.d/integrations-limited-visibility.fixed.md @@ -0,0 +1 @@ +Tenant-wide integrations that are not attached to any provider, such as Jira, are now visible and manageable by roles with `manage_integrations` and without unlimited visibility diff --git a/api/changelog.d/integrations-object-scoping.security.md b/api/changelog.d/integrations-object-scoping.security.md new file mode 100644 index 0000000000..877190fc1b --- /dev/null +++ b/api/changelog.d/integrations-object-scoping.security.md @@ -0,0 +1 @@ +Integration connection checks, Jira issue type lookups and Jira dispatches now resolve the integration through the provider visibility of the role instead of the whole tenant diff --git a/api/changelog.d/integrations-provider-scoping.security.md b/api/changelog.d/integrations-provider-scoping.security.md new file mode 100644 index 0000000000..eec84285a4 --- /dev/null +++ b/api/changelog.d/integrations-provider-scoping.security.md @@ -0,0 +1 @@ +Roles without unlimited visibility can no longer attach an integration to providers they cannot see, nor edit or delete an integration bound to them diff --git a/api/changelog.d/jira-site-name-validation.security.md b/api/changelog.d/jira-site-name-validation.security.md deleted file mode 100644 index d06244297a..0000000000 --- a/api/changelog.d/jira-site-name-validation.security.md +++ /dev/null @@ -1 +0,0 @@ -Jira integration credentials only accept bare Atlassian site names containing letters, numbers, and hyphens diff --git a/api/changelog.d/oci-regionless-api-legacy-region.changed.md b/api/changelog.d/oci-regionless-api-legacy-region.changed.md new file mode 100644 index 0000000000..087b027c88 --- /dev/null +++ b/api/changelog.d/oci-regionless-api-legacy-region.changed.md @@ -0,0 +1 @@ +OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning diff --git a/api/changelog.d/social-account-linking.security.md b/api/changelog.d/social-account-linking.security.md deleted file mode 100644 index 74e9fd129a..0000000000 --- a/api/changelog.d/social-account-linking.security.md +++ /dev/null @@ -1 +0,0 @@ -Social account linking requires a verified matching email from both the identity provider and the existing user account without sending account connection notifications diff --git a/api/pyproject.toml b/api/pyproject.toml index 8061632cb8..607a8c7348 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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. diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index 3a33b964b7..3ef55b7eca 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -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 diff --git a/api/src/backend/api/attack_paths/retryable_session.py b/api/src/backend/api/attack_paths/retryable_session.py index 2cd32f54ee..e7e78e9798 100644 --- a/api/src/backend/api/attack_paths/retryable_session.py +++ b/api/src/backend/api/attack_paths/retryable_session.py @@ -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) diff --git a/api/src/backend/api/attack_paths/sink/base.py b/api/src/backend/api/attack_paths/sink/base.py index 0ba4737f5e..0134e0a41f 100644 --- a/api/src/backend/api/attack_paths/sink/base.py +++ b/api/src/backend/api/attack_paths/sink/base.py @@ -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: ... diff --git a/api/src/backend/api/attack_paths/sink/neo4j.py b/api/src/backend/api/attack_paths/sink/neo4j.py index cb7d4889b0..c820ed9d93 100644 --- a/api/src/backend/api/attack_paths/sink/neo4j.py +++ b/api/src/backend/api/attack_paths/sink/neo4j.py @@ -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, ) diff --git a/api/src/backend/api/attack_paths/sink/neptune.py b/api/src/backend/api/attack_paths/sink/neptune.py index c68b6f8277..022e3c8669 100644 --- a/api/src/backend/api/attack_paths/sink/neptune.py +++ b/api/src/backend/api/attack_paths/sink/neptune.py @@ -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, ) diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py index e8dd728cb9..7a2c9c61c4 100644 --- a/api/src/backend/api/base_views.py +++ b/api/src/backend/api/base_views.py @@ -3,9 +3,10 @@ from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias from api.db_utils import POSTGRES_USER_VAR, rls_transaction from api.filters import CustomDjangoFilterBackend from api.models import Role, UserRoleRelationship -from api.rbac.permissions import HasPermissions +from api.rbac.permissions import HasPermissions, get_role from django.conf import settings from django.db import transaction +from django.utils.functional import cached_property from rest_framework import permissions from rest_framework.exceptions import NotAuthenticated from rest_framework.filters import SearchFilter @@ -100,6 +101,11 @@ class BaseRLSViewSet(BaseViewSet): context["tenant_id"] = self.request.tenant_id return context + @cached_property + def user_role(self): + """Role of the requesting user in the active tenant, resolved once per request.""" + return get_role(self.request.user, self.request.tenant_id) + class BaseTenantViewset(BaseViewSet): def dispatch(self, request, *args, **kwargs): diff --git a/api/src/backend/api/decorators.py b/api/src/backend/api/decorators.py index a055b2252f..2dd2d5fea4 100644 --- a/api/src/backend/api/decorators.py +++ b/api/src/backend/api/decorators.py @@ -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 diff --git a/api/src/backend/api/rbac/permissions.py b/api/src/backend/api/rbac/permissions.py index 3458346a5f..e2c209a990 100644 --- a/api/src/backend/api/rbac/permissions.py +++ b/api/src/backend/api/rbac/permissions.py @@ -1,8 +1,8 @@ from enum import Enum from api.db_router import MainRouter -from api.models import Provider, Role, User -from django.db.models import QuerySet +from api.models import Integration, Provider, Role, User +from django.db.models import Q, QuerySet from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import BasePermission @@ -83,3 +83,32 @@ def get_providers(role: Role) -> QuerySet[Provider]: return Provider.objects.filter( tenant_id=tenant_id, provider_groups__in=provider_groups ).distinct() + + +def get_integrations( + role: Role, providers: QuerySet[Provider] | None = None +) -> QuerySet[Integration]: + """ + Return a distinct queryset of Integrations visible to the given role. + + Integrations with no providers attached are tenant-wide, as is always the case for + Jira, and stay visible regardless of the provider visibility of the role. Integrations + attached to providers are only visible when the role can access at least one of them. + + Args: + role: A Role instance. + providers: Optional queryset of the providers accessible by the role, to reuse + an already resolved `get_providers(role)` result within the same request. + + Returns: + A QuerySet of Integration objects visible to the role. + """ + queryset = Integration.objects.filter(tenant_id=role.tenant_id) + if role.unlimited_visibility: + return queryset + + if providers is None: + providers = get_providers(role) + return queryset.filter( + Q(providers__isnull=True) | Q(providers__in=providers) + ).distinct() diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index de2cbfce2f..7a25bea182 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.36.0 + version: 1.37.0 description: |- Prowler API specification. @@ -6629,8 +6629,10 @@ paths: /api/v1/integrations: get: operationId: api_v1_integrations_list - description: Retrieve a list of all configured integrations with options for - filtering by various criteria. + description: |- + Retrieve a list of all configured integrations with options for filtering by various criteria. + + Integrations attached to one or more providers are only returned when the role can access at least one of those providers, and each integration lists only the providers visible to the role. Integrations not attached to any provider, such as Jira, are tenant-wide and are returned for every role. summary: List all integrations parameters: - in: query @@ -6781,7 +6783,8 @@ paths: post: operationId: api_v1_integrations_create description: Register a new integration with the system, providing necessary - configuration details. + configuration details. Only providers visible to the role can be attached + to the integration. summary: Create a new integration tags: - Integration @@ -6810,7 +6813,7 @@ paths: post: operationId: api_v1_integrations_jira_dispatches_create description: |- - Send a set of filtered findings to the given integration. At least one finding filter must be provided. + Send a set of filtered findings to the given integration. At least one finding filter must be provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings sent are limited to the providers the role can access. ## Known Limitations @@ -6883,7 +6886,8 @@ paths: get: operationId: api_v1_integrations_jira_issue_types_retrieve description: Fetch the available issue types from Jira for a given project key - and update the integration configuration. + and update the integration configuration. Jira integrations are tenant-wide + and do not require unlimited visibility. summary: Get available issue types for a Jira project parameters: - in: query @@ -6924,7 +6928,8 @@ paths: get: operationId: api_v1_integrations_retrieve description: Fetch detailed information about a specific integration by its - ID. + ID. Integrations outside the provider visibility of the role are reported + the same way as one that does not exist. summary: Retrieve integration details parameters: - in: query @@ -6978,7 +6983,8 @@ paths: patch: operationId: api_v1_integrations_partial_update description: Modify certain fields of an existing integration without affecting - other settings. + other settings. Integrations attached to providers outside the visibility + of the role cannot be modified by it. summary: Partially update an integration parameters: - in: path @@ -7013,7 +7019,8 @@ paths: description: '' delete: operationId: api_v1_integrations_destroy - description: Remove an integration from the system by its ID. + description: Remove an integration from the system by its ID. Integrations attached + to providers outside the visibility of the role cannot be deleted by it. summary: Delete an integration parameters: - in: path @@ -7033,7 +7040,9 @@ paths: /api/v1/integrations/{id}/connection: post: operationId: api_v1_integrations_connection_create - description: Try to verify integration connection + description: Try to verify integration connection. Integrations outside the + provider visibility of the role are reported the same way as one that does + not exist. summary: Check integration connection parameters: - in: path diff --git a/api/src/backend/api/tests/test_decorators.py b/api/src/backend/api/tests/test_decorators.py index 5c2897730f..0bf58340a1 100644 --- a/api/src/backend/api/tests/test_decorators.py +++ b/api/src/backend/api/tests/test_decorators.py @@ -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.""" diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py index ed177138b2..92d19bd3c0 100644 --- a/api/src/backend/api/tests/test_rbac.py +++ b/api/src/backend/api/tests/test_rbac.py @@ -3,6 +3,8 @@ from unittest.mock import ANY, Mock, patch import pytest from api.models import ( + Integration, + IntegrationProviderRelationship, Membership, ProviderGroup, ProviderGroupMembership, @@ -681,6 +683,363 @@ class TestLimitedVisibility: response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1 ) + @pytest.fixture + def jira_integration(self, tenants_fixture): + # Jira is a tenant-wide integration: it is not attached to any provider + return Integration.objects.create( + tenant_id=tenants_fixture[0].id, + enabled=True, + connected=True, + integration_type=Integration.IntegrationChoices.JIRA, + configuration={"projects": {"TEST": "Test project"}}, + credentials={ + "domain": "test", + "user_mail": "a@b.com", + "api_token": "token", + }, + ) + + @pytest.fixture + def out_of_scope_integration(self, tenants_fixture, provider_factory): + tenant_id = tenants_fixture[0].id + integration = Integration.objects.create( + tenant_id=tenant_id, + enabled=True, + connected=True, + integration_type=Integration.IntegrationChoices.AMAZON_S3, + configuration={ + "bucket_name": "bucket", + "output_directory": "output", + }, + credentials={"aws_access_key_id": "key"}, + ) + IntegrationProviderRelationship.objects.create( + tenant_id=tenant_id, + integration=integration, + provider=provider_factory(), + ) + return integration + + def test_integrations_list_includes_tenant_wide_integration( + self, + authenticated_client_rbac_limited, + integrations_fixture, + jira_integration, + aws_provider_pair, + ): + # Integration 2 is attached to both providers, so make both visible to the role + # to assert the provider join does not duplicate it in the listing + ProviderGroupMembership.objects.create( + tenant_id=aws_provider_pair[1].tenant_id, + provider=aws_provider_pair[1], + provider_group=ProviderGroup.objects.get(name="limited_visibility_group"), + ) + + response = authenticated_client_rbac_limited.get(reverse("integration-list")) + + assert response.status_code == status.HTTP_200_OK + integration_ids = [item["id"] for item in response.json()["data"]] + # The tenant-wide Jira integration is visible without unlimited visibility + assert str(jira_integration.id) in integration_ids + # Integrations attached to more than one visible provider are not duplicated + assert integration_ids.count(str(integrations_fixture[1].id)) == 1 + assert response.json()["meta"]["pagination"]["count"] == len(integration_ids) + + def test_integrations_list_without_provider_groups_keeps_tenant_wide_integration( + self, authenticated_client_rbac_limited, integrations_fixture, jira_integration + ): + # A role with no provider group at all sees no provider, but still needs Jira + RoleProviderGroupRelationship.objects.all().delete() + + response = authenticated_client_rbac_limited.get(reverse("integration-list")) + + assert response.status_code == status.HTTP_200_OK + integration_ids = [item["id"] for item in response.json()["data"]] + assert integration_ids == [str(jira_integration.id)] + + def test_integrations_include_providers_hides_out_of_scope_providers( + self, authenticated_client_rbac_limited, integrations_fixture, aws_provider_pair + ): + # Integration 2 is related to provider1 (visible) and provider2 (not visible) + hidden_provider = aws_provider_pair[1] + + response = authenticated_client_rbac_limited.get( + reverse("integration-list"), {"include": "providers"} + ) + + assert response.status_code == status.HTTP_200_OK + included_ids = {item["id"] for item in response.json().get("included", [])} + assert str(aws_provider_pair[0].id) in included_ids + # Sideloaded resources must not disclose the provider the role cannot see + assert str(hidden_provider.id) not in included_ids + + def test_integrations_list_with_sparse_fields( + self, authenticated_client_rbac_limited, integrations_fixture + ): + response = authenticated_client_rbac_limited.get( + reverse("integration-list"), {"fields[integrations]": "enabled"} + ) + + assert response.status_code == status.HTTP_200_OK + assert all( + list(item["attributes"].keys()) == ["enabled"] + for item in response.json()["data"] + ) + + def test_integrations_list_excludes_out_of_scope_integration( + self, authenticated_client_rbac_limited, out_of_scope_integration + ): + response = authenticated_client_rbac_limited.get(reverse("integration-list")) + + assert response.status_code == status.HTTP_200_OK + integration_ids = [item["id"] for item in response.json()["data"]] + assert str(out_of_scope_integration.id) not in integration_ids + + def test_integration_detail_out_of_scope_returns_404( + self, authenticated_client_rbac_limited, out_of_scope_integration + ): + response = authenticated_client_rbac_limited.get( + reverse("integration-detail", kwargs={"pk": out_of_scope_integration.id}) + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_integration_connection_out_of_scope_returns_404( + self, authenticated_client_rbac_limited, out_of_scope_integration + ): + response = authenticated_client_rbac_limited.post( + reverse( + "integration-connection", kwargs={"pk": out_of_scope_integration.id} + ) + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_integration_update_allowed_when_fully_visible( + self, authenticated_client_rbac_limited, integrations_fixture, jira_integration + ): + # Integration 1 is only related to provider1, which the role can access + integration = integrations_fixture[0] + payload = { + "data": { + "type": "integrations", + "id": str(integration.id), + "attributes": { + "enabled": False, + # integration_type is `amazon_s3` + "credentials": {"aws_access_key_id": "new_value"}, + "configuration": { + "bucket_name": "new_bucket_name", + "output_directory": "new_output_directory", + }, + }, + } + } + + response = authenticated_client_rbac_limited.patch( + reverse("integration-detail", kwargs={"pk": integration.id}), + data=json.dumps(payload), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_200_OK + integration.refresh_from_db() + assert integration.enabled is False + + # Tenant-wide integrations have no provider restricting the role + payload = { + "data": { + "type": "integrations", + "id": str(jira_integration.id), + "attributes": {"enabled": False}, + } + } + + response = authenticated_client_rbac_limited.patch( + reverse("integration-detail", kwargs={"pk": jira_integration.id}), + data=json.dumps(payload), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_200_OK + jira_integration.refresh_from_db() + assert jira_integration.enabled is False + + def test_integration_create_rejects_out_of_scope_provider( + self, authenticated_client_rbac_limited, aws_provider_pair + ): + # provider2 is not in any provider group assigned to the role + payload = { + "data": { + "type": "integrations", + "attributes": { + "integration_type": "amazon_s3", + "configuration": { + "bucket_name": "attacker_bucket", + "output_directory": "output", + }, + "credentials": {"aws_access_key_id": "key"}, + }, + "relationships": { + "providers": { + "data": [ + {"type": "providers", "id": str(aws_provider_pair[1].id)} + ] + } + }, + } + } + + response = authenticated_client_rbac_limited.post( + reverse("integration-list"), + data=json.dumps(payload), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not Integration.objects.filter( + integrationproviderrelationship__provider=aws_provider_pair[1], + configuration__bucket_name="attacker_bucket", + ).exists() + + @pytest.mark.parametrize("submitted_providers", [True, False]) + def test_integration_update_denied_when_shared_with_hidden_provider( + self, + authenticated_client_rbac_limited, + integrations_fixture, + aws_provider_pair, + submitted_providers, + ): + # Integration 2 is related to provider1 (visible) and provider2 (not visible). + # Editing it would reach beyond the visibility of the role, just like deleting + # it, so both are rejected consistently + integration = integrations_fixture[1] + visible_provider, hidden_provider = aws_provider_pair + payload = { + "data": { + "type": "integrations", + "id": str(integration.id), + "attributes": { + "enabled": False, + # integration_type is `amazon_s3` + "credentials": {"aws_access_key_id": "new_value"}, + "configuration": { + "bucket_name": "new_bucket_name", + "output_directory": "new_output_directory", + }, + }, + } + } + if submitted_providers: + payload["data"]["relationships"] = { + "providers": { + "data": [{"type": "providers", "id": str(visible_provider.id)}] + } + } + + response = authenticated_client_rbac_limited.patch( + reverse("integration-detail", kwargs={"pk": integration.id}), + data=json.dumps(payload), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + integration.refresh_from_db() + assert integration.enabled is True + assert integration.providers.filter(id=hidden_provider.id).exists() + assert integration.providers.filter(id=visible_provider.id).exists() + + def test_integration_delete_denied_when_shared_with_hidden_provider( + self, authenticated_client_rbac_limited, integrations_fixture + ): + # Integration 2 is related to provider1 (visible) and provider2 (not visible) + integration = integrations_fixture[1] + + response = authenticated_client_rbac_limited.delete( + reverse("integration-detail", kwargs={"pk": integration.id}) + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert Integration.objects.filter(id=integration.id).exists() + + def test_integration_delete_allowed_when_fully_visible( + self, authenticated_client_rbac_limited, integrations_fixture, jira_integration + ): + # Integration 1 is only related to provider1, which the role can access + integration = integrations_fixture[0] + + response = authenticated_client_rbac_limited.delete( + reverse("integration-detail", kwargs={"pk": integration.id}) + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Integration.objects.filter(id=integration.id).exists() + + # Tenant-wide integrations have no provider restricting the role + response = authenticated_client_rbac_limited.delete( + reverse("integration-detail", kwargs={"pk": jira_integration.id}) + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + def test_jira_issue_types_allowed_without_unlimited_visibility( + self, authenticated_client_rbac_limited, jira_integration + ): + with patch("api.v1.views.initialize_prowler_integration") as mock_jira: + mock_jira.return_value.get_available_issue_types.return_value = ["Task"] + response = authenticated_client_rbac_limited.get( + reverse( + "integration-jira-issue-types", + kwargs={"integration_pk": jira_integration.id}, + ), + {"project_key": "TEST"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"]["attributes"]["issue_types"] == ["Task"] + + def test_jira_issue_types_out_of_scope_returns_404( + self, authenticated_client_rbac_limited, out_of_scope_integration + ): + response = authenticated_client_rbac_limited.get( + reverse( + "integration-jira-issue-types", + kwargs={"integration_pk": out_of_scope_integration.id}, + ), + {"project_key": "TEST"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_jira_dispatches_out_of_scope_returns_404( + self, authenticated_client_rbac_limited, out_of_scope_integration + ): + response = authenticated_client_rbac_limited.post( + reverse( + "integration-jira-dispatches", + kwargs={"integration_pk": out_of_scope_integration.id}, + ), + data=json.dumps({}), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_jira_dispatches_allowed_without_unlimited_visibility( + self, authenticated_client_rbac_limited, jira_integration + ): + response = authenticated_client_rbac_limited.post( + reverse( + "integration-jira-dispatches", + kwargs={"integration_pk": jira_integration.id}, + ), + data=json.dumps({}), + content_type="application/vnd.api+json", + ) + + # The integration is reachable: the request fails on payload validation, not RBAC + assert response.status_code == status.HTTP_400_BAD_REQUEST + @pytest.mark.usefixtures("scan_summaries_fixture") def test_overviews_providers( self, diff --git a/api/src/backend/api/tests/test_retryable_session.py b/api/src/backend/api/tests/test_retryable_session.py index 8bb457b8b6..09b184c223 100644 --- a/api/src/backend/api/tests/test_retryable_session.py +++ b/api/src/backend/api/tests/test_retryable_session.py @@ -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, + ) diff --git a/api/src/backend/api/tests/test_sentry.py b/api/src/backend/api/tests/test_sentry.py index 082f563808..67ba1ee01e 100644 --- a/api/src/backend/api/tests/test_sentry.py +++ b/api/src/backend/api/tests/test_sentry.py @@ -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) diff --git a/api/src/backend/api/tests/test_serializers.py b/api/src/backend/api/tests/test_serializers.py index 0ee674d22c..8e77d63604 100644 --- a/api/src/backend/api/tests/test_serializers.py +++ b/api/src/backend/api/tests/test_serializers.py @@ -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 = """ diff --git a/api/src/backend/api/tests/test_sink.py b/api/src/backend/api/tests/test_sink.py index 487519923e..c778626b62 100644 --- a/api/src/backend/api/tests/test_sink.py +++ b/api/src/backend/api/tests/test_sink.py @@ -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: diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 9e96685477..4b5e8e1694 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -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" diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index bfe8e57bc9..96d366e847 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -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", ( diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index bb636b1bfa..8e73b96a39 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -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, diff --git a/api/src/backend/api/v1/serializer_utils/integrations.py b/api/src/backend/api/v1/serializer_utils/integrations.py index ac876d1d5b..aa941b6c2a 100644 --- a/api/src/backend/api/v1/serializer_utils/integrations.py +++ b/api/src/backend/api/v1/serializer_utils/integrations.py @@ -1,7 +1,9 @@ import os import re +from api.models import Integration, IntegrationProviderRelationship, Provider from api.v1.serializer_utils.base import BaseValidateSerializer +from django.db import transaction from drf_spectacular.utils import extend_schema_field from rest_framework_json_api import serializers @@ -10,6 +12,24 @@ ATLASSIAN_SITE_NAME_REGEX = re.compile( ) +def replace_integration_providers( + integration: Integration, providers: list[Provider], tenant_id: str +) -> None: + """Replace the provider relationships of an integration with the given set.""" + # Atomic on its own, so callers without an ambient transaction cannot leave the + # integration with no relationships if the recreation fails halfway + with transaction.atomic(): + IntegrationProviderRelationship.objects.filter(integration=integration).delete() + IntegrationProviderRelationship.objects.bulk_create( + [ + IntegrationProviderRelationship( + integration=integration, provider=provider, tenant_id=tenant_id + ) + for provider in providers + ] + ) + + class S3ConfigSerializer(BaseValidateSerializer): bucket_name = serializers.CharField() output_directory = serializers.CharField(allow_blank=True) diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index 50c1c7376c..49b593049f 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -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", diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 0f08c8f4ba..7aeaf8d10c 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -47,6 +47,7 @@ from api.v1.serializer_utils.integrations import ( JiraCredentialSerializer, S3ConfigSerializer, SecurityHubConfigSerializer, + replace_integration_providers, ) from api.v1.serializer_utils.lighthouse import ( BedrockCredentialsSerializer, @@ -1672,6 +1673,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 +1815,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 +1985,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 +2021,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 @@ -2716,6 +2744,37 @@ class ScheduleDailyCreateSerializer(BaseSerializerV1): # Integrations +class IntegrationProviderVisibilityMixin: + """ + Keep the `providers` relationship within the provider visibility of the role. + + The view injects `allowed_providers` in the serializer context: `None` when the role + has unlimited visibility, and the queryset of visible providers otherwise. Roles with + limited visibility can neither attach providers they cannot see nor discover, through + the serialized output, the ones already attached. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + allowed_providers = self.context.get("allowed_providers") + if allowed_providers is not None: + self.fields["providers"].child_relation.queryset = allowed_providers + + def hide_restricted_providers(self, representation: dict) -> dict: + allowed_providers = self.context.get("allowed_providers") + # `providers` is missing when the request asks for a subset of the fields + if allowed_providers is None or "providers" not in representation: + return representation + + allowed_provider_ids = {str(provider.id) for provider in allowed_providers} + representation["providers"] = [ + provider + for provider in representation["providers"] + if provider["id"] in allowed_provider_ids + ] + return representation + + class BaseWriteIntegrationSerializer(BaseWriteSerializer): def validate(self, attrs): integration_type = attrs.get("integration_type") @@ -2848,7 +2907,7 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer): ) -class IntegrationSerializer(RLSSerializer): +class IntegrationSerializer(IntegrationProviderVisibilityMixin, RLSSerializer): """ Serializer for the Integration model. """ @@ -2877,15 +2936,9 @@ class IntegrationSerializer(RLSSerializer): } def to_representation(self, instance): - representation = super().to_representation(instance) - allowed_providers = self.context.get("allowed_providers") - if allowed_providers: - allowed_provider_ids = {str(provider.id) for provider in allowed_providers} - representation["providers"] = [ - provider - for provider in representation["providers"] - if provider["id"] in allowed_provider_ids - ] + representation = self.hide_restricted_providers( + super().to_representation(instance) + ) if instance.integration_type == Integration.IntegrationChoices.JIRA: representation["configuration"].update( {"domain": instance.credentials.get("domain")} @@ -2893,7 +2946,9 @@ class IntegrationSerializer(RLSSerializer): return representation -class IntegrationCreateSerializer(BaseWriteIntegrationSerializer): +class IntegrationCreateSerializer( + IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer +): credentials = IntegrationCredentialField(write_only=True) configuration = IntegrationConfigField() providers = serializers.ResourceRelatedField( @@ -2944,22 +2999,18 @@ class IntegrationCreateSerializer(BaseWriteIntegrationSerializer): tenant_id = self.context.get("tenant_id") providers = validated_data.pop("providers", []) - integration = Integration.objects.create(tenant_id=tenant_id, **validated_data) - - through_model_instances = [ - IntegrationProviderRelationship( - integration=integration, - provider=provider, - tenant_id=tenant_id, + with transaction.atomic(): + integration = Integration.objects.create( + tenant_id=tenant_id, **validated_data ) - for provider in providers - ] - IntegrationProviderRelationship.objects.bulk_create(through_model_instances) + replace_integration_providers(integration, providers, tenant_id) return integration -class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer): +class IntegrationUpdateSerializer( + IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer +): credentials = IntegrationCredentialField(write_only=True, required=False) configuration = IntegrationConfigField(required=False) providers = serializers.ResourceRelatedField( @@ -3004,15 +3055,13 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer): def update(self, instance, validated_data): tenant_id = self.context.get("tenant_id") - if validated_data.get("providers") is not None: - instance.providers.clear() - new_relationships = [ - IntegrationProviderRelationship( - integration=instance, provider=provider, tenant_id=tenant_id - ) - for provider in validated_data["providers"] - ] - IntegrationProviderRelationship.objects.bulk_create(new_relationships) + # Relationships are replaced here, so they are kept out of the default + # `ModelSerializer.update()`, which would otherwise reset them all. The view + # rejects updates on integrations shared with providers hidden to the role, so + # every existing relationship is visible to the requester at this point + providers = validated_data.pop("providers", None) + if providers is not None: + replace_integration_providers(instance, providers, tenant_id) # Preserve regions field for Security Hub integrations if instance.integration_type == Integration.IntegrationChoices.AWS_SECURITY_HUB: @@ -3024,7 +3073,9 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer): return super().update(instance, validated_data) def to_representation(self, instance): - representation = super().to_representation(instance) + representation = self.hide_restricted_providers( + super().to_representation(instance) + ) # Ensure JIRA integrations show updated domain in configuration from credentials if instance.integration_type == Integration.IntegrationChoices.JIRA: representation["configuration"].update( diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index e392505818..db7b76f01a 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -124,7 +124,12 @@ from api.models import ( UserRoleRelationship, ) from api.pagination import ComplianceOverviewPagination -from api.rbac.permissions import Permissions, get_providers, get_role +from api.rbac.permissions import ( + Permissions, + get_integrations, + get_providers, + get_role, +) from api.renderers import APIJSONRenderer, PlainTextRenderer from api.rls import Tenant from api.utils import ( @@ -281,6 +286,7 @@ from django.shortcuts import redirect from django.urls import reverse from django.utils.dateparse import parse_date from django.utils.decorators import method_decorator +from django.utils.functional import cached_property from django.views.decorators.cache import cache_control from django_celery_beat.models import PeriodicTask from drf_spectacular.settings import spectacular_settings @@ -6652,27 +6658,34 @@ class ScheduleViewSet(BaseRLSViewSet): list=extend_schema( tags=["Integration"], summary="List all integrations", - description="Retrieve a list of all configured integrations with options for filtering by various criteria.", + description="Retrieve a list of all configured integrations with options for filtering by various criteria.\n\n" + "Integrations attached to one or more providers are only returned when the role can access at least one of " + "those providers, and each integration lists only the providers visible to the role. Integrations not " + "attached to any provider, such as Jira, are tenant-wide and are returned for every role.", ), retrieve=extend_schema( tags=["Integration"], summary="Retrieve integration details", - description="Fetch detailed information about a specific integration by its ID.", + description="Fetch detailed information about a specific integration by its ID. Integrations outside the " + "provider visibility of the role are reported the same way as one that does not exist.", ), create=extend_schema( tags=["Integration"], summary="Create a new integration", - description="Register a new integration with the system, providing necessary configuration details.", + description="Register a new integration with the system, providing necessary configuration details. Only " + "providers visible to the role can be attached to the integration.", ), partial_update=extend_schema( tags=["Integration"], summary="Partially update an integration", - description="Modify certain fields of an existing integration without affecting other settings.", + description="Modify certain fields of an existing integration without affecting other settings. Integrations " + "attached to providers outside the visibility of the role cannot be modified by it.", ), destroy=extend_schema( tags=["Integration"], summary="Delete an integration", - description="Remove an integration from the system by its ID.", + description="Remove an integration from the system by its ID. Integrations attached to providers outside " + "the visibility of the role cannot be deleted by it.", ), ) @method_decorator(CACHE_DECORATOR, name="list") @@ -6685,18 +6698,27 @@ class IntegrationViewSet(BaseRLSViewSet): ordering = ["integration_type", "-inserted_at"] # RBAC required permissions required_permissions = [Permissions.MANAGE_INTEGRATIONS] - allowed_providers = None + + @cached_property + def allowed_providers(self): + """ + Providers the role can access, or None when it has unlimited visibility. + + Resolved per request and independently of the action, so that writes are scoped + as tightly as reads. + """ + if self.user_role.unlimited_visibility: + return None + return get_providers(self.user_role) def get_queryset(self): - user_roles = get_role(self.request.user, self.request.tenant_id) - if user_roles.unlimited_visibility: - # User has unlimited visibility, return all integrations - queryset = Integration.objects.filter(tenant_id=self.request.tenant_id) - else: - # User lacks permission, filter providers based on provider groups associated with the role - allowed_providers = get_providers(user_roles) - queryset = Integration.objects.filter(providers__in=allowed_providers) - self.allowed_providers = allowed_providers + queryset = get_integrations(self.user_role, providers=self.allowed_providers) + if self.allowed_providers is not None and self.action in ("list", "retrieve"): + # Restrict the relationship itself, so that the providers hidden to the role + # are left out of the sideloaded resources of `?include=providers` too + queryset = queryset.prefetch_related( + Prefetch("providers", queryset=self.allowed_providers) + ) return queryset def get_serializer_class(self): @@ -6711,16 +6733,33 @@ class IntegrationViewSet(BaseRLSViewSet): context["allowed_providers"] = self.allowed_providers return context + def get_object(self): + instance = super().get_object() + # Writes on an integration shared with providers hidden to the role would reach + # beyond its visibility, so both editing and deleting are rejected consistently + if ( + self.action in ("partial_update", "destroy") + and self.allowed_providers is not None + and instance.providers.exclude( + id__in=self.allowed_providers.values("id") + ).exists() + ): + raise PermissionDenied( + "The integration is attached to providers outside the visibility of your role." + ) + return instance + @extend_schema( tags=["Integration"], summary="Check integration connection", - description="Try to verify integration connection", + description="Try to verify integration connection. Integrations outside the provider visibility of the role " + "are reported the same way as one that does not exist.", request=None, responses={202: OpenApiResponse(response=TaskSerializer)}, ) @action(detail=True, methods=["post"], url_name="connection") def connection(self, request, pk=None): - get_object_or_404(Integration, pk=pk) + get_object_or_404(self.get_queryset(), pk=pk) with transaction.atomic(): task = check_integration_connection_task.delay( integration_id=pk, tenant_id=self.request.tenant_id @@ -6743,7 +6782,8 @@ class IntegrationViewSet(BaseRLSViewSet): tags=["Integration"], summary="Send findings to a Jira integration", description="Send a set of filtered findings to the given integration. At least one finding filter must be " - "provided.\n\n" + "provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings " + "sent are limited to the providers the role can access.\n\n" "## Known Limitations\n\n" "### Issue Types with Required Custom Fields\n\n" "Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not " @@ -6787,24 +6827,37 @@ class IntegrationJiraViewSet(BaseRLSViewSet): return [] return super().get_filter_backends() - def get_queryset(self): - tenant_id = self.request.tenant_id - user_roles = get_role(self.request.user, self.request.tenant_id) - if user_roles.unlimited_visibility: - # User has unlimited visibility, return all findings - queryset = Finding.all_objects.filter(tenant_id=tenant_id) - else: - # User lacks permission, filter findings based on provider groups associated with the role - queryset = Finding.all_objects.filter( - scan__provider__in=get_providers(user_roles) - ) + @cached_property + def allowed_providers(self): + """ + Providers the role can access, or None when it has unlimited visibility. - return queryset + Resolved once per request and shared between the findings queryset and the + integration lookup. + """ + if self.user_role.unlimited_visibility: + return None + return get_providers(self.user_role) + + def get_queryset(self): + if self.allowed_providers is None: + # User has unlimited visibility, return all findings + return Finding.all_objects.filter(tenant_id=self.request.tenant_id) + # Findings are limited to the providers the role can access + return Finding.all_objects.filter(scan__provider__in=self.allowed_providers) + + def get_integration(self, integration_pk): + """Retrieve the integration, honoring the provider visibility of the user's role.""" + return get_object_or_404( + get_integrations(self.user_role, providers=self.allowed_providers), + pk=integration_pk, + ) @extend_schema( tags=["Integration"], summary="Get available issue types for a Jira project", - description="Fetch the available issue types from Jira for a given project key and update the integration configuration.", + description="Fetch the available issue types from Jira for a given project key and update the integration " + "configuration. Jira integrations are tenant-wide and do not require unlimited visibility.", parameters=[ OpenApiParameter( name="project_key", @@ -6817,7 +6870,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet): ) @action(detail=False, methods=["get"], url_name="issue-types") def issue_types(self, request, integration_pk=None): - integration = get_object_or_404(Integration, pk=integration_pk) + integration = self.get_integration(integration_pk) project_key = request.query_params.get("project_key") if not project_key: @@ -6862,23 +6915,23 @@ class IntegrationJiraViewSet(BaseRLSViewSet): @action(detail=False, methods=["post"], url_name="dispatches") def dispatches(self, request, integration_pk=None): - get_object_or_404(Integration, pk=integration_pk) + self.get_integration(integration_pk) serializer = self.get_serializer( data=request.data, context={"integration_id": integration_pk} ) serializer.is_valid(raise_exception=True) - if self.filter_queryset(self.get_queryset()).count() == 0: - raise ValidationError( - {"findings": "No findings match the provided filters"} - ) - finding_ids = [ str(finding_id) for finding_id in self.filter_queryset(self.get_queryset()).values_list( "id", flat=True ) ] + if not finding_ids: + raise ValidationError( + {"findings": "No findings match the provided filters"} + ) + project_key = serializer.validated_data["project_key"] issue_type = serializer.validated_data["issue_type"] diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 04251b4479..a079942600 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -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. diff --git a/api/src/backend/config/settings/sentry.py b/api/src/backend/config/settings/sentry.py index d75f9360e5..a3acbe37bb 100644 --- a/api/src/backend/config/settings/sentry.py +++ b/api/src/backend/config/settings/sentry.py @@ -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 diff --git a/api/src/backend/tasks/jobs/attack_paths/aws.py b/api/src/backend/tasks/jobs/attack_paths/aws.py index 15ecd86a19..4b2b96dd1b 100644 --- a/api/src/backend/tasks/jobs/attack_paths/aws.py +++ b/api/src/backend/tasks/jobs/attack_paths/aws.py @@ -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)" ) diff --git a/api/src/backend/tasks/jobs/attack_paths/config.py b/api/src/backend/tasks/jobs/attack_paths/config.py index d8ed63a8fc..122fc8a9e0 100644 --- a/api/src/backend/tasks/jobs/attack_paths/config.py +++ b/api/src/backend/tasks/jobs/attack_paths/config.py @@ -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 diff --git a/api/src/backend/tasks/jobs/attack_paths/findings.py b/api/src/backend/tasks/jobs/attack_paths/findings.py index 6cc7ddb2e0..c47c9f1149 100644 --- a/api/src/backend/tasks/jobs/attack_paths/findings.py +++ b/api/src/backend/tasks/jobs/attack_paths/findings.py @@ -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 diff --git a/api/src/backend/tasks/jobs/attack_paths/scan.py b/api/src/backend/tasks/jobs/attack_paths/scan.py index 32337c6832..e161c9eb8d 100644 --- a/api/src/backend/tasks/jobs/attack_paths/scan.py +++ b/api/src/backend/tasks/jobs/attack_paths/scan.py @@ -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 diff --git a/api/src/backend/tasks/jobs/attack_paths/sync.py b/api/src/backend/tasks/jobs/attack_paths/sync.py index 00c2c585c7..98a52bd48b 100644 --- a/api/src/backend/tasks/jobs/attack_paths/sync.py +++ b/api/src/backend/tasks/jobs/attack_paths/sync.py @@ -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") diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 39db32abe4..36dc806ff3 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -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 diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 7a1ff54131..a91ff85c01 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -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)) diff --git a/api/src/backend/tasks/tests/test_attack_paths_aws.py b/api/src/backend/tasks/tests/test_attack_paths_aws.py new file mode 100644 index 0000000000..dc2c59d614 --- /dev/null +++ b/api/src/backend/tasks/tests/test_attack_paths_aws.py @@ -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] diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py index 3be885a7fc..4409e5f19d 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -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( diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 2a66985276..cad5e3d343 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -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] diff --git a/api/uv.lock b/api/uv.lock index e579bb7b92..de878d9dc9 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -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" }, diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx index 913444d84b..e2bec0f94b 100644 --- a/docs/developer-guide/environment-variables.mdx +++ b/docs/developer-guide/environment-variables.mdx @@ -36,6 +36,9 @@ The former build-time variables map to the new runtime variables as follows: | `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` | | `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` | | `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` | +| `NEXT_PUBLIC_IS_CLOUD_ENV` | `UI_CLOUD_ENABLED` | + +`UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read. The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of Prowler Local Server's runtime configuration. diff --git a/docs/docs.json b/docs/docs.json index b9bc195182..4832909e1e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -80,7 +80,12 @@ { "group": "Prowler for AI Agents", "pages": [ - "getting-started/products/prowler-claude-code-plugin" + "user-guide/ai-agents/index", + "user-guide/ai-agents/claude-code", + "user-guide/ai-agents/claude-desktop", + "user-guide/ai-agents/codex", + "user-guide/ai-agents/cursor", + "user-guide/ai-agents/vscode" ] } ] @@ -217,7 +222,12 @@ { "group": "Prowler for AI Agents", "pages": [ - "getting-started/products/prowler-claude-code-plugin" + "user-guide/ai-agents/index", + "user-guide/ai-agents/claude-code", + "user-guide/ai-agents/claude-desktop", + "user-guide/ai-agents/codex", + "user-guide/ai-agents/cursor", + "user-guide/ai-agents/vscode" ] }, { @@ -660,6 +670,10 @@ { "source": "/user-guide/tutorials/prowler-cloud-public-ips", "destination": "/security/networking" + }, + { + "source": "/getting-started/products/prowler-claude-code-plugin", + "destination": "/user-guide/ai-agents/claude-code" } ] } diff --git a/docs/getting-started/basic-usage/prowler-mcp.mdx b/docs/getting-started/basic-usage/prowler-mcp.mdx index 120e5c038b..0a4c7fec04 100644 --- a/docs/getting-started/basic-usage/prowler-mcp.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp.mdx @@ -23,6 +23,28 @@ Most users should use the **Cloud MCP Server** — it needs no installation and - **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). +### Step-by-Step Guides Per Agent + +The tabs below are a quick configuration reference. For a walkthrough with screenshots, troubleshooting, and client-specific caveats, follow the dedicated guide for your agent: + + + + Plugin vs. MCP-only, and which Claude surfaces work + + + The Chat tab, via a local bridge + + + CLI and the VS Code extension + + + Global and project scopes + + + Agent mode with secure key prompts + + + ## 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. @@ -76,67 +98,6 @@ Connect to the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` over HTTP. The `mcp-remote` tool acts as a bridge for clients that don't support HTTP natively. Learn more at [mcp-remote on npm](https://www.npmjs.com/package/mcp-remote). - - - 1. Open Claude Desktop settings - 2. Go to "Developer" tab - 3. Click in "Edit Config" button - 4. Edit the `claude_desktop_config.json` file with your favorite editor - 5. Install a reviewed version of `mcp-remote` in a dedicated local workspace: - ```bash - mkdir -p ~/.local/share/prowler-mcp-bridge - cd ~/.local/share/prowler-mcp-bridge - npm init -y - npm install --save-exact mcp-remote@0.1.38 - ``` - 6. Add the following configuration: - ```json - { - "mcpServers": { - "prowler": { - "command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote", - "args": [ - "https://mcp.prowler.com/mcp", - "--header", - "Authorization: Bearer ${PROWLER_API_KEY}" - ], - "env": { - "PROWLER_API_KEY": "" - } - } - } - } - ``` - - - - Run the following command: - ```bash - export PROWLER_API_KEY="" - claude mcp add --transport http prowler https://mcp.prowler.com/mcp --header "Authorization: Bearer $PROWLER_API_KEY" --scope user - ``` - - - - 1. Open Cursor settings - 2. Go to "Tools & MCP" - 3. Click in "New MCP Server" button - 4. Add to the JSON Configuration the following: - ```json - { - "mcpServers": { - "prowler": { - "url": "https://mcp.prowler.com/mcp", - "headers": { - "Authorization": "Bearer " - } - } - } - } - ``` - - - ## Local MCP Server Configuration diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 0c2d032315..1994b8ad0f 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -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" ``` diff --git a/docs/getting-started/products/index.mdx b/docs/getting-started/products/index.mdx index c14ea37471..d8def66737 100644 --- a/docs/getting-started/products/index.mdx +++ b/docs/getting-started/products/index.mdx @@ -18,7 +18,7 @@ Read the [public announcement of the Prowler product families](https://prowler-w | Prowler Private Cloud | Prowler Cloud deployed in your own environment. Formerly Prowler Enterprise. See [pricing](https://prowler.com/pricing). | | [Prowler Hub](https://hub.prowler.com) | Free public library of versioned checks, cloud service artifacts, and compliance frameworks. | | [Prowler Lighthouse AI](/getting-started/products/prowler-cloud-lighthouse) | AI security analyst capabilities within Prowler Cloud and Prowler Private Cloud. | -| [Prowler MCP](/getting-started/products/prowler-mcp) | MCP server that connects AI assistants and agents to Prowler, including IDE plugins such as [Prowler for Claude Code](/getting-started/products/prowler-claude-code-plugin). | +| [Prowler MCP](/getting-started/products/prowler-mcp) | MCP server that connects AI assistants and agents to Prowler, including IDE plugins such as [Prowler for Claude Code](/user-guide/ai-agents/claude-code). | {/* Unreleased products. Uncomment these rows in the Prowler Products table when announced: | Prowler Registry | Distribution service for Prowler content such as checks and compliance frameworks. Free and paid tiers. | diff --git a/docs/getting-started/products/prowler-claude-code-plugin.mdx b/docs/getting-started/products/prowler-claude-code-plugin.mdx deleted file mode 100644 index 99c92a1488..0000000000 --- a/docs/getting-started/products/prowler-claude-code-plugin.mdx +++ /dev/null @@ -1,102 +0,0 @@ ---- -title: 'Prowler for Claude Code' -sidebarTitle: 'Claude Code' ---- - -End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](/getting-started/products/prowler-mcp). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant. - - -**Preview**: this plugin is under active development. Please report issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join the [Slack community](https://goto.prowler.com/slack) for feedback. - - -## Requirements - - - - Installed and signed in. See the [official install guide](https://www.claude.com/product/claude-code). - - - The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com). - - - Create one at [cloud.prowler.com/profile](https://cloud.prowler.com/profile). - - - -## Installation - - - - Inside a Claude Code session: - - ```text - /plugin marketplace add prowler-cloud/prowler - /plugin install prowler@prowler-plugins - ``` - - - If you already have the repository checked out: - - ```text - /plugin marketplace add /absolute/path/to/prowler - /plugin install prowler@prowler-plugins - ``` - - - -## Configuration - -On first install, Claude Code prompts for your **Prowler API key**. The value is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud. - - -To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again. - - -## Verify the installation - -In a Claude Code session: - -```text -/mcp → "prowler" appears as a connected server -/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage -``` - -If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts. - -## Usage - -Open a conversation that mentions the framework you want to comply with. Examples: - -- *"Make my AWS production account compliant with CIS 4.0."* -- *"Make my current Terraform project compliant with Prowler ThreatScore Compliance Framework based on the latest scan results."* -- *"Help me get to 100% on PCI-DSS for this GCP project."* - -You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**: - - - - Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying. - - - Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable. - - - -Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end. - -## Uninstalling - -```text -/plugin uninstall prowler@prowler-plugins -/plugin marketplace remove prowler-plugins -``` - -The stored API key is removed automatically. - -## Troubleshooting - -| Symptom | Likely cause | Fix | -| --- | --- | --- | -| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. | -| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. | -| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). | diff --git a/docs/getting-started/products/prowler-cloud-lighthouse.mdx b/docs/getting-started/products/prowler-cloud-lighthouse.mdx index 04bfd58224..d32118d468 100644 --- a/docs/getting-started/products/prowler-cloud-lighthouse.mdx +++ b/docs/getting-started/products/prowler-cloud-lighthouse.mdx @@ -24,6 +24,9 @@ The Agentic Cloud Defender does more than answer questions, it helps teams **fin Switch between the standard interface and a chat-first agentic view. + + Open Lighthouse AI as a side panel from any page to get help in context. + Credentials are validated automatically when a provider is configured. @@ -37,6 +40,20 @@ Promoting the chat to a top-level view gives Lighthouse AI the room it needs for 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. + +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. + +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? diff --git a/docs/getting-started/products/prowler-mcp.mdx b/docs/getting-started/products/prowler-mcp.mdx index 93159b2e7d..a30c4e5488 100644 --- a/docs/getting-started/products/prowler-mcp.mdx +++ b/docs/getting-started/products/prowler-mcp.mdx @@ -26,7 +26,7 @@ The fastest way to get started is the **Cloud MCP Server** at `https://mcp.prowl ``` - Step-by-step setup for Claude Desktop, Claude Code, Cursor, and other clients. + Step-by-step setup for Claude Code, Codex, Cursor, VS Code, and other agents. diff --git a/docs/images/organizations/authentication-details.png b/docs/images/organizations/authentication-details.png index 6aa72b5949..aec5060afd 100644 Binary files a/docs/images/organizations/authentication-details.png and b/docs/images/organizations/authentication-details.png differ diff --git a/docs/images/prowler-app/lighthouse/prowler-cloud/side-panel-closed.png b/docs/images/prowler-app/lighthouse/prowler-cloud/side-panel-closed.png new file mode 100644 index 0000000000..c36da34a95 Binary files /dev/null and b/docs/images/prowler-app/lighthouse/prowler-cloud/side-panel-closed.png differ diff --git a/docs/images/prowler-app/lighthouse/prowler-cloud/side-panel-open.png b/docs/images/prowler-app/lighthouse/prowler-cloud/side-panel-open.png new file mode 100644 index 0000000000..fd10f5a00b Binary files /dev/null and b/docs/images/prowler-app/lighthouse/prowler-cloud/side-panel-open.png differ diff --git a/docs/images/prowler-mcp/claude/claude-code-mcp-add.png b/docs/images/prowler-mcp/claude/claude-code-mcp-add.png new file mode 100644 index 0000000000..2f5894219c Binary files /dev/null and b/docs/images/prowler-mcp/claude/claude-code-mcp-add.png differ diff --git a/docs/images/prowler-mcp/claude/claude-code-mcp-command.png b/docs/images/prowler-mcp/claude/claude-code-mcp-command.png new file mode 100644 index 0000000000..c036ce8b61 Binary files /dev/null and b/docs/images/prowler-mcp/claude/claude-code-mcp-command.png differ diff --git a/docs/images/prowler-mcp/claude/claude-code-prowler-query.png b/docs/images/prowler-mcp/claude/claude-code-prowler-query.png new file mode 100644 index 0000000000..f78f5286e6 Binary files /dev/null and b/docs/images/prowler-mcp/claude/claude-code-prowler-query.png differ diff --git a/docs/images/prowler-mcp/claude/claude-desktop-developer-settings.png b/docs/images/prowler-mcp/claude/claude-desktop-developer-settings.png new file mode 100644 index 0000000000..494661238b Binary files /dev/null and b/docs/images/prowler-mcp/claude/claude-desktop-developer-settings.png differ diff --git a/docs/images/prowler-mcp/claude/claude-desktop-prowler-tools.png b/docs/images/prowler-mcp/claude/claude-desktop-prowler-tools.png new file mode 100644 index 0000000000..30d0ad7be8 Binary files /dev/null and b/docs/images/prowler-mcp/claude/claude-desktop-prowler-tools.png differ diff --git a/docs/images/prowler-mcp/codex/codex-app-mcp-servers.png b/docs/images/prowler-mcp/codex/codex-app-mcp-servers.png new file mode 100644 index 0000000000..3b735864f9 Binary files /dev/null and b/docs/images/prowler-mcp/codex/codex-app-mcp-servers.png differ diff --git a/docs/images/prowler-mcp/codex/codex-mcp-slash-command.png b/docs/images/prowler-mcp/codex/codex-mcp-slash-command.png new file mode 100644 index 0000000000..df3f8a65b3 Binary files /dev/null and b/docs/images/prowler-mcp/codex/codex-mcp-slash-command.png differ diff --git a/docs/images/prowler-mcp/codex/codex-prowler-query.png b/docs/images/prowler-mcp/codex/codex-prowler-query.png new file mode 100644 index 0000000000..b225933cfd Binary files /dev/null and b/docs/images/prowler-mcp/codex/codex-prowler-query.png differ diff --git a/docs/images/prowler-mcp/cursor/cursor-customize-page.png b/docs/images/prowler-mcp/cursor/cursor-customize-page.png new file mode 100644 index 0000000000..8afe41c1c5 Binary files /dev/null and b/docs/images/prowler-mcp/cursor/cursor-customize-page.png differ diff --git a/docs/images/prowler-mcp/cursor/cursor-mcp-json.png b/docs/images/prowler-mcp/cursor/cursor-mcp-json.png new file mode 100644 index 0000000000..79814b4db4 Binary files /dev/null and b/docs/images/prowler-mcp/cursor/cursor-mcp-json.png differ diff --git a/docs/images/prowler-mcp/cursor/cursor-prowler-connected.png b/docs/images/prowler-mcp/cursor/cursor-prowler-connected.png new file mode 100644 index 0000000000..ae946abdc5 Binary files /dev/null and b/docs/images/prowler-mcp/cursor/cursor-prowler-connected.png differ diff --git a/docs/images/prowler-mcp/cursor/cursor-prowler-query.png b/docs/images/prowler-mcp/cursor/cursor-prowler-query.png new file mode 100644 index 0000000000..3bdec29a36 Binary files /dev/null and b/docs/images/prowler-mcp/cursor/cursor-prowler-query.png differ diff --git a/docs/images/prowler-mcp/vscode/vscode-agent-tools.png b/docs/images/prowler-mcp/vscode/vscode-agent-tools.png new file mode 100644 index 0000000000..e1d90719d6 Binary files /dev/null and b/docs/images/prowler-mcp/vscode/vscode-agent-tools.png differ diff --git a/docs/images/prowler-mcp/vscode/vscode-command-palette.png b/docs/images/prowler-mcp/vscode/vscode-command-palette.png new file mode 100644 index 0000000000..453e973a36 Binary files /dev/null and b/docs/images/prowler-mcp/vscode/vscode-command-palette.png differ diff --git a/docs/images/prowler-mcp/vscode/vscode-list-servers.png b/docs/images/prowler-mcp/vscode/vscode-list-servers.png new file mode 100644 index 0000000000..596a6af443 Binary files /dev/null and b/docs/images/prowler-mcp/vscode/vscode-list-servers.png differ diff --git a/docs/images/prowler-mcp/vscode/vscode-mcp-json.png b/docs/images/prowler-mcp/vscode/vscode-mcp-json.png new file mode 100644 index 0000000000..01fbf91768 Binary files /dev/null and b/docs/images/prowler-mcp/vscode/vscode-mcp-json.png differ diff --git a/docs/style.css b/docs/style.css index 9a1ed19a0f..edbb1fbfcf 100644 --- a/docs/style.css +++ b/docs/style.css @@ -84,6 +84,7 @@ li[data-title="Prowler Lighthouse AI"] > button span:first-child::after, li[data-title="Providers"] > button span:first-child::after, li[data-title="Scans"] > button span:first-child::after, li[data-title="Prowler MCP"] > button span:first-child::after, +li[data-title="Prowler for AI Agents"] > button span:first-child::after, div:has(+ ul a[href="/security/encryption"]) h3 span::after, li[id="/user-guide/compliance/tutorials/cross-provider-compliance"] a > div > div > span:first-child::after, li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::after, diff --git a/docs/user-guide/ai-agents/claude-code.mdx b/docs/user-guide/ai-agents/claude-code.mdx new file mode 100644 index 0000000000..84763bf173 --- /dev/null +++ b/docs/user-guide/ai-agents/claude-code.mdx @@ -0,0 +1,294 @@ +--- +title: "Connect Claude Code to Prowler MCP Server" +sidebarTitle: "Claude Code" +--- + +Connect [Claude Code](https://www.claude.com/product/claude-code) to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp`. + +## Where Claude Code Runs + +Claude Code runs in two places. Both read the same configuration file, so you set it up **once from a terminal** and it works in both. + +| Surface | How you open it | Reads | Covered by | +|---|---|---|---| +| **Claude Code CLI** | `claude` in a terminal | `~/.claude.json` | This guide | +| **Claude Code in the desktop app** | The **Code** tab inside the Claude app | `~/.claude.json` — the same file | This guide, [set up from a terminal](#claude-code-in-the-desktop-app-code-tab) | +| **Claude app Chat** | The **Chat** tab inside the Claude app | `claude_desktop_config.json` | [Claude App Chat](/user-guide/ai-agents/claude-desktop) — a separate setup | + + +**The Chat tab is not Claude Code.** It is a different product surface with its own configuration file and its own connection method (a local bridge). Nothing on this page applies to it. If you want Prowler in Chat, use the [Claude App Chat](/user-guide/ai-agents/claude-desktop) guide instead. + + +## Choose Your Setup + +There are two ways to connect. Both end with the same MCP Server connection, the difference is what comes with it. + +| | 🔌 **Prowler Plugin** | ⚙️ **MCP Connection Only** | +|---|---|---| +| **What you get** | The MCP connection **plus** the official Prowler skills for cloud security tasks | The MCP connection | +| **Setup** | Two slash commands, prompts for the API key | One `claude mcp add` command | +| **Guided workflows** | ✅ Skills drive multi-step security work end to end | ❌ You drive the conversation | +| **Best for** | Structured cloud security work, such as taking an account to compliance | Ad-hoc queries and your own workflows | +| **Where to use it** | Claude Code CLI | Claude Code CLI, and the **recommended setup for the desktop app's [Code tab](#claude-code-in-the-desktop-app-code-tab)** | + + +**The plugin already includes the MCP connection.** If you install the plugin, do **not** also run `claude mcp add` — you would end up with the server configured twice. + + +## Prerequisites + +- **Claude Code** installed and signed in. See the [official install guide](https://www.claude.com/product/claude-code). +- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com). + +## Get Your Prowler API Key + +Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details. + +--- + +# Option 1: Install the Prowler Plugin + + +**Preview**: this plugin is under active development. Please report issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join the [Slack community](https://goto.prowler.com/slack) for feedback. + + +End-to-end cloud security from inside Claude Code, powered by the Prowler MCP server. The plugin bundles the official Prowler skills, task-specific workflows that let Claude carry out multi-step security work against a Prowler Cloud-connected account, rather than answering one question at a time. + +### Included Skills + +| Skill | What it does | +| --- | --- | +| `prowler:framework-compliance-triage` | Walks an account through a compliance assessment and remediates findings until the chosen security or industry framework is compliant. | + + +More skills are on the way. Installing the plugin keeps you current — new skills arrive with plugin updates, no extra configuration required. + + +## Installation (Claude Code CLI) + + + + Inside a Claude Code session: + + ```text + /plugin marketplace add prowler-cloud/prowler + /plugin install prowler@prowler-plugins + ``` + + + If you already have the repository checked out: + + ```text + /plugin marketplace add /absolute/path/to/prowler + /plugin install prowler@prowler-plugins + ``` + + + +On first install, Claude Code prompts for your **Prowler API key**. The value is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud. + +## Verify the Installation + +In a Claude Code session: + +```text +/mcp → "prowler" appears as a connected server +/plugin → "prowler" enabled, with the bundled Prowler skills listed +``` + +If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key, re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts. + +## Usage + +Describe the security task you want done and Claude selects the matching skill. + +### Framework Compliance Triage + +Mention the framework you want to comply with: + +- *"Make my AWS production account compliant with CIS 4.0."* +- *"Make my current Terraform project compliant with Prowler ThreatScore Compliance Framework based on the latest scan results."* +- *"Help me get to 100% on PCI-DSS for this GCP project."* + +You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**: + + + + Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying. + + + Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable. + + + +Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end. + +## Uninstalling + +```text +/plugin uninstall prowler@prowler-plugins +/plugin marketplace remove prowler-plugins +``` + +The stored API key is removed automatically. + +--- + +# Option 2: Connect the MCP Server Only + +Choose this when you want Prowler's tools available without the Prowler skills. + +## Add the Server + +Claude Code connects to remote HTTP MCP servers natively and supports custom headers, so no bridge is required. + +```bash +export PROWLER_API_KEY="pk_your_api_key_here" + +claude mcp add --transport http prowler https://mcp.prowler.com/mcp \ + --header "Authorization: Bearer $PROWLER_API_KEY" \ + --scope user +``` + + + Terminal showing the claude mcp add command and its confirmation output + + + +**Always pass `--scope user`.** The default scope is `local`, which binds the server to the single directory you ran the command in. A locally-scoped server does not load when you open Claude Code anywhere else — this is the most common reason Prowler tools appear to vanish. + + +| Scope | Loads in | Shared | Stored in | +|-------|----------|--------|-----------| +| `user` | All your projects | No | `~/.claude.json`, top-level `mcpServers` | +| `project` | Current project only | Yes, via version control | `.mcp.json` in the project root | +| `local` (default) | Current project only | No | `~/.claude.json`, under that project's entry | + +When the same server name exists in more than one scope, precedence is **local → project → user**. The winning entry is used whole; fields are not merged. + + +Avoid `--scope project` for Prowler. That writes `.mcp.json` into your repository, and committing the file would publish your API key. + + + +**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same. + + +## Verify the Connection + +```bash +claude mcp get prowler # shows which scope holds the definition +claude mcp list # lists all servers and their status +``` + +Inside a Claude Code session, run `/mcp` to see connected servers and their tools. + + + Claude Code session showing the /mcp command output with the Prowler server connected + + +## Start Using Prowler MCP + +- *"Show me all critical findings from my AWS accounts"* +- *"What does the S3 bucket public access check do?"* +- *"Onboard this new AWS account in my Prowler organization"* + + + Claude Code answering a question about critical findings using Prowler MCP tools + + +--- + +# Claude Code in the Desktop App (Code Tab) + +The **Code** tab in the Claude desktop app runs the same Claude Code as the CLI, and reads the same `~/.claude.json`. There is no separate Prowler setup for it — you configure it **from a terminal** and the Code tab picks it up. + + +**Use [Option 2](#option-2-connect-the-mcp-server-only) with `--scope user` here.** It is the recommended setup for the Code tab. The Prowler plugin ([Option 1](#option-1-install-the-prowler-plugin)) is not the recommended route for the desktop app — install it in the Claude Code CLI instead. + + + +**You cannot do this from inside the app.** The desktop app has no interface for adding an MCP server to a Claude Code session. **Settings → Connectors** configures the **Chat** tab, not the **Code** tab, so anything added there never reaches Claude Code. Trying to configure it from the app is the main reason this appears not to work. + + + + + In a normal terminal — not inside the app: + + ```bash + export PROWLER_API_KEY="pk_your_api_key_here" + + claude mcp add --transport http prowler https://mcp.prowler.com/mcp \ + --header "Authorization: Bearer $PROWLER_API_KEY" \ + --scope user + ``` + + `--scope user` is what makes this work. It writes to `~/.claude.json`, the file the Code tab reads. + + + + ```bash + claude mcp get prowler + ``` + + The scope must be `user`. A `local`-scoped server is bound to the directory you ran the command in and will not load in an app session opened elsewhere. + + + + Quit the app completely and reopen it. Configuration is read at startup. + + + + Open a **Code** tab session and ask for a Prowler tool: "Do you have access to the Prowler MCP tools?", it should respond with a list of available tools or confirming that it has access. + + + +--- + +# Claude App Chat (Chat Tab) + +Not covered by this page. The **Chat** tab is a separate surface: it does not read `~/.claude.json`, so a server added with `claude mcp add` appears in the CLI and in the Code tab but **never** in Chat. That is expected behavior, not a broken setup. + +Chat reads `claude_desktop_config.json` and reaches the Prowler MCP Server through a local bridge. + + + Separate guide: local bridge and its own configuration file + + +--- + +# Troubleshooting + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud. With the plugin, reinstall it to re-prompt. | +| No MCP servers configured | Server added at `local` scope from another directory | Run `claude mcp get prowler`, then re-add with `--scope user`. | +| A stale entry overrides a working one | Precedence is local → project → user | `claude mcp remove prowler --scope local` | +| Tools appear in the CLI but not in the app's **Code** tab | Server added at `local` scope, or the app was not restarted | Re-add with `--scope user`, then quit and reopen the app. See [Claude Code in the Desktop App](#claude-code-in-the-desktop-app-code-tab). | +| Tools appear in the **Code** tab but not the **Chat** tab | Chat is a different surface with its own config file | Expected. Set Chat up separately, see [Claude App Chat](/user-guide/ai-agents/claude-desktop). | +| No way to add the server from inside the app | The app has no MCP interface for Claude Code sessions | Configure it from a terminal with `--scope user`, then restart the app. See [Claude Code in the Desktop App](#claude-code-in-the-desktop-app-code-tab). | +| Skill not invoked when expected | The prompt didn't match any skill's description | Name the task explicitly. For compliance triage, mention the framework plus "compliance" or "compliant". | +| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). | + +### Authentication Fails With 401 + +- Confirm the header value includes the `Bearer ` prefix. +- Check that `PROWLER_API_KEY` was set when you ran `claude mcp add` — the shell expands it at that moment and stores the resulting literal value. If the variable was empty, the stored header reads `Bearer ` with nothing after it. Verify with `claude mcp get prowler`. +- Confirm the key has not been revoked in Prowler Cloud. + +## Next Steps + + + + Explore all available tools and capabilities + + + Configuration reference for every supported client + + + +## Getting Help + +- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues) +- Ask for help in our [Slack community](https://goto.prowler.com/slack) +- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new) diff --git a/docs/user-guide/ai-agents/claude-desktop.mdx b/docs/user-guide/ai-agents/claude-desktop.mdx new file mode 100644 index 0000000000..1a09c43116 --- /dev/null +++ b/docs/user-guide/ai-agents/claude-desktop.mdx @@ -0,0 +1,142 @@ +--- +title: "Connect the Claude App Chat to Prowler MCP Server" +sidebarTitle: "Claude App (Chat)" +--- + +Connect the **Chat** tab of the Claude desktop app to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp`. + + +**This page covers the Chat tab only.** Looking for **Claude Code** — either the CLI or the app's **Code** tab? Those are a different surface, with a different configuration file and a different connection method. See [Connect Claude Code](/user-guide/ai-agents/claude-code). + + +## Prerequisites + +- **Claude desktop app** installed and signed in. +- **Node.js and npm**, to install the bridge. +- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com). + +## Why "Add Custom Connector" Does Not Work + +The app's **Settings → Connectors → Add custom connector** dialog is the obvious place to paste an MCP URL, but it does not fit the Prowler Cloud MCP Server for two independent reasons: + +1. **Connectors authenticate with OAuth.** Authenticating with a fixed API key sent as a request header is a separate mechanism that Anthropic documents as **beta**, rolled out on request. Without it, the dialog offers a URL and OAuth client credentials, with nowhere to supply `Authorization: Bearer pk_...`. +2. **Connectors do not connect from your machine.** Claude reaches your MCP server from Anthropic's cloud infrastructure rather than your local device. A Prowler MCP Server on `localhost`, behind a VPN, or restricted by an IP allowlist is unreachable that way regardless of authentication. + +Use a local bridge instead, as described below. + +## Step 1: Get Your Prowler API Key + +Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details. + +## Step 2: Install the Bridge + +`mcp-remote` presents the remote HTTP server to Claude as a local STDIO server and injects the `Authorization` header. Install a pinned version into a dedicated directory: + +```bash +mkdir -p ~/.local/share/prowler-mcp-bridge +cd ~/.local/share/prowler-mcp-bridge +npm init -y +npm install --save-exact mcp-remote@0.1.38 +``` + + +Do not configure Claude to run `npx mcp-remote` directly. `npx` can fetch and execute a new version on every launch, which means unreviewed code runs with access to your API key. Install a pinned version and point Claude at the installed binary. + + + +`mcp-remote` is community-maintained and is not an Anthropic product. Review it before use. + + +## Step 3: Edit the Configuration File + +In the Claude app, go to **Settings → Developer** and click **Edit Config**. This reveals `claude_desktop_config.json`: + +- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` + + + Claude app Settings Developer tab showing the Edit Config button + + +Add the following, replacing the `command` path with the absolute path to the installed binary and the placeholder with your API key: + +```json +{ + "mcpServers": { + "prowler": { + "command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote", + "args": [ + "https://mcp.prowler.com/mcp", + "--header", + "Authorization: Bearer ${PROWLER_API_KEY}" + ], + "env": { + "PROWLER_API_KEY": "pk_your_api_key_here" + } + } + } +} +``` + + +**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same. + + +## Step 4: Restart the App + +Quit the Claude app completely and reopen it. Configuration is read at startup. + +## Step 5: Start Using Prowler MCP + +Open a Chat conversation and ask questions that use the Prowler tools: + +- *"Show me all critical findings from my AWS accounts"* +- *"What does the S3 bucket public access check do?"* +- *"Summarize my CIS compliance status by provider"* + + + Claude app chat showing the Prowler MCP tools available + + +## Troubleshooting + +### Server Does Not Appear After Editing the Config + +- Quit and reopen the app entirely — closing the window is not enough on macOS. +- Confirm `claude_desktop_config.json` is valid JSON. +- Confirm the `command` path points at a real executable. A wrong path surfaces as the server failing to start rather than as an auth error. + +### Tools Appear in Claude Code but Not in Chat + +Expected. The Chat tab does not read `~/.claude.json`, so servers added with `claude mcp add` never appear here. The Chat tab needs an entry in `claude_desktop_config.json`, which is what this guide sets up. + +### Authentication Fails With 401 + +- Confirm the header value includes the `Bearer ` prefix. +- Confirm the key has not been revoked in Prowler Cloud. + +### Checking the Logs + +- **macOS:** `~/Library/Logs/Claude/mcp*.log` +- **Windows:** `%APPDATA%\Claude\logs\mcp*.log` + +```bash +tail -f ~/Library/Logs/Claude/mcp*.log +``` + +## Next Steps + + + + Explore all available tools and capabilities + + + Configuration reference for every supported client + + + +## Getting Help + +- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues) +- Ask for help in our [Slack community](https://goto.prowler.com/slack) +- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new) diff --git a/docs/user-guide/ai-agents/codex.mdx b/docs/user-guide/ai-agents/codex.mdx new file mode 100644 index 0000000000..4a346e999f --- /dev/null +++ b/docs/user-guide/ai-agents/codex.mdx @@ -0,0 +1,188 @@ +--- +title: "Connect Codex / ChatGPT Desktop to Prowler MCP Server" +sidebarTitle: "Codex / ChatGPT" +--- + +Connect [OpenAI Codex](https://learn.chatgpt.com/docs/extend/mcp) to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so Codex can query findings, inspect checks, and manage your Prowler providers. + +## Which Codex Surfaces Work + +Codex keeps MCP servers in one file, `~/.codex/config.toml`. You can set it up from either the **Codex / ChatGPT desktop app** or the **Codex CLI** — both write to that same file, so pick whichever you already use. + +| Surface | Set it up here | Notes | +|---------|----------------|-------| +| **[Codex / ChatGPT desktop app](https://learn.chatgpt.com/docs/app)** (macOS, Windows) | ✅ Yes | **Settings → MCP servers** | +| **Codex CLI** (terminal) | ✅ Yes | `codex mcp` commands | +| **Codex IDE extension** (VS Code) | Inherits | Works automatically once the app or CLI is configured | +| **ChatGPT on the web** | ❌ No | Does not read local Codex configuration | + + +**Codex and ChatGPT share one desktop app.** Since July 2026 the standalone Codex app and the ChatGPT desktop app are the same application: Codex is a dedicated coding surface inside it, alongside Chat and Work. If you already had the Codex app, updating turns it into the new ChatGPT desktop app and it still opens in Codex. Either way, this guide applies. + +Not to be confused with **ChatGPT Classic**, the name given to the previous-generation ChatGPT desktop app. + + + +**Configure once, use everywhere.** The Codex documentation states that the ChatGPT desktop app, Codex CLI, and IDE extension "share this configuration. Once you configure your MCP servers, you can switch among those clients without redoing setup." Set the server up in the app or the CLI and the IDE extension picks it up with no extra work. + + +## Prerequisites + +- **The Codex / ChatGPT desktop app, or Codex CLI 0.46.0 or later.** Remote MCP servers over streamable HTTP were added to the CLI in 0.46.0 — check with `codex --version` and upgrade if needed. +- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com). + +## Step 1: Get Your Prowler API Key + +Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details. + +## Step 2: Add the Prowler MCP Server + +The Prowler MCP Server needs two request headers: `Authorization` to authenticate you, and `User-Agent` because Codex does not send one by default. + +Each tab below is a complete setup — follow the one that matches the surface you use. + + + + 1. Open **Settings** and select **Plugins → MCPs** + 2. Click **Add server** + 3. Enter `prowler` as the name and choose type **Streamable HTTP** + 4. Enter the URL `https://mcp.prowler.com/mcp` + 5. Add two headers: + + | Header | Value | + |--------|-------| + | `Authorization` | `Bearer pk_your_api_key_here` | + | `User-Agent` | `codex` | + + 6. Save the server + + + Codex / ChatGPT desktop app Settings showing the MCP servers panel with the Add server dialog and both headers filled in + + + + **Enter the key directly here rather than using an environment variable.** Codex can read credentials from an environment variable, but desktop applications do not reliably inherit variables exported in a shell profile — on macOS an app launched from Finder or the Dock typically sees none of them. Pasting the key into the dialog is the approach that works consistently in the app. + + + + **This stores your API key in plain text** in `~/.codex/config.toml`. Treat that file accordingly: exclude it from dotfile repositories and config sync, and create the key from an account with the minimum permissions you need so its exposure is limited. Revoke and re-issue the key in Prowler Cloud if the file is ever shared. + + + + + Register the server: + + ```bash + codex mcp add prowler --url https://mcp.prowler.com/mcp + ``` + + Codex confirms with `Added global MCP server 'prowler'.` + + Then add both headers by hand, since `codex mcp add` has no flag for headers. Open `~/.codex/config.toml` and complete the entry: + + ```toml + [mcp_servers.prowler] + url = "https://mcp.prowler.com/mcp" + http_headers = { Authorization = "Bearer pk_your_api_key_here", "User-Agent" = "codex" } + ``` + + + **Write the key literally rather than using an environment variable.** This is the form that works across every Codex surface. All of them read this same file, but only the CLI reliably sees variables exported in your shell profile — see the warning below. + + + + **This stores your API key in plain text** in `~/.codex/config.toml`. Treat that file accordingly: exclude it from dotfile repositories and config sync, and create the key from an account with the minimum permissions you need so its exposure is limited. Revoke and re-issue the key in Prowler Cloud if the file is ever shared. + + + + +Restart Codex once you are done. + + +**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same. + + +## Step 3: Verify the Connection + +Run `/mcp` in the app or in a CLI session to list connected servers and their tools. + + + Codex composer showing the /mcp command output with Prowler tools listed + + +From the CLI you can also inspect the stored entry directly: + +```bash +codex mcp list # one row per server, with status and auth +codex mcp get prowler # full entry, header values masked +``` + + +**Verify rather than assume.** Codex silently ignores unrecognized keys in `config.toml` — a misspelled key name produces no error at all, and the server simply never receives your credentials. Always confirm with `codex mcp get prowler` after editing the file by hand. + + +## Step 4: Start Using Prowler MCP + +Ask Codex questions that use the Prowler tools: + +- *"Show me all critical findings from my AWS accounts"* +- *"What does the S3 bucket public access check do?"* +- *"List my connected Prowler providers and their last scan date"* + + + Codex answering a question about critical findings using Prowler MCP tools + + +## Troubleshooting + +### Startup Fails With HTTP 403 Forbidden + +Codex reports a handshake failure on startup, with an HTML error page rather than a JSON response: + +``` +⚠ MCP client for `prowler` failed to start: MCP startup failed: handshaking with MCP server + failed: ... unexpected server response: HTTP 403: + 403 Forbidden +``` + +The `User-Agent` header is missing. Codex's HTTP client does not send one, and requests without it are rejected before reaching the MCP server. Note this is a **403**, not a 401 — so it is not an API key problem. Add the header as shown in [Step 2](#step-2-add-the-prowler-mcp-server); the value itself does not matter, only that the header is present. + +### Authentication Fails With 401 + +- Run `codex mcp get prowler` and confirm the entry has the headers you expect. Values are masked, but a missing header shows as `-`. +- If you used a literal header, confirm the value starts with `Bearer ` and contains the full key. +- **If it works in the CLI but fails in the desktop app or the VS Code extension, you are almost certainly using an environment variable.** Those surfaces do not inherit your shell profile. Switch that entry to a literal `Authorization` header as shown in [Step 2](#step-2-add-the-prowler-mcp-server). +- If you use an environment variable, verify it is set in the environment Codex was launched from: `echo $PROWLER_API_KEY`. +- With `env_http_headers` the variable must include the `Bearer ` prefix. With `bearer_token_env_var` it must **not** — Codex adds the prefix itself. +- Confirm the key has not been revoked in Prowler Cloud. + +### Server Not Listed + +- Confirm your Codex CLI version is 0.46.0 or later with `codex --version`. +- Run `codex mcp get prowler`. If it reports the server is not found, the entry was not written or the TOML table name is misspelled. +- Check for a typo in the key names. Codex ignores unknown keys without warning. + +### Project-Scoped Config Is Ignored + +A `.codex/config.toml` inside a project is loaded **only when the project is trusted**. If your entry lives there and does nothing, trust the project or move the entry to `~/.codex/config.toml`. + +### Tools Do Not Appear After Editing the Config + +Restart Codex. Configuration is read at startup. In the app, quit completely and reopen it, sometimes just clous the window is not enough. + +## Next Steps + + + + Explore all available tools and capabilities + + + Configuration reference for every supported client + + + +## Getting Help + +- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues) +- Ask for help in our [Slack community](https://goto.prowler.com/slack) +- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new) diff --git a/docs/user-guide/ai-agents/cursor.mdx b/docs/user-guide/ai-agents/cursor.mdx new file mode 100644 index 0000000000..5e28eeeb1f --- /dev/null +++ b/docs/user-guide/ai-agents/cursor.mdx @@ -0,0 +1,171 @@ +--- +title: "Connect Cursor to Prowler MCP Server" +sidebarTitle: "Cursor" +--- + +Connect [Cursor](https://cursor.com/docs/mcp) to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so the Cursor agent can query findings, inspect security checks, and manage your Prowler providers while you work. + +Cursor supports remote MCP servers over HTTP natively, so no bridge or local installation is required. + +## Prerequisites + +- **Cursor** installed and authenticated. See the [official install guide](https://cursor.com/download). +- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com). + +## Step 1: Get Your Prowler API Key + +Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details. + +## Step 2: Add the Prowler MCP Server + +Cursor reads MCP servers from an `mcp.json` file. Choose the scope that fits your use case: + +| Scope | File | Applies to | +|-------|------|------------| +| **Global** | `~/.cursor/mcp.json` | Every project you open in Cursor | +| **Project** | `.cursor/mcp.json` in the project root | That project only | + +Both files are merged. If the same server name appears in both, the project-level entry takes priority. + +For Prowler, the **global** scope is usually the right choice — your findings are not tied to a single repository, and it keeps the API key out of any project directory that might be committed. + + + + From Agent Window open **Customize** in the Cursor sidebar, then select the MCP section. + + On earlier versions, press `Cmd + Shift + J` (macOS) or `Ctrl + Shift + J` (Windows/Linux) to open Cursor Settings, then click **Tools & MCP** in the sidebar. + + + + + + Click **New MCP Server** (or **Add Custom MCP**). Cursor opens `mcp.json` in the editor. + + + Cursor Customize page with the MCP section open + + + + + Paste the following, replacing the placeholder with your API key: + + ```json + { + "mcpServers": { + "prowler": { + "url": "https://mcp.prowler.com/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } + } + ``` + + Save the file. Cursor picks up the change and connects to the server. + + + Cursor editor showing the completed mcp.json with the Prowler server entry + + + + + +**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same. + + +### Keeping the API Key Out of the File + +Cursor resolves variables in the `command`, `args`, `env`, `url`, and `headers` fields, so you can reference an environment variable instead of writing the key into `mcp.json`: + +```json +{ + "mcpServers": { + "prowler": { + "url": "https://mcp.prowler.com/mcp", + "headers": { + "Authorization": "Bearer ${env:PROWLER_API_KEY}" + } + } + } +} +``` + +Export the variable in your shell profile (`~/.zshrc`, `~/.bashrc`, or equivalent): + +```bash +export PROWLER_API_KEY="pk_your_api_key_here" +``` + + +The syntax is `${env:NAME}`, not a bare `${NAME}`. Restart Cursor after changing your shell profile so it inherits the new value. + + + +The `envFile` option does **not** work for remote servers — it is STDIO-only. Use `${env:...}` interpolation with variables set in your shell profile instead. + + +This form is strongly recommended when using a **project-scoped** `.cursor/mcp.json`, since that file may be committed to version control. + +## Step 3: Verify the Connection + +Return to the MCP settings. The `prowler` server should be listed as enabled, with the Prowler tools shown beneath it. + + + Cursor MCP settings showing the Prowler server connected with its tools listed + + +## Step 4: Start Using Prowler MCP + +Open the chat panel and ask questions that use the Prowler tools: + +- *"Show me all critical findings from my AWS accounts"* +- *"What does the S3 bucket public access check do?"* +- *"Which of my providers failed the most CIS checks in the last scan?"* + +Cursor asks for approval before running an MCP tool the first time. + + + Cursor chat answering a question about critical findings using Prowler MCP tools + + +You can toggle individual tools on or off from the tools list at the top of the chat panel, which is useful for keeping the active tool count down. + +## Troubleshooting + +### Server Does Not Connect + +- Check that `mcp.json` is valid JSON. A trailing comma or missing brace prevents the whole file from loading. +- Open **MCP Logs** in the Output panel for the specific error. +- Confirm the URL is exactly `https://mcp.prowler.com/mcp`. + +### Authentication Fails With 401 + +- Verify the header value includes the `Bearer ` prefix: `"Bearer pk_..."`, not just the key. +- Confirm the key has not been revoked in Prowler Cloud. +- If using `${env:PROWLER_API_KEY}`, check the variable is set in the environment Cursor inherits. Restart Cursor after editing your shell profile — a value exported only in an already-open terminal will not reach the app. + +### The Entire `mcp.json` Is Ignored + +Remove any `"type": "streamable-http"` field. One such entry causes the Cursor CLI to drop every server in the file silently. + +### Some Prowler Tools Are Missing + +Cursor limits how many tools it exposes to the agent at once. With several MCP servers enabled you may exceed it, and some tools become unavailable. Disable servers you are not using, or turn off individual tools from the chat panel's tools list. + +## Next Steps + + + + Explore all available tools and capabilities + + + Configuration reference for every supported client + + + +## Getting Help + +- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues) +- Ask for help in our [Slack community](https://goto.prowler.com/slack) +- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new) diff --git a/docs/user-guide/ai-agents/index.mdx b/docs/user-guide/ai-agents/index.mdx new file mode 100644 index 0000000000..ca05419050 --- /dev/null +++ b/docs/user-guide/ai-agents/index.mdx @@ -0,0 +1,49 @@ +--- +title: "Connect Your AI Agent to Prowler" +sidebarTitle: "Overview" +description: "Pick your AI agent and follow its guide to connect it to the Prowler Cloud MCP Server." +--- + +Connect your AI agent to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so it can query findings, inspect security checks, and manage your Prowler providers. + +Pick your agent below. Each guide is a full walkthrough with screenshots, verification steps, and the caveats specific to that client. + + + + Plugin and MCP-only choices, and which Claude surfaces work + + + The Chat tab, via a local bridge + + + ChatGPT Desktop App, Codex CLI, the VS Code extension through same config file + + + Agentic code editor. Global and project scopes + + + Agent mode with secure key prompts + + + +## Before You Start + +All guides need the same two things: + +- A **Prowler Cloud account** with at least one cloud provider connected. [Sign up](https://cloud.prowler.com) if you do not have one. +- A **Prowler API key**, created in Prowler Cloud. The key begins with `pk_` and is shown only once. See the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide. + + +Using an agent that is not listed here? Any MCP-compatible client can connect. See the [generic configuration reference](/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended) for the raw connection details. + + +## Next Steps + + + + How the MCP Server fits into Prowler + + + Cloud and local server options, all clients + + diff --git a/docs/user-guide/ai-agents/vscode.mdx b/docs/user-guide/ai-agents/vscode.mdx new file mode 100644 index 0000000000..90f41e5816 --- /dev/null +++ b/docs/user-guide/ai-agents/vscode.mdx @@ -0,0 +1,145 @@ +--- +title: "Connect VS Code and GitHub Copilot to Prowler MCP Server" +sidebarTitle: "VS Code / Copilot" +--- + +Connect [Visual Studio Code](https://code.visualstudio.com/docs/agents/reference/mcp-configuration) and GitHub Copilot agent mode to the Prowler Cloud MCP Server at `https://mcp.prowler.com/mcp` so Copilot can query findings, inspect security checks, and manage your Prowler providers. + +## Prerequisites + +- **VS Code 1.102 or later.** MCP support became generally available in 1.102. +- **GitHub Copilot** enabled, with access to agent mode. +- **A Prowler Cloud account.** The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com). + +## Step 1: Get Your Prowler API Key + +Create an API key in Prowler Cloud and copy it. The key begins with `pk_` and is shown only once. Check the [API Keys](/user-guide/tutorials/prowler-app-api-keys#creating-api-keys) guide for details. + +## Step 2: Add the Prowler MCP Server + +VS Code stores MCP servers in an `mcp.json` file. Choose the scope that fits your use case: + +| Scope | How to open it | Applies to | +|-------|----------------|------------| +| **User** | Command palette → **MCP: Open User Configuration** | Every workspace | +| **Workspace** | `.vscode/mcp.json` in the project root | That workspace only | + +For Prowler, the **user** scope is usually the right choice — your findings are not tied to a single repository, and it keeps the API key out of any project directory that might be committed. + + + + Open the command palette with `Cmd + Shift + P` (macOS) or `Ctrl + Shift + P` (Windows/Linux), then run **MCP: Open User Configuration**. + + VS Code opens your user-level `mcp.json`. Use this command rather than navigating to the file by hand — the file lives inside your active profile folder, and the path differs per profile. + + + VS Code command palette showing the MCP: Open User Configuration command + + + + + Paste the following. This version prompts you for the API key on first use and stores it securely, so the key is never written into the file: + + ```json + { + "inputs": [ + { + "type": "promptString", + "id": "prowler-api-key", + "description": "Prowler API Key", + "password": true + } + ], + "servers": { + "prowler": { + "type": "http", + "url": "https://mcp.prowler.com/mcp", + "headers": { + "Authorization": "Bearer ${input:prowler-api-key}" + } + } + } + } + ``` + + Save the file. + + + VS Code editor showing the completed mcp.json with the Prowler server entry + + + + + Start the server. VS Code prompts for the Prowler API key. Paste it and press Enter — VS Code stores it securely and does not ask again. + + + + + +**The root key is `servers`, not `mcpServers`.** VS Code uses a different schema from Cursor, Claude, and most other clients. Copying a `mcpServers` snippet from elsewhere silently fails to register the server. + + + +**Local server:** Replace the URL with your own HTTP endpoint. Everything else stays the same. + + +## Step 3: Verify the Connection + +Run **MCP: List Servers** from the command palette. The `prowler` server should appear as running. + + + VS Code MCP: List Servers output showing the Prowler server running + + +Select the server to start, stop, or restart it, and to view its output log if the connection fails. + +## Step 4: Start Using Prowler MCP + +Open the Chat view and switch the mode selector to **Agent**. Click the tools icon to confirm the Prowler tools are available, then ask: + +- *"Show me all critical findings from my AWS accounts"* +- *"What does the S3 bucket public access check do?"* +- *"Summarize my CIS compliance status by provider"* + + + VS Code Copilot Chat in agent mode showing the Prowler tools in the tools picker + + +Copilot asks for confirmation before running an MCP tool for the first time. + +## Troubleshooting + +### Server Does Not Appear + +- Confirm the root key is `servers`, not `mcpServers`. +- Confirm each server entry has `"type": "http"`. +- Check that `mcp.json` is valid JSON. +- Verify your VS Code version is 1.102 or later. + +### Authentication Fails With 401 + +- Verify the header value includes the `Bearer ` prefix. +- Confirm the key has not been revoked in Prowler Cloud. +- If you mistyped the key at the prompt, run **MCP: List Servers**, select `prowler`, and restart it to be prompted again. + +### Tools Do Not Appear in Chat + +- Make sure the Chat view is in **Agent** mode. MCP tools are not available in Ask mode. +- Open the tools picker and confirm the Prowler tools are enabled. + +## Next Steps + + + + Explore all available tools and capabilities + + + Configuration reference for every supported client + + + +## Getting Help + +- Search for existing [GitHub issues](https://github.com/prowler-cloud/prowler/issues) +- Ask for help in our [Slack community](https://goto.prowler.com/slack) +- Report a new issue on [GitHub](https://github.com/prowler-cloud/prowler/issues/new) diff --git a/docs/user-guide/cli/tutorials/parallel-execution.mdx b/docs/user-guide/cli/tutorials/parallel-execution.mdx index 93b8ef381b..d659116b50 100644 --- a/docs/user-guide/cli/tutorials/parallel-execution.mdx +++ b/docs/user-guide/cli/tutorials/parallel-execution.mdx @@ -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 diff --git a/docs/user-guide/cli/tutorials/reporting.mdx b/docs/user-guide/cli/tutorials/reporting.mdx index 19a14c9ae2..47e46dc2aa 100644 --- a/docs/user-guide/cli/tutorials/reporting.mdx +++ b/docs/user-guide/cli/tutorials/reporting.mdx @@ -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 |----------|----------|----------|----------|---------- diff --git a/docs/user-guide/providers/oci/authentication.mdx b/docs/user-guide/providers/oci/authentication.mdx index 9627e88cd4..cc0e75cded 100644 --- a/docs/user-guide/providers/oci/authentication.mdx +++ b/docs/user-guide/providers/oci/authentication.mdx @@ -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 diff --git a/docs/user-guide/providers/oci/getting-started-oci.mdx b/docs/user-guide/providers/oci/getting-started-oci.mdx index 2f610cd09c..6a8c8de3e9 100644 --- a/docs/user-guide/providers/oci/getting-started-oci.mdx +++ b/docs/user-guide/providers/oci/getting-started-oci.mdx @@ -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. diff --git a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx index 83554ee8c0..6d725bb5d0 100644 --- a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx @@ -24,6 +24,12 @@ When enabled and configured: 1. Security findings can be manually sent to Jira from the Findings table. 2. Each finding creates a Jira work item with all the check's metadata, including guidance on how to remediate it. +## Prerequisites + + + +Configuring and using the Jira integration requires the **Manage Integrations** permission. The Jira integration is tenant-wide, so it does not require **Unlimited Visibility** or any specific Provider Group. Findings sent to Jira are still limited to the providers the role can access. + ## Configuration To configure Jira integration in Prowler Cloud: diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index 4533e0423c..2598c6d9ec 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -128,7 +128,7 @@ To resend the invitation to the user, it is necessary to explicitly **delete the ## Managing Groups and Roles -Roles combine administrative permissions with provider visibility. Administrative permissions control the actions a role can perform. Provider Groups and Unlimited Visibility control the providers, resources, findings, scans, and compliance results the role can access. +Roles combine administrative permissions with provider visibility. Administrative permissions control the actions a role can perform. Provider Groups and Unlimited Visibility control the providers, resources, findings, scans, compliance results, and integrations the role can access. **Only users that have the _Manage Account_ or _admin_ permission can access this section.** @@ -142,6 +142,12 @@ New roles have no provider visibility by default. Assign at least one Provider G **Unlimited Visibility** grants organization-wide visibility across every provider, regardless of the Provider Groups assigned to the role. It does not grant administrative permissions. +#### Integration Visibility + + + +Integrations follow the visibility of the providers attached to them: a role can see an integration when it can access at least one of its providers, and only the providers visible to that role are listed on the integration. Editing or deleting an integration attached to providers outside the visibility of the role is not allowed. Integrations that are not attached to any provider, such as Jira, are tenant-wide and remain available to every role with the **Manage Integrations** permission. + #### Creating a Provider Group Follow these steps to create a provider group in your account: diff --git a/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx b/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx index 8d50f0072c..60e0db2f4c 100644 --- a/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx +++ b/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx @@ -8,7 +8,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" -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. @@ -54,6 +54,24 @@ gcp: storage_min_retention_days: 30 ``` +### Limiting the Scan Scope + + + +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 +``` + + +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. + + ## Creating a Scan Configuration diff --git a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx index 717bbdfc0e..8b2124e004 100644 --- a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx @@ -8,12 +8,14 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" -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. For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations). +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 -Onboarding deploys the **ProwlerScan Identity and Access Management (IAM) role** in your management account and in every member account. The wizard can deploy both from a **single CloudFormation stack** ([Step 4](#step-4-authenticate-with-your-management-account)), or you can deploy them yourself beforehand using Steps 1–2. The onboarding follows this sequence: + + +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). Onboarding flow: 1. Start the Wizard, 2. Deploy the Roles (single CloudFormation stack), 3. Discover and Connect, 4. Launch Scans -## 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. | By the wizard's **single stack** ([Step 4](#step-4-authenticate-with-your-management-account)), or on its own 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. | By the wizard's **single stack** ([Step 4](#step-4-authenticate-with-your-management-account)), or on its own via a **CloudFormation StackSet** ([Step 2](#step-2-deploy-the-cloudformation-stackset)). Automated across all accounts. | - - - Two Roles Architecture: ProwlerScan in management account (Quick Create or Manual, discovery + scanning) and ProwlerScan in member accounts (via StackSet, scanning only) - - - -**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). - - -### 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. - - -**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. The Prowler wizard works around this by having the **same** stack create the management account role (`DeployLocalRole=true`) alongside the StackSet, so a single deployment covers both. You can also create the management account role on its own via the Quick Create link ([Option A](#option-a-quick-create-link-fastest)) or manually ([Option B](#option-b-create-the-role-manually)). - - - -**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. - - -### 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. - - -**[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)). - - -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": "" - }, - "StringLike": { - "aws:PrincipalArn": "arn:aws:iam::232136659152:role/prowler*" - } - } - } - ] -} -``` - -Replace `` 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": "*" - } - ] -} -``` - - -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. - - -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:::role/ProwlerScan` - - -The role **must** be named `ProwlerScan`. Do not use a different name. - - - -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. - - -## Step 2: Deploy the CloudFormation StackSet - -This step deploys the **ProwlerScan** role to your member accounts using a CloudFormation StackSet. It is the **manual alternative** to letting the wizard's single stack create the StackSet for you ([Step 4](#step-4-authenticate-with-your-management-account)) — use it if you prefer to create and manage the StackSet yourself. - -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. - - -**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**. - - - -**The Prowler wizard now deploys this StackSet for you.** The **Create Stack in Management Account** button ([Step 4](#step-4-authenticate-with-your-management-account)) launches a single CloudFormation Stack that creates the management account role **and** a service-managed StackSet for your member accounts (`DeployStackSet=true`). Use the manual console steps below only if you prefer to create the StackSet yourself. - - - -**[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. - - -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. - - -**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. - - -### 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 - -Start the Prowler wizard. It walks you through deploying both roles — the management account role and the ProwlerScan role in member accounts — from a single CloudFormation stack ([Step 4](#step-4-authenticate-with-your-management-account)). If you already deployed them beforehand via Steps 1–2, the wizard simply picks up where you left off. +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,13 +74,13 @@ Start the Prowler wizard. It walks you through deploying both roles — the mana 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 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. +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. 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. +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 @@ -294,12 +88,20 @@ The wizard displays a **Prowler External ID** at the top — auto-generated and The wizard deploys the deployment account role and the member-account StackSet in a **single** CloudFormation Stack: + +**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). + + 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. + + 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 + + **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: @@ -311,17 +113,11 @@ aws organizations list-roots --query 'Roots[0].Id' --output text aws organizations list-organizational-units-for-parent --parent-id r-xxxx \ --query 'OrganizationalUnits[].{Name:Name,Id:Id}' --output table ``` - -If you deploy the CloudFormation template manually (instead of via the wizard link), set **`DeployStackSet=true`**, **`DeployLocalRole=true`**, and **`EnableOrganizations=true`**, then put the root/OU ID above into **`AWSOrganizationalUnitId`** (required whenever `DeployStackSet=true`). Leave **`DeployFromDelegatedAdmin=false`** unless you launch the stack from a delegated administrator account. - - 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 - - ### Enter the Deployment Account Role ARN -Paste the **Role ARN** created by the single stack above into the **Management Account Role ARN** field or, when delegated administrator mode is selected, the **Delegated Administrator Account Role ARN** field. If you deployed the management account role beforehand, use the role created in [Step 1](#step-1-create-the-management-account-role). +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: ``` @@ -334,6 +130,10 @@ For example: `arn:aws:iam::123456789012:role/ProwlerScan` Deployment account Role ARN field in the Authentication Details form + +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. + + ### Confirm and Discover 1. Check the box: **"The Stack has been successfully deployed in AWS"**. @@ -344,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 @@ -355,6 +155,7 @@ Once discovery completes, the wizard displays a **hierarchical tree view** of yo - 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. @@ -371,14 +172,12 @@ Only **ACTIVE** accounts can be selected for scanning: | **CLOSED** | No | Account has been closed. | -**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. ### Custom Aliases @@ -387,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. @@ -402,154 +196,225 @@ Click **Test Connections** to verify that Prowler can assume the **ProwlerScan** Connection testing in progress with spinners on each account -- 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. - - Test Connections button - - **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. Connection test results showing failed accounts with error banner and Skip Connection Validation button - -**Skip Connection Validation** is only available when at least one account connected successfully. - +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. Launch Scan step showing Accounts Connected confirmation, scan schedule selector, and Launch scan button -### 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/` -- 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. + + +**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. + + +### 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 `` 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": "" + }, + "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": "*" + } + ] +} +``` + + +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. + + +6. Name the role **`ProwlerScan`** and click **Create role**. The ARN follows the format `arn:aws:iam:::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. + + +**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**. + + +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. + + + Two Roles Architecture: ProwlerScan in management account (discovery + scanning) and ProwlerScan in member accounts (via StackSet, scanning only) + + +### 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 diff --git a/mcp_server/CHANGELOG.md b/mcp_server/CHANGELOG.md index ec0a2e354f..5898f816c9 100644 --- a/mcp_server/CHANGELOG.md +++ b/mcp_server/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to the **Prowler MCP Server** are documented in this file. +## [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 diff --git a/mcp_server/changelog.d/prowler-tools-namespace.changed.md b/mcp_server/changelog.d/prowler-tools-namespace.changed.md deleted file mode 100644 index 0ca03d15ca..0000000000 --- a/mcp_server/changelog.d/prowler-tools-namespace.changed.md +++ /dev/null @@ -1 +0,0 @@ -Core Prowler tool namespace from the `prowler_app_*` prefix to `prowler_*` diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index e2f20c952f..408c76d3e1 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [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 diff --git a/prowler/changelog.d/alibabacloud-security-group-policy-case.fixed.md b/prowler/changelog.d/alibabacloud-security-group-policy-case.fixed.md new file mode 100644 index 0000000000..cef4ff076c --- /dev/null +++ b/prowler/changelog.d/alibabacloud-security-group-policy-case.fixed.md @@ -0,0 +1 @@ +Alibaba Cloud SSH and RDP security group checks no longer produce false negatives when allowed rules use capitalized `Policy="Accept"` values diff --git a/prowler/changelog.d/bucket-validation-syntaxwarning.fixed.md b/prowler/changelog.d/bucket-validation-syntaxwarning.fixed.md new file mode 100644 index 0000000000..ceba09541c --- /dev/null +++ b/prowler/changelog.d/bucket-validation-syntaxwarning.fixed.md @@ -0,0 +1 @@ +Fix invalid escape sequence `SyntaxWarning` raised on startup by the S3 bucket name validation regex diff --git a/prowler/changelog.d/grouped-jira-dispatch.changed.md b/prowler/changelog.d/grouped-jira-dispatch.changed.md new file mode 100644 index 0000000000..8dd2e43ab5 --- /dev/null +++ b/prowler/changelog.d/grouped-jira-dispatch.changed.md @@ -0,0 +1 @@ +Jira output rendering supports grouped Finding Group issues with caller-provided links and capped or uncapped finding copy diff --git a/prowler/changelog.d/jira-tenant-info-request.security.md b/prowler/changelog.d/jira-tenant-info-request.security.md deleted file mode 100644 index 270ba01bfc..0000000000 --- a/prowler/changelog.d/jira-tenant-info-request.security.md +++ /dev/null @@ -1 +0,0 @@ -Jira tenant information requests validate site names and do not follow redirects diff --git a/prowler/changelog.d/sagemaker-notebook-no-secrets.added.md b/prowler/changelog.d/sagemaker-notebook-no-secrets.added.md new file mode 100644 index 0000000000..4789aa6aaf --- /dev/null +++ b/prowler/changelog.d/sagemaker-notebook-no-secrets.added.md @@ -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 diff --git a/prowler/config/config.py b/prowler/config/config.py index c76f79910a..bee2984db3 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -49,7 +49,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.35.0" +prowler_version = "5.36.0" html_logo_url = "https://github.com/prowler-cloud/prowler/" square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png" aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png" diff --git a/prowler/config/scan_config_schema.py b/prowler/config/scan_config_schema.py index ac00250c78..431c4cec60 100644 --- a/prowler/config/scan_config_schema.py +++ b/prowler/config/scan_config_schema.py @@ -9,21 +9,49 @@ The Prowler App, however, needs to surface those errors to the user when they save a Scan Config from the UI, and to expose the schema as JSON so the UI can validate live with `ajv`. This module provides: -- `validate_scan_config(payload)` — STRICT: returns a list of - `{path, message}` errors without silently dropping anything. The DRF - serializer (`api/.../v1/serializers.py:validate_scan_config_payload`) - turns each entry into a `ValidationError`. +- `validate_and_normalize_scan_config(payload)` — STRICT: returns + ``(normalized, errors)``. When ``errors`` is non-empty the normalized + dictionary is empty so callers never persist a partially validated + configuration. On success the normalized payload is JSON-serializable + (`model_dump(mode="json", exclude_unset=True)`), so the API can store + it directly in a Django ``JSONField`` and consume it at scan time + without re-running schema validation. + +- `validate_scan_config(payload)` — thin backward-compatible wrapper that + returns only the validation errors, preserved for callers that don't + need the normalized payload. - `SCAN_CONFIG_SCHEMA` — aggregated JSON Schema derived from the Pydantic models via `model_json_schema()`. Served by the `/scan-configs/schema` endpoint and consumed by the UI editor for in-editor live validation. """ +import json +from functools import lru_cache from typing import Any from pydantic import ValidationError from prowler.config.schema.registry import SCHEMAS +from prowler.lib.check.check import list_services +from prowler.lib.check.models import CheckMetadata + +# Pydantic v2 prefixes messages emitted from a ``field_validator`` that +# raises ``ValueError`` with this string. Strip it so the message that +# reaches the UI is the one the validator actually wrote. +_PYDANTIC_VALUE_ERROR_PREFIX = "Value error, " + + +@lru_cache(maxsize=None) +def _get_provider_check_ids(provider: str) -> frozenset[str]: + """Return cached check identifiers for a provider.""" + return frozenset(CheckMetadata.get_bulk(provider)) + + +@lru_cache(maxsize=None) +def _get_provider_services(provider: str) -> frozenset[str]: + """Return cached service identifiers for a provider.""" + return frozenset(list_services(provider)) def _format_loc(loc: tuple) -> str: @@ -50,48 +78,145 @@ def _format_loc(loc: tuple) -> str: return ".".join(parts) if parts else "" -def validate_scan_config(payload: Any) -> list[dict]: - """Validate a scan config payload against the registered provider schemas. +def validate_and_normalize_scan_config( + payload: Any, +) -> tuple[dict, list[dict[str, str]]]: + """Strict validation and normalization of a scan configuration payload. - Strict by design: every Pydantic violation surfaces as a `{path, message}` - entry so the caller can decide how to present it. Unknown provider - sections are accepted (consistent with `additionalProperties: True` at - the top level — the SDK simply has no opinion on them). + Returns ``(normalized, errors)``: + + - ``normalized`` is a JSON-serializable dict that mirrors the layout of + ``prowler/config/config.yaml`` (keyed by provider type). Registered + provider sections are dumped from their Pydantic models with + ``mode="json"`` (so the API can persist the result in a Django + ``JSONField``) and ``exclude_unset=True`` (so omitted defaults are + not injected into pre-existing configurations). Unknown provider + sections and unknown keys inside registered sections are preserved + untouched for forward compatibility with plugin-provided keys. + - ``errors`` is a list of ``{"path": , "message": }`` + entries, one per schema or exclusion-catalog violation. When any error + is present the normalized dictionary is returned empty so the caller + never persists a partially validated configuration. + + The input payload is never mutated. """ if not isinstance(payload, dict): - return [ + return {}, [ { "path": "", "message": "Scan config must be a mapping with provider sections.", } ] - errors: list[dict] = [] + errors: list[dict[str, str]] = [] + normalized: dict[str, Any] = {} + for provider, section in payload.items(): - schema_cls = SCHEMAS.get(provider) + # Reject non-string provider keys so distinct entries like ``123`` + # and ``"123"`` don't collide after ``str()`` in the normalized dict. + # YAML always produces string keys at this level; anything else + # comes from a hand-built payload and is a caller bug. + if not isinstance(provider, str): + errors.append( + { + "path": repr(provider), + "message": "provider keys must be strings.", + } + ) + continue + + provider_key = provider + schema_cls = SCHEMAS.get(provider_key) if schema_cls is None: - # Unknown provider type: tolerated. The SDK will simply ignore it. + # Unknown provider type: tolerated, but only when its contents + # are already JSON-serializable. The API persists the returned + # payload in a Django ``JSONField`` and would blow up at write + # time if we let a ``set()`` or similar through here. + try: + json.dumps(section) + except (TypeError, ValueError) as exc: + errors.append( + { + "path": provider_key, + "message": ( + "unknown provider section is not JSON-serializable: " + f"{exc}" + ), + } + ) + continue + normalized[provider_key] = section continue if not isinstance(section, dict): errors.append( { - "path": str(provider), + "path": provider_key, "message": "section must be a mapping.", } ) continue try: - schema_cls.model_validate(section) + model = schema_cls.model_validate(section) except ValidationError as exc: for err in exc.errors(): loc = err.get("loc") or () - path = _format_loc((str(provider), *loc)) - errors.append( - { - "path": path, - "message": err.get("msg", "validation error"), - } - ) + path = _format_loc((provider_key, *loc)) + message = err.get("msg", "validation error") + # Only strip on the specific error type that pydantic + # prefixes — a legitimate future message that happens to + # start with "Value error, " keeps its text intact. + if err.get("type") == "value_error" and message.startswith( + _PYDANTIC_VALUE_ERROR_PREFIX + ): + message = message[len(_PYDANTIC_VALUE_ERROR_PREFIX) :] + errors.append({"path": path, "message": message}) + continue + + if model.excluded_checks: + available_checks = _get_provider_check_ids(provider_key) + for index, check in enumerate(model.excluded_checks): + if check not in available_checks: + errors.append( + { + "path": f"{provider_key}.excluded_checks[{index}]", + "message": ( + f"Unknown check '{check}' for provider " + f"'{provider_key}'." + ), + } + ) + + if model.excluded_services: + available_services = _get_provider_services(provider_key) + for index, service in enumerate(model.excluded_services): + if service not in available_services: + errors.append( + { + "path": f"{provider_key}.excluded_services[{index}]", + "message": ( + f"Unknown service '{service}' for provider " + f"'{provider_key}'." + ), + } + ) + + normalized[provider_key] = model.model_dump(mode="json", exclude_unset=True) + + if errors: + return {}, errors + return normalized, [] + + +def validate_scan_config(payload: Any) -> list[dict]: + """Backward-compatible wrapper returning only validation errors. + + Preserved for callers that only need the strict-validation error list + (e.g. the DRF serializer that turns each entry into a + ``ValidationError``). New callers should prefer + :func:`validate_and_normalize_scan_config` to also receive the + normalized payload. + """ + _, errors = validate_and_normalize_scan_config(payload) return errors diff --git a/prowler/config/schema/base.py b/prowler/config/schema/base.py index cc473a4545..fc5a76af43 100644 --- a/prowler/config/schema/base.py +++ b/prowler/config/schema/base.py @@ -1,4 +1,12 @@ -from pydantic import BaseModel, ConfigDict +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator + +# Item type for excluded_checks / excluded_services list entries. Item +# whitespace is stripped via ``str_strip_whitespace`` on the base +# ``model_config`` (no second stripping implementation added here), so +# ``min_length=1`` catches "", " ", and any all-whitespace input uniformly. +NonEmptyScopeIdentifier = Annotated[str, StringConstraints(min_length=1)] class ProviderConfigBase(BaseModel): @@ -15,3 +23,28 @@ class ProviderConfigBase(BaseModel): str_strip_whitespace=True, validate_assignment=False, ) + + excluded_checks: list[NonEmptyScopeIdentifier] = Field( + default_factory=list, + description="Check identifiers to exclude from the scan scope.", + json_schema_extra={"default": [], "uniqueItems": True}, + ) + excluded_services: list[NonEmptyScopeIdentifier] = Field( + default_factory=list, + description="Service identifiers to exclude from the scan scope.", + json_schema_extra={"default": [], "uniqueItems": True}, + ) + + @field_validator("excluded_checks", "excluded_services") + @classmethod + def _reject_duplicates(cls, value: list[str]) -> list[str]: + seen: set[str] = set() + duplicates: set[str] = set() + for item in value: + if item in seen: + duplicates.add(item) + else: + seen.add(item) + if duplicates: + raise ValueError(f"duplicate values are not allowed: {sorted(duplicates)}") + return value diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 32ee8af4eb..601120cdfc 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -417,6 +417,19 @@ class Jira: message=init_error, file=os.path.basename(__file__) ) + @staticmethod + def _sanitize_summary(summary: str) -> str: + """Normalize and truncate a Jira issue summary. + + Args: + summary: Raw summary text. + + Returns: + The summary collapsed to one line and limited to Jira's 255-character + summary maximum. + """ + return " ".join(summary.split())[:255] + @staticmethod def _build_code_block_content(code_value: str) -> Optional[Dict]: if not code_value: @@ -1155,6 +1168,101 @@ class Jira: return "#0000FF" return "#000000" # Default black color for unknown severities + @staticmethod + def _adf_colored_strong_marks(color_mark_type: str, color: str) -> list[dict]: + """Build ADF marks for bold text with a Jira color mark. + + Args: + color_mark_type: Jira ADF color mark type, such as textColor or + backgroundColor. + color: Hex color value for the mark. + + Returns: + ADF marks for strong colored text. + """ + return [ + {"type": "strong"}, + {"type": color_mark_type, "attrs": {"color": color}}, + ] + + def _adf_severity_marks( + self, severity: str = "", severity_color: str | None = None + ) -> list[dict]: + """Build ADF marks for severity text. + + Args: + severity: Finding severity used to derive a color when severity_color + is not provided. + severity_color: Optional explicit severity color. + + Returns: + ADF marks for highlighted severity text. + """ + color = severity_color or self.get_severity_color(str(severity).lower()) + return self._adf_colored_strong_marks("backgroundColor", color) + + def _adf_status_marks( + self, status: str = "", status_color: str | None = None + ) -> list[dict]: + """Build ADF marks for status text. + + Args: + status: Finding status used to derive a color when status_color is + not provided. + status_color: Optional explicit status color. + + Returns: + ADF marks for colored status text. + """ + color = status_color or self.get_color_from_status(str(status).upper()) + return self._adf_colored_strong_marks("textColor", color) + + @staticmethod + def _adf_text_node(text: str, marks: list[dict] | None = None) -> dict: + """Build an ADF text node. + + Args: + text: Text content for the node. + marks: Optional ADF marks to apply to the text. + + Returns: + ADF text node with optional marks. + """ + node = {"type": "text", "text": text} + if marks: + node["marks"] = marks + return node + + def _adf_severity_text_node( + self, severity: str = "", severity_color: str | None = None + ) -> dict: + """Build an ADF text node for severity. + + Args: + severity: Severity text to render. + severity_color: Optional explicit severity color. + + Returns: + ADF text node with severity marks. + """ + return self._adf_text_node( + severity, self._adf_severity_marks(severity, severity_color) + ) + + def _adf_status_text_node( + self, status: str = "", status_color: str | None = None + ) -> dict: + """Build an ADF text node for status. + + Args: + status: Status text to render. + status_color: Optional explicit status color. + + Returns: + ADF text node with status marks. + """ + return self._adf_text_node(status, self._adf_status_marks(status, status_color)) + def get_adf_description( self, check_id: str = "", @@ -1293,19 +1401,9 @@ class Jira: { "type": "paragraph", "content": [ - { - "type": "text", - "text": severity, - "marks": [ - {"type": "strong"}, - { - "type": "backgroundColor", - "attrs": { - "color": severity_color, - }, - }, - ], - } + self._adf_severity_text_node( + severity, severity_color + ) ], } ], @@ -1338,17 +1436,7 @@ class Jira: { "type": "paragraph", "content": [ - { - "type": "text", - "text": status, - "marks": [ - {"type": "strong"}, - { - "type": "textColor", - "attrs": {"color": status_color}, - }, - ], - } + self._adf_status_text_node(status, status_color) ], } ], @@ -1872,6 +1960,239 @@ class Jira: ], } + def get_grouped_adf_description( + self, + check_id: str = "", + check_title: str = "", + check_description: str = "", + severity: str = "", + status: str = "", + provider: str = "", + service: str = "", + affected_failing_resources: int = 0, + last_seen: str = "", + failing_for: str = "", + grouped_resources: list[dict] | None = None, + resources_total: int = 0, + resources_shown: int = 0, + finding_group_url: str = "", + finding_group_link_text: str = "", + risk: str = "", + recommendation_text: str = "", + recommendation_url: str = "", + ) -> dict: + """Build a Jira ADF description for a grouped finding issue. + + Args: + check_id: Finding check ID. + check_title: Finding check title. + check_description: Finding check description. + severity: Finding group severity. + status: Finding group status. + provider: Cloud provider name. + service: Provider service name. + affected_failing_resources: Number of failing resources in the group. + last_seen: Last time the finding group was seen. + failing_for: Duration the finding group has been failing. + grouped_resources: Resource rows to include in the grouped issue. + resources_total: Total number of resources in the group. + resources_shown: Number of resources rendered in this Jira issue. + finding_group_url: Optional URL for the full finding group. + finding_group_link_text: Optional link text for finding_group_url. + risk: Risk description for the check. + recommendation_text: Remediation recommendation text. + recommendation_url: Optional remediation recommendation URL. + + Returns: + Jira ADF document describing the finding group. + """ + + def _safe(value) -> str: + return str(value) if value not in (None, "") else "-" + + def _text(value, marks: list[dict] | None = None) -> dict: + node = {"type": "text", "text": _safe(value)} + if marks: + node["marks"] = marks + return node + + def _paragraph(value, marks: list[dict] | None = None) -> dict: + return {"type": "paragraph", "content": [_text(value, marks)]} + + def _cell(value, marks: list[dict] | None = None) -> dict: + return {"type": "tableCell", "content": [_paragraph(value, marks)]} + + def _content_cell(content: list[dict]) -> dict: + return {"type": "tableCell", "content": content} + + def _append_link(content: list[dict], url: str) -> list[dict]: + if not url: + return content + + link_node = { + "type": "text", + "text": url, + "marks": [{"type": "link", "attrs": {"href": url}}], + } + if content and content[-1].get("type") == "paragraph": + paragraph_content = content[-1].setdefault("content", []) + if paragraph_content: + last_inline = paragraph_content[-1] + if last_inline.get("type") != "text" or not last_inline.get( + "text", "" + ).endswith(" "): + paragraph_content.append({"type": "text", "text": " "}) + paragraph_content.append(link_node) + else: + content.append({"type": "paragraph", "content": [link_node]}) + return content + + def _row(cells: list[dict]) -> dict: + return {"type": "tableRow", "content": cells} + + strong = [{"type": "strong"}] + code = [{"type": "code"}] + severity_marks = self._adf_severity_marks(severity) + status_marks = self._adf_status_marks(status) + recommendation_content = _append_link( + self._markdown_converter.convert(_safe(recommendation_text)), + recommendation_url, + ) + main_rows = [ + _row([_cell("Check Id", strong), _cell(check_id, code)]), + _row([_cell("Check Title", strong), _cell(check_title)]), + _row([_cell("Severity", strong), _cell(severity, severity_marks)]), + _row([_cell("Status", strong), _cell(status, status_marks)]), + _row([_cell("Provider", strong), _cell(provider, code)]), + _row([_cell("Service", strong), _cell(service, code)]), + _row( + [ + _cell("Affected Failing Resources", strong), + _cell(affected_failing_resources, strong), + ] + ), + _row([_cell("Last Seen", strong), _cell(last_seen)]), + _row([_cell("Failing For", strong), _cell(failing_for)]), + _row( + [ + _cell("Risk", strong), + _content_cell(self._markdown_converter.convert(_safe(risk))), + ] + ), + _row( + [ + _cell("Recommendation", strong), + _content_cell(recommendation_content), + ] + ), + ] + + resource_rows = [ + _row( + [ + _cell("Resource", strong), + _cell("Resource UID", strong), + _cell("Provider", strong), + _cell("Service", strong), + _cell("Account / Tenant", strong), + _cell("Status", strong), + _cell("Severity", strong), + _cell("Region", strong), + _cell("Last Seen", strong), + _cell("Failing For", strong), + _cell("Triage", strong), + ] + ) + ] + for resource in grouped_resources or []: + resource_status = resource.get("status") + resource_severity = str(resource.get("severity", "")).upper() + resource_status_marks = self._adf_status_marks(resource_status) + resource_severity_marks = self._adf_severity_marks(resource_severity) + resource_rows.append( + _row( + [ + _cell(resource.get("resource_name"), code), + _cell(resource.get("resource_uid"), code), + _cell(resource.get("provider"), code), + _cell(resource.get("service"), code), + _cell(resource.get("provider_account"), code), + _cell(resource_status, resource_status_marks), + _cell(resource_severity, resource_severity_marks), + _cell(resource.get("region"), code), + _cell(resource.get("last_seen")), + _cell(resource.get("failing_for")), + _cell(resource.get("triage")), + ] + ) + ) + + content = [ + _paragraph("Prowler has discovered the following Finding Group:"), + {"type": "table", "attrs": {"layout": "full-width"}, "content": main_rows}, + ] + + content.extend( + [ + { + "type": "heading", + "attrs": {"level": 2}, + "content": [_text("Affected failing resources")], + }, + { + "type": "table", + "attrs": {"layout": "full-width"}, + "content": resource_rows, + }, + ] + ) + + if resources_total > resources_shown: + remaining_content = [ + _text(f"Showing {resources_shown} of {resources_total} Findings.") + ] + if finding_group_url and finding_group_link_text: + remaining_content = [ + _text( + f"Showing {resources_shown} of {resources_total} Findings " + "in this Jira issue. " + ), + _text( + finding_group_link_text, + [ + { + "type": "link", + "attrs": {"href": finding_group_url}, + } + ], + ), + ] + content.append( + { + "type": "paragraph", + "content": remaining_content, + } + ) + elif finding_group_url and finding_group_link_text: + content.append( + { + "type": "paragraph", + "content": [ + _text( + finding_group_link_text, + [ + { + "type": "link", + "attrs": {"href": finding_group_url}, + } + ], + ), + ], + } + ) + + return {"type": "doc", "version": 1, "content": content} + def send_findings( self, findings: list[Finding] = None, @@ -1965,7 +2286,7 @@ class Jira: summary_parts.append(finding.resource_uid) summary = " - ".join(summary_parts[1:]) - summary = f"{summary_parts[0]} {summary}"[:255] + summary = self._sanitize_summary(f"{summary_parts[0]} {summary}") payload = { "fields": { @@ -2048,11 +2369,13 @@ class Jira: self, check_id: str = "", check_title: str = "", + check_description: str = "", severity: str = "", status: str = "", status_extended: str = "", provider: str = "", region: str = "", + service: str = "", resource_uid: str = "", resource_name: str = "", risk: str = "", @@ -2069,6 +2392,14 @@ class Jira: issue_labels: list[str] = "", finding_url: str = "", tenant_info: str = "", + affected_failing_resources: int = 0, + grouped_resources: list[dict] | None = None, + resources_total: int = 0, + resources_shown: int = 0, + last_seen: str = "", + failing_for: str = "", + finding_group_url: str = "", + finding_group_link_text: str = "", ) -> bool: """ Send the finding to Jira @@ -2076,11 +2407,13 @@ class Jira: Args: - check_id: The check ID - check_title: The check title + - check_description: The check description - severity: The severity - status: The status - status_extended: The status extended - provider: The provider - region: The region + - service: The service - resource_uid: The resource UID - resource_name: The resource name - risk: The risk @@ -2097,6 +2430,15 @@ class Jira: - issue_labels: The issue labels - finding_url: The finding URL - tenant_info: The tenant info + - affected_failing_resources: The number of affected failing resources + - grouped_resources: The grouped resources to render, or None for a + single finding issue + - resources_total: The total resources in the finding group + - resources_shown: The resources shown in the Jira issue + - last_seen: The last time the finding group was seen + - failing_for: The duration the finding group has been failing + - finding_group_url: The finding group URL + - finding_group_link_text: The link text for the finding group URL Raises: - JiraRefreshTokenError: Failed to refresh the access token @@ -2140,40 +2482,66 @@ class Jira: status_color = self.get_color_from_status(status) severity_color = self.get_severity_color(severity.lower()) - adf_description = self.get_adf_description( - check_id=check_id, - check_title=check_title, - severity=severity.upper(), - severity_color=severity_color, - status=status, - status_color=status_color, - status_extended=status_extended, - provider=provider, - region=region, - resource_uid=resource_uid, - resource_name=resource_name, - risk=risk, - recommendation_text=recommendation_text, - recommendation_url=recommendation_url, - remediation_code_native_iac=remediation_code_native_iac, - remediation_code_terraform=remediation_code_terraform, - remediation_code_cli=remediation_code_cli, - remediation_code_other=remediation_code_other, - resource_tags=resource_tags, - compliance=compliance, - finding_url=finding_url, - tenant_info=tenant_info, - ) + if grouped_resources is not None: + adf_description = self.get_grouped_adf_description( + check_id=check_id, + check_title=check_title, + check_description=check_description, + severity=severity.upper(), + status=status, + provider=provider, + service=service, + affected_failing_resources=affected_failing_resources, + last_seen=last_seen, + failing_for=failing_for, + grouped_resources=grouped_resources, + resources_total=resources_total, + resources_shown=resources_shown, + finding_group_url=finding_group_url, + finding_group_link_text=finding_group_link_text, + risk=risk, + recommendation_text=recommendation_text, + recommendation_url=recommendation_url, + ) + else: + adf_description = self.get_adf_description( + check_id=check_id, + check_title=check_title, + severity=severity.upper(), + severity_color=severity_color, + status=status, + status_color=status_color, + status_extended=status_extended, + provider=provider, + region=region, + resource_uid=resource_uid, + resource_name=resource_name, + risk=risk, + recommendation_text=recommendation_text, + recommendation_url=recommendation_url, + remediation_code_native_iac=remediation_code_native_iac, + remediation_code_terraform=remediation_code_terraform, + remediation_code_cli=remediation_code_cli, + remediation_code_other=remediation_code_other, + resource_tags=resource_tags, + compliance=compliance, + finding_url=finding_url, + tenant_info=tenant_info, + ) summary_parts = ["[Prowler]"] if severity: summary_parts.append(severity.upper()) if check_id: summary_parts.append(check_id) - if resource_uid: + if grouped_resources is not None: + summary_parts.append( + f"{affected_failing_resources} affected failing resources" + ) + elif resource_uid: summary_parts.append(resource_uid) summary = " - ".join(summary_parts[1:]) - summary = f"{summary_parts[0]} {summary}"[:255] + summary = self._sanitize_summary(f"{summary_parts[0]} {summary}") payload = { "fields": { diff --git a/prowler/lib/scan/scan.py b/prowler/lib/scan/scan.py index 4bef660d33..b87cfcdf1d 100644 --- a/prowler/lib/scan/scan.py +++ b/prowler/lib/scan/scan.py @@ -178,25 +178,58 @@ class Scan: ) ) - # Exclude checks + # Validate excluded checks against the FULL provider catalog — not + # just the selected scope — so a global config can exclude a valid + # check even when that check is not part of a particular scoped run. + excluded_check_set: set[str] = set() if excluded_checks: - for check in excluded_checks: - if check in self._checks_to_execute: - self._checks_to_execute.remove(check) - else: - raise ScanInvalidCheckError( - f"Invalid check provided: {check}. Check does not exist in the provider." - ) + excluded_check_set = set(excluded_checks) + if len(excluded_check_set) != len(excluded_checks): + raise ScanInvalidCheckError( + "Duplicate excluded checks are not allowed." + ) + unknown_checks = excluded_check_set.difference(self._bulk_checks_metadata) + if unknown_checks: + raise ScanInvalidCheckError( + f"Invalid excluded check(s) provided: {sorted(unknown_checks)}." + ) - # Exclude services + # Validate excluded services against the provider service catalog. + # Only resolve the catalog when there is something to check to avoid + # walking the provider package tree unnecessarily. + excluded_service_set: set[str] = set() if excluded_services: - for check in self._checks_to_execute: - if get_service_name_from_check_name(check) in excluded_services: - self._checks_to_execute.remove(check) - else: - raise ScanInvalidServiceError( - f"Invalid service provided: {check}. Service does not exist in the provider." - ) + excluded_service_set = set(excluded_services) + if len(excluded_service_set) != len(excluded_services): + raise ScanInvalidServiceError( + "Duplicate excluded services are not allowed." + ) + unknown_services = excluded_service_set.difference( + list_services(provider.type) + ) + if unknown_services: + raise ScanInvalidServiceError( + f"Invalid excluded service(s) provided: {sorted(unknown_services)}." + ) + + if excluded_check_set or excluded_service_set: + previous_scope = self._checks_to_execute + selected_checks = { + check + for check in previous_scope + if check not in excluded_check_set + and get_service_name_from_check_name(check) not in excluded_service_set + } + # Only complain when exclusions actually emptied a non-empty + # scope. If the scope was already empty (e.g. a severity or + # category filter matched nothing) the exclusions did not + # cause the emptiness and the misleading error would obscure + # the real reason. + if previous_scope and not selected_checks: + raise ScanInvalidCheckError( + "The scan configuration excludes every selected check." + ) + self._checks_to_execute = sorted(selected_checks) self._number_of_checks_to_execute = len(self._checks_to_execute) diff --git a/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet.py b/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet.py index 72e579bbc6..45cfa81bda 100644 --- a/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet.py +++ b/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet.py @@ -26,7 +26,7 @@ class ecs_securitygroup_restrict_rdp_internet(Check): for ingress_rule in security_group.ingress_rules: # Check if rule allows traffic (policy == "accept") - if ingress_rule.get("policy", "accept") != "accept": + if str(ingress_rule.get("policy", "accept")).lower() != "accept": continue # Check protocol (tcp for RDP) diff --git a/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet.py b/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet.py index 9dfdd182e1..0315a69207 100644 --- a/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet.py +++ b/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet.py @@ -26,7 +26,7 @@ class ecs_securitygroup_restrict_ssh_internet(Check): for ingress_rule in security_group.ingress_rules: # Check if rule allows traffic (policy == "accept") - if ingress_rule.get("policy", "accept") != "accept": + if str(ingress_rule.get("policy", "accept")).lower() != "accept": continue # Check protocol (tcp for SSH) diff --git a/prowler/providers/aws/lib/arguments/arguments.py b/prowler/providers/aws/lib/arguments/arguments.py index 50f4665b2d..2d1632422b 100644 --- a/prowler/providers/aws/lib/arguments/arguments.py +++ b/prowler/providers/aws/lib/arguments/arguments.py @@ -235,7 +235,7 @@ def validate_arguments(arguments: Namespace) -> tuple[bool, str]: def validate_bucket(bucket_name: str) -> str: """validate_bucket validates that the input bucket_name is valid""" if search( - "^(?!^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$)(?!.*\.{2})(?!.*\.-)(?!.*-\.)(?!^xn--)(?!^sthree-)(?!^amzn-s3-demo-)(?!.*--table-s3$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", + r"^(?!^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$)(?!.*\.{2})(?!.*\.-)(?!.*-\.)(?!^xn--)(?!^sthree-)(?!^amzn-s3-demo-)(?!.*--table-s3$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", bucket_name, ): return bucket_name diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.metadata.json new file mode 100644 index 0000000000..161abe5d30 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_notebook_instance_no_secrets", + "CheckTitle": "SageMaker notebook instance lifecycle configuration contains no hardcoded secrets", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Sensitive Data Identifications/Passwords", + "Effects/Data Exposure" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsSageMakerNotebookInstance", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker notebook instance lifecycle configuration scripts** (`OnCreate` and `OnStart`) are analyzed for **embedded secrets**, detecting patterns like API keys, passwords, tokens, and connection strings. Findings reference the lifecycle hook and line numbers where potential secrets appear.", + "Risk": "**Hardcoded secrets** in lifecycle configuration scripts can be read by anyone with SageMaker access to the notebook instance, letting attackers reuse the credentials to access databases, APIs, or cloud resources, enabling data exfiltration and unauthorized changes.\n\nRotation is harder, increasing dwell time and blast radius of compromises.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/notebook-lifecycle-config.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sagemaker update-notebook-instance-lifecycle-config --notebook-instance-lifecycle-config-name --on-start Content=", + "NativeIaC": "", + "Other": "1. Create a secret in AWS Secrets Manager for the hardcoded value.\n2. Update the notebook instance IAM role to allow secretsmanager:GetSecretValue on that secret.\n3. Edit the lifecycle script to fetch the secret at runtime instead of hardcoding it.\n4. Update the notebook instance lifecycle configuration.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Use AWS Secrets Manager or Parameter Store to store secrets and retrieve them at runtime in lifecycle scripts; never hardcode them.", + "Url": "https://hub.prowler.com/check/sagemaker_notebook_instance_no_secrets" + } + }, + "Categories": [ + "secrets", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.py b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.py new file mode 100644 index 0000000000..9b975596e4 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets.py @@ -0,0 +1,131 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) +from prowler.providers.aws.services.sagemaker.sagemaker_client import ( + sagemaker_client, +) + + +class sagemaker_notebook_instance_no_secrets(Check): + """Check for hardcoded secrets in SageMaker notebook instance lifecycle scripts. + + Scans the OnCreate and OnStart lifecycle configuration scripts of each + SageMaker notebook instance for hardcoded secrets such as API keys, + passwords, tokens, and connection strings. The scripts are fetched and + decoded by the SageMaker service; this check only consumes that data. + """ + + def execute(self): + """Execute the sagemaker_notebook_instance_no_secrets check. + + Returns: + list[Check_Report_AWS]: One report per SageMaker notebook + instance, with status PASS, FAIL, or MANUAL. + """ + findings = [] + notebook_instances = sagemaker_client.sagemaker_notebook_instances + if not notebook_instances: + return findings + + secrets_ignore_patterns = sagemaker_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = sagemaker_client.audit_config.get("secrets_validate", False) + + # Instances that actually contribute a script to the batch. Only these + # (plus instances whose describe/decode failed) may be marked MANUAL on + # a batch scan failure; instances with nothing to scan must PASS. + scanned_resources = { + notebook_instance.arn + for notebook_instance in notebook_instances + if notebook_instance.lifecycle_scripts + } + + def payloads(): + for notebook_instance in notebook_instances: + for fragment, script in notebook_instance.lifecycle_scripts.items(): + yield (notebook_instance.arn, fragment), script + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + findings_by_instance = {} + for ( + resource_id, + fragment, + ), fragment_findings in batch_results.items(): + findings_by_instance.setdefault(resource_id, {})[ + fragment + ] = fragment_findings + + for notebook_instance in notebook_instances: + report = Check_Report_AWS( + metadata=self.metadata(), resource=notebook_instance + ) + + # MANUAL when the instance could not be fully scanned: either the + # lifecycle config describe/decode failed, or the batch scan failed + # for an instance that actually had scripts queued for scanning. + batch_failed = ( + scan_error is not None and notebook_instance.arn in scanned_resources + ) + if notebook_instance.lifecycle_scan_failed or batch_failed: + report.status = "MANUAL" + report.status_extended = ( + f"Could not fully scan SageMaker notebook instance " + f"{notebook_instance.name} lifecycle configuration for " + f"secrets; manual review is required." + ) + findings.append(report) + continue + + report.status = "PASS" + if not notebook_instance.lifecycle_config_name: + report.status_extended = ( + f"SageMaker notebook instance {notebook_instance.name} " + f"does not have a lifecycle configuration." + ) + else: + report.status_extended = ( + f"No secrets found in SageMaker notebook instance " + f"{notebook_instance.name} lifecycle configuration." + ) + + fragments_with_secrets = findings_by_instance.get(notebook_instance.arn) + + if fragments_with_secrets: + all_secrets = [] + secrets_findings = [] + + for fragment, fragment_findings in fragments_with_secrets.items(): + all_secrets.extend(fragment_findings) + secrets_string = ", ".join( + f"{secret['type']} on line {secret['line_number']}" + for secret in fragment_findings + ) + secrets_findings.append(f"{fragment}: {secrets_string}") + + final_output_string = "; ".join(secrets_findings) + report.status = "FAIL" + report.status_extended = ( + f"Potential {'secrets' if len(secrets_findings) > 1 else 'secret'} " + f"found in SageMaker notebook instance " + f"{notebook_instance.name} lifecycle configuration -> " + f"{final_output_string}." + ) + annotate_verified_secrets(report, all_secrets) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py index 20ea4c0280..6303ebbbf2 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py @@ -1,3 +1,4 @@ +import base64 from typing import Optional from botocore.client import ClientError @@ -37,6 +38,11 @@ class SageMaker(AWSService): self.__threading_call__( self._describe_notebook_instance, self.sagemaker_notebook_instances ) + # Runs after _describe_notebook_instance so lifecycle_config_name is set. + self.__threading_call__( + self._describe_notebook_instance_lifecycle_config, + self.sagemaker_notebook_instances, + ) self.__threading_call__( self._describe_training_job, self.sagemaker_training_jobs ) @@ -224,11 +230,61 @@ class SageMaker(AWSService): notebook_instance.direct_internet_access = True if "KmsKeyId" in describe_notebook_instance: notebook_instance.kms_key_id = describe_notebook_instance["KmsKeyId"] + if "NotebookInstanceLifecycleConfigName" in describe_notebook_instance: + notebook_instance.lifecycle_config_name = describe_notebook_instance[ + "NotebookInstanceLifecycleConfigName" + ] except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _describe_notebook_instance_lifecycle_config(self, notebook_instance): + """Fetch and decode a notebook instance's lifecycle scripts. + + Reads the ``OnCreate`` and ``OnStart`` scripts from + ``DescribeNotebookInstanceLifecycleConfig`` and stores the base64-decoded + content on ``notebook_instance.lifecycle_scripts`` keyed by + ``"[]"``. Instances without a lifecycle configuration are + skipped. Any describe or decode failure sets + ``notebook_instance.lifecycle_scan_failed`` to True so the consuming + check can report ``MANUAL`` instead of a false ``PASS``. + + Args: + notebook_instance: NotebookInstance model to enrich in-place. + """ + if not notebook_instance.lifecycle_config_name: + return + logger.info("SageMaker - describing notebook instance lifecycle config...") + try: + regional_client = self.regional_clients[notebook_instance.region] + lifecycle_config = regional_client.describe_notebook_instance_lifecycle_config( + NotebookInstanceLifecycleConfigName=notebook_instance.lifecycle_config_name + ) + except Exception as error: + notebook_instance.lifecycle_scan_failed = True + logger.error( + f"{notebook_instance.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return + + scripts = {} + for hook_name in ("OnCreate", "OnStart"): + for script_index, script in enumerate(lifecycle_config.get(hook_name, [])): + content_b64 = script.get("Content") + if not content_b64: + continue + try: + scripts[f"{hook_name}[{script_index}]"] = base64.b64decode( + content_b64 + ).decode("utf-8", errors="ignore") + except Exception as error: + notebook_instance.lifecycle_scan_failed = True + logger.error( + f"{notebook_instance.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + notebook_instance.lifecycle_scripts = scripts + def _describe_model(self, model): logger.info("SageMaker - describing models...") try: @@ -497,6 +553,13 @@ class NotebookInstance(BaseModel): subnet_id: str = None direct_internet_access: bool = None kms_key_id: str = None + lifecycle_config_name: str = None + # Decoded lifecycle scripts keyed by "[]" (e.g. "OnStart[0]"), + # populated by _describe_notebook_instance_lifecycle_config. + lifecycle_scripts: dict = {} + # True if the lifecycle configuration could not be fully described/decoded, + # so the secrets check reports MANUAL instead of a false PASS. + lifecycle_scan_failed: bool = False tags: Optional[list] = [] diff --git a/pyproject.toml b/pyproject.toml index 2edbcc428b..64affc8556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,7 +125,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.14" -version = "5.35.0" +version = "5.36.0" [project.scripts] prowler = "prowler.__main__:prowler" diff --git a/skills/prowler-compliance/SKILL.md b/skills/prowler-compliance/SKILL.md index f119c7fa9b..747cb6ab64 100644 --- a/skills/prowler-compliance/SKILL.md +++ b/skills/prowler-compliance/SKILL.md @@ -2,23 +2,29 @@ name: prowler-compliance description: > Creates, syncs, audits and manages Prowler compliance frameworks end-to-end. - Covers the four-layer architecture (SDK models → JSON catalogs → output - formatters → API/UI), upstream sync workflows, cloud-auditor check-mapping - reviews, output formatter creation, and framework-specific attribute models. - Trigger: When working with compliance frameworks (CIS, NIST, PCI-DSS, SOC2, - GDPR, ISO27001, ENS, MITRE ATT&CK, CCC, C5, CSA CCM, KISA ISMS-P, - Prowler ThreatScore, FedRAMP, HIPAA), syncing with upstream catalogs, - auditing check-to-requirement mappings, adding output formatters, or fixing - compliance JSON bugs (duplicate IDs, empty Version, wrong Section, stale - check refs). + Covers the two supported JSON schemas (universal multi-provider and legacy + per-provider), the SDK model tree (legacy attribute classes, universal + ComplianceFramework, ConfigRequirements guardrails), output formatters + (legacy per-framework + universal data-driven), API/UI consumption, upstream + sync workflows, and cloud-auditor check-mapping reviews. + Trigger: When working with compliance frameworks (CIS, CIS Controls, NIST, + PCI-DSS, SOC2, GDPR, ISO27001, ENS, MITRE ATT&CK, CCC, C5, CSA CCM, DORA, + KISA ISMS-P, ASD Essential Eight, DISA STIG, CISA SCuBA, SecNumCloud, + FedRAMP, HIPAA, NIS2, Prowler ThreatScore), creating a universal + multi-provider framework, adding ConfigRequirements guardrails, syncing with + upstream catalogs, auditing check-to-requirement mappings, adding output + formatters, or fixing compliance JSON bugs (duplicate IDs, empty Version, + wrong Section, stale check refs). license: Apache-2.0 metadata: author: prowler-cloud - version: "1.2" + version: "2.0" scope: [root, sdk] auto_invoke: - "Creating/updating compliance frameworks" + - "Creating a universal (multi-provider) compliance framework" - "Mapping checks to compliance controls" + - "Adding ConfigRequirements guardrails to compliance requirements" - "Syncing compliance framework with upstream catalog" - "Auditing check-to-requirement mappings as a cloud auditor" - "Adding a compliance output formatter (per-provider class + table dispatcher)" @@ -29,527 +35,673 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ## When to Use Use this skill when: -- Creating a new compliance framework for any provider + +- Creating a new compliance framework for any provider — **decide universal vs legacy first** (see below) - **Syncing an existing framework with an upstream source of truth** (CIS, FINOS CCC, CSA CCM, NIST, ENS, etc.) -- Adding requirements to existing frameworks +- Adding requirements to existing frameworks, or extending a universal framework to a new provider - Mapping checks to compliance controls -- **Auditing existing check mappings as a cloud auditor** (user asks "are these mappings correct?", "which checks apply to this requirement?", "review the mappings") -- **Adding a new output formatter** (new framework needs a table dispatcher + per-provider classes + CSV models) +- **Adding `ConfigRequirements` guardrails** so configurable checks can't silently satisfy a requirement with a loosened config +- **Auditing existing check mappings as a cloud auditor** ("are these mappings correct?", "which checks apply?", "review the mappings") +- **Adding a new legacy output formatter** (table dispatcher + per-provider classes + CSV models) - **Fixing JSON bugs**: duplicate IDs, empty Version, wrong Section, stale check refs, inconsistent FamilyName, padded tangential check mappings -- **Registering a framework in the CLI table dispatcher or API export map** - Investigating why a finding/check isn't showing under the expected compliance framework in the UI - Understanding compliance framework structures and attributes -## Four-Layer Architecture (Mental Model) +The authoritative contributor doc is `docs/developer-guide/security-compliance-framework.mdx` — +keep this skill and that doc consistent when either changes. For **reviewing** +a compliance PR, use the sister skill +[prowler-compliance-review](../prowler-compliance-review/SKILL.md) instead. -Prowler compliance is a **four-layer system** hanging off one Pydantic model tree. Bugs usually happen where one layer doesn't match another, so know all four before touching anything. +## Universal vs Legacy: The First Decision + +Prowler supports **two JSON schemas**. Choosing wrong means unnecessary Python +code, so decide this before anything else. At load time both converge: legacy +files are adapted into the universal `ComplianceFramework` model +(`adapt_legacy_to_universal()`), so the difference is about **authoring cost +and capabilities**, not about what the rest of Prowler sees. + +### Side-by-side comparison + +| | Universal (recommended for new frameworks) | Legacy provider-specific | +|---|---|---| +| File location | `prowler/compliance/.json` (top level) | `prowler/compliance//__.json` | +| Providers | Any number, one file (`checks` dict keyed by provider) | Exactly one provider per file (one file per provider to multi-cover) | +| Key style | lowercase (`framework`, `requirements`, `checks`) | Capitalized (`Framework`, `Requirements`, `Checks`) | +| Attribute schema | Declared **in the JSON itself** via `attributes_metadata`, validated at load | Pydantic class per framework family in `compliance_models.py` (code change for new shapes) | +| Attributes per requirement | One flat dict (`attributes: {...}`) | List of objects (`Attributes: [{...}]`) — only `Attributes[0]` is used downstream | +| Table/CSV/OCSF output | Data-driven from `outputs.table_config` — **zero Python changes** | Formatter package + registrations in `compliance.py`, `__main__.py`, `export.py` | +| Guardrails field | `config_requirements` (+ mandatory `Provider` per constraint) | `ConfigRequirements` (`Provider` omitted) | +| Loader behavior on error | Lenient: logs + skips file (`load_compliance_framework_universal`) | Fail-fast: `sys.exit(1)` (`load_compliance_framework`) | +| Loaded by | Only `get_bulk_compliance_frameworks_universal()` | Both loaders (`Compliance.get_bulk()` + universal, via adapter) | +| Shipped examples | `cis_controls_8.1.json`, `csa_ccm_4.0.json`, `dora_2022_2554.json` | Everything else (~105 files across 11 providers) | + +### When to use which + +**Use universal when** (any of these): + +- The framework is **new to Prowler** — no existing attribute class, no + existing formatter. This is the default: zero Python changes needed. +- The framework spans (or will span) **more than one provider** — DORA, CSA + CCM, CIS Controls. One file covers all providers; extending to a new + provider is a one-line `checks` edit. +- The attribute shape is **unique to this framework** — declare it in + `attributes_metadata` instead of adding a Pydantic class to the Union. + +**Use legacy only when extending an existing legacy family**: + +- A new **version** of a shipped legacy framework (CIS 8.0 for AWS → new + `cis_8.0_aws.json`, same `CIS_Requirement_Attribute`, same `cis/` formatter). +- An existing legacy framework for a **new provider** (ENS for m365 → new + `ens_rd2022_m365.json` + `ens_m365.py` transformer). +- Consistency with the family matters more than the universal benefits — a + lone `cis_8.0_aws` in universal format while 20+ CIS files stay legacy + would fragment the family. + +**Never**: start a brand-new single-provider framework as legacy "because it's +only AWS today". Universal handles single-provider fine (the `checks` dict +just has one key) and you skip 3 output files + 3 registrations. + +### The same requirement in both schemas + +Universal (`prowler/compliance/my_framework_1.0.json`): + +```json +{ + "framework": "My-Framework", + "name": "My Framework 1.0", + "version": "1.0", + "description": "...", + "attributes_metadata": [ + {"key": "Section", "type": "str", "required": true}, + {"key": "Service", "type": "str"} + ], + "outputs": {"table_config": {"group_by": "Section"}}, + "requirements": [ + { + "id": "MF-1.1", + "name": "Root MFA", + "description": "Root account must have MFA enabled.", + "attributes": {"Section": "IAM", "Service": "iam"}, + "checks": { + "aws": ["iam_root_mfa_enabled"], + "azure": [] + } + } + ] +} +``` + +Legacy (`prowler/compliance/aws/my_framework_1.0_aws.json` — plus a second +file per extra provider, plus formatter + registrations): + +```json +{ + "Framework": "My-Framework", + "Name": "My Framework 1.0 for AWS", + "Version": "1.0", + "Provider": "AWS", + "Description": "...", + "Requirements": [ + { + "Id": "MF-1.1", + "Name": "Root MFA", + "Description": "Root account must have MFA enabled.", + "Attributes": [ + {"ItemId": "MF-1.1", "Section": "IAM", "Service": "iam"} + ], + "Checks": ["iam_root_mfa_enabled"] + } + ] +} +``` + +Same control, but the universal file already covers Azure, validates its own +attribute schema, and renders table/CSV/OCSF with no code. Field-by-field +references for each schema follow below. + +## Architecture (Mental Model) + +Prowler compliance is a four-layer system. Bugs usually happen where one layer +doesn't match another, so know all four before touching anything. ### Layer 1: SDK / Core Models — `prowler/lib/check/` -- **`compliance_models.py`** — Pydantic **v1** model tree (`from pydantic.v1 import`). One `*_Requirement_Attribute` class per framework type + `Generic_Compliance_Requirement_Attribute` as fallback. -- `Compliance_Requirement.Attributes: list[Union[...]]` — **`Generic_Compliance_Requirement_Attribute` MUST be LAST** in the Union or every framework-specific attribute falls through to Generic (Pydantic v1 tries union members in order). -- **`compliance.py`** — runtime linker. `get_check_compliance()` builds the key as `f"{Framework}-{Version}"` **only if `Version` is non-empty**. An empty Version makes the key just `"{Framework}"` — this breaks downstream filters and tests that expect the versioned key. -- `Compliance.get_bulk(provider)` walks `prowler/compliance/{provider}/` and parses every `.json` file. No central index — just directory scan. +All in **Pydantic v1** (`from pydantic.v1 import ...`). Three model groups live +in `compliance_models.py`: -### Layer 2: JSON Frameworks — `prowler/compliance/{provider}/` +**Legacy tree** — `Compliance` → `Compliance_Requirement` / `Mitre_Requirement`: -See "Compliance Framework Location" and "Framework-Specific Attribute Structures" sections below. +- One `*_Requirement_Attribute` class per framework family. Registered today (Union order matters): + `ASDEssentialEight`, `CIS`, `ENS`, `ISO27001_2013`, `AWS_Well_Architected`, + `KISA_ISMSP`, `Prowler_ThreatScore`, `CCC`, `C5Germany`, `CSA_CCM`, `STIG` + (Okta IDaaS), and `Generic_Compliance_Requirement_Attribute` as fallback. +- **Generic MUST stay LAST** in `Compliance_Requirement.Attributes: list[Union[...]]` — + Pydantic v1 tries union members in order; Generic first would swallow every + framework-specific attribute. NIST 800-53/CSF, PCI DSS, GDPR, HIPAA, SOC2, + FedRAMP, SecNumCloud etc. intentionally use Generic. +- A `root_validator` rejects empty `Framework`, `Provider` or `Name`. +- MITRE uses the separate `Mitre_Requirement` model (`Tactics`, `SubTechniques`, + `Platforms`, `TechniqueURL` at requirement top level, per-provider + `Mitre_Requirement_Attribute_{AWS,Azure,GCP}`). -### Layer 3: Output Formatters — `prowler/lib/outputs/compliance/{framework}/` +**Universal tree** — `ComplianceFramework` → `UniversalComplianceRequirement`: -**Every framework directory follows this exact convention** — do not deviate: +- Flat `attributes: dict` per requirement, schema declared in + `attributes_metadata` (key, label, type, enum, required, `enum_display`, + `enum_order`, `output_formats`). A `root_validator` rejects missing required + keys, unknown keys (drift guard), enum violations, and int/float/bool type + mismatches. If `attributes_metadata` is omitted, **no validation runs**. +- `checks: dict[provider, list[check_id]]` — the provider list of the framework + is **derived** from these keys (`get_providers()` / `supports_provider()`); + the top-level `provider` field is only a fallback. +- `outputs.table_config` (group_by, split_by, scoring, labels) drives the CLI + table; `outputs.pdf_config` exists in the model but **is not consumed by the + API PDF pipeline yet** (see Layer 4). + +**Guardrails** — `Compliance_Requirement_ConfigConstraint`: + +- Fields `Check`, `ConfigKey`, `Operator` (`lte|gte|eq|in|subset|superset`), + `Value`, optional `Provider` (required in universal multi-provider files). +- A `root_validator` rejects Value/Operator type mismatches at load time. +- Evaluation is centralized in `prowler/lib/check/compliance_config_eval.py` + (`evaluate_config_constraints`, `apply_config_status`, `get_effective_status`, + `CONFIG_NOT_VALID_PREFIX = "Configuration not valid for this requirement."`), + shared by CSV/OCSF/table outputs **and** the API backend. A violated + constraint forces the requirement to FAIL and prepends the reason to + `status_extended`. Constraints whose `ConfigKey` is absent from + `audit_config` are skipped (defaults assumed compliant). + +**Loaders**: + +- `Compliance.get_bulk(provider)` — legacy: scans only + `prowler/compliance/{provider}/` (+ external JSONs via the + `prowler.compliance` entry-point group). Does NOT see top-level universal files. +- `get_bulk_compliance_frameworks_universal(provider)` — scans **both** the + top-level `prowler/compliance/` and every provider subdirectory, adapting + legacy files via `adapt_legacy_to_universal()` (flattens `Attributes[0]` to a + dict, wraps `Checks` as `{provider: [...]}`, infers `attributes_metadata`). + Also loads external universal frameworks via the + `prowler.compliance.universal` entry-point group (built-ins win collisions). +- `get_check_compliance(finding, provider_type, bulk_checks_metadata)` lives in + **`prowler/lib/outputs/compliance/compliance_check.py`** (not in + `lib/check/compliance.py`). It builds the per-finding dict keyed + `f"{Framework}-{Version}"` **only when Version is non-empty** — an empty + Version silently produces the key `"{Framework}"` and breaks downstream + filters and tests. +- `prowler/lib/check/compliance.py` now contains only + `update_checks_metadata_with_compliance()`. + +### Layer 2: JSON Catalogs — `prowler/compliance/` + +See "Compliance Catalog Coverage" below. + +### Layer 3: Output Formatters — `prowler/lib/outputs/compliance/` + +**Universal path** (no Python needed per framework): + +- `universal/universal_table.py` — `get_universal_table()`, renders the CLI + table from `outputs.table_config` + `attributes_metadata`. +- `universal/universal_output.py` — `UniversalComplianceOutput`, builds the CSV + Pydantic model **dynamically** from `attributes_metadata`. +- `universal/ocsf_compliance.py` — `OCSFComplianceOutput`; OCSF output is + **always generated** for universal frameworks regardless of `--output-formats`. +- Orchestrated by `process_universal_compliance_frameworks()` in + `compliance.py`, which runs **before** any legacy dispatch and removes the + processed frameworks from the set. + +**Legacy path** — per-framework directory, usually: ```text {framework}/ ├── __init__.py -├── {framework}.py # ONLY get_{framework}_table() — NO function docstring -├── {framework}_{provider}.py # One class per provider (e.g., CCC_AWS, CCC_Azure, CCC_GCP) -└── models.py # One Pydantic v2 BaseModel per provider (CSV columns) +├── {framework}.py # get_{framework}_table() summary-table function +├── {framework}_{provider}.py # One ComplianceOutput subclass per provider +└── models.py # One Pydantic CSV row model per provider ``` -- **`{framework}.py`** holds the **table dispatcher function** `get_{framework}_table()`. It prints the pass/fail/muted summary table. **Must NOT import `Finding` or `ComplianceOutput`** — doing so creates a circular import with `prowler/lib/outputs/compliance/compliance.py`. Only imports: `colorama`, `tabulate`, `prowler.config.config.orange_color`. -- **`{framework}_{provider}.py`** holds a per-provider class like `CCC_AWS(ComplianceOutput)` with a `transform()` method that walks findings and emits rows. This file IS allowed to import `Finding` because it's not on the dispatcher import chain. -- **`models.py`** holds one Pydantic v2 `BaseModel` per provider. Field names become CSV column headers (**public API** — renaming breaks downstream consumers). -- **Never collapse per-provider files into a unified parameterized class**, even when DRY-tempting. Every framework in Prowler follows the per-provider file pattern and reviewers will reject the refactor. CSV columns differ per provider (`AccountId`/`Region` vs `SubscriptionId`/`Location` vs `ProjectId`/`Location`) — three classes is the convention. -- **No function docstring on `get_{framework}_table()`** — no other framework has one; stay consistent. -- Register in `prowler/lib/outputs/compliance/compliance.py` → `display_compliance_table()` with an `elif compliance_framework.startswith("{framework}_"):` branch. Import the table function at the top of the file. +Directories today: `asd_essential_eight`, `aws_well_architected`, `c5`, `ccc`, +`cis`, `cisa_scuba`, `ens`, `generic`, `iso27001`, `kisa_ismsp`, +`mitre_attack`, `okta_idaas_stig`, `prowler_threatscore`, `universal`. +Known deviations (don't "fix" them without a reason): `iso27001/` has no table +file (falls to the generic table), `aws_well_architected/` has no per-provider +files, `cisa_scuba/` only ships googleworkspace. + +- CSV writers emit `;`-delimited files with UPPERCASE headers + (`ComplianceOutput.batch_write_data_to_file`). Field names in `models.py` + are **public API** — renaming breaks downstream consumers. +- **Circular import rule**: the table file (`{framework}.py`) must not import + `Finding` directly or transitively (`compliance.compliance` → table module → + `ComplianceOutput` → `Finding` → `get_check_compliance` → cycle). Keep table + files bare (`colorama`, `tabulate`, `prowler.config.config`); when a module + genuinely needs both, use `if TYPE_CHECKING:` or function-local imports (see + `universal_output.py` / `process_universal_compliance_frameworks`). +- Legacy table functions have no docstrings; the universal ones do. Match the + style of the file family you're touching. +- Dispatcher `display_compliance_table()` in `compliance.py` order: + universal (`table_config`) first → `cis_` → `ens_` → `mitre_attack` → + `kisa` → `prowler_threatscore_` → `c5_` → `ccc_` → `asd_essential_eight` + (substring) → `okta_idaas_stig` → else provider hook + (`provider.display_compliance_table()`, may raise `NotImplementedError`) → + `get_generic_compliance_table()`. iso27001, aws_well_architected and + cisa_scuba ride the fallback on purpose. ### Layer 4: API / UI -- **API table dispatcher**: `api/src/backend/tasks/jobs/export.py` → `COMPLIANCE_CLASS_MAP` keyed by provider. Uses `startswith` predicates: `(lambda name: name.startswith("ccc_"), CCC_AWS)`. **Never use exact match** (`name == "ccc_aws"`) — it's inconsistent and breaks versioning. -- **API lazy loader**: `api/src/backend/api/compliance.py` — `LazyComplianceTemplate` and `LazyChecksMapping` load compliance per provider on first access. -- **UI mapper routing**: `ui/lib/compliance/compliance-mapper.ts` routes framework names → per-framework mapper. -- **UI per-framework mapper**: `ui/lib/compliance/{framework}.tsx` flattens `Requirements` into a 3-level tree (Framework → Category → Control → Requirement) for the accordion view. Groups by `Attributes[0].FamilyName` and `Attributes[0].Section`. -- **UI detail panel**: `ui/components/compliance/compliance-custom-details/{framework}-details.tsx`. -- **UI types**: `ui/types/compliance.ts` — TypeScript mirrors of the attribute metadata. +- **API lazy loaders**: `api/src/backend/api/compliance.py` — + `LazyComplianceTemplate` / `LazyChecksMapping` (per-provider lazy caches over + `get_bulk_compliance_frameworks_universal`, with Gunicorn background warm-up). +- **API CSV export dispatch**: `COMPLIANCE_CLASS_MAP` in + `api/src/backend/tasks/jobs/export.py`, consumed from `tasks/tasks.py`. It is + a dict `provider → [(predicate, exporter_class)]` with `GenericCompliance` as + fallback. Predicates mix **`startswith` for multi-version families** + (`cis_`, `ens_`, `iso27001_`, `ccc_`, `cisa_scuba_`, ...) and **exact + `name == ...` for true singletons** (`mitre_attack_aws`, + `prowler_threatscore_*`, `asd_essential_eight_aws` — and inconsistently + `c5_azure`/`c5_gcp`, while aws uses `startswith("c5_")`). Rule of thumb: if + the framework can ever grow versions or variants, use `startswith`. +- **API overview ingestion**: `create_compliance_requirements()` in + `api/src/backend/tasks/jobs/scan.py` builds per-region rows from the lazy + template and persists `ComplianceRequirementOverview` (COPY with bulk-create + fallback) plus `ComplianceOverviewSummary`. +- **API PDF reports**: `api/src/backend/tasks/jobs/reports/` — hardcoded + `FRAMEWORK_REGISTRY` (own `FrameworkConfig` dataclass, NOT the SDK + `PDFConfig`) with one generator class per framework. Only + `prowler_threatscore`, `ens`, `nis2`, `csa_ccm` and `cis` have PDFs today; + adding one means a generator class + registry entry + wiring in `report.py`. +- **UI mapper routing**: `ui/lib/compliance/compliance-mapper.ts` — + `getComplianceMappers()` keyed by the JSON's `framework` value + (e.g. `"CIS"`, `"CIS-Controls"`, `"DORA"`, `"Okta-IDaaS-STIG"`). Unregistered + frameworks **fall back to the generic mapper + `GenericCustomDetails` + automatically** — a dedicated mapper/detail panel is a first-class upgrade, + not a requirement to render. +- **UI grouping varies per mapper**: generic/cis group by + `Section`/`SubSection`, iso by `Category`, ccc by `FamilyName`. All read + `attributes[0]` — inconsistent values within one JSON become separate tree + branches, so normalize before shipping. +- **UI types**: `ui/types/compliance.ts` — one `*AttributesMetadata` interface + per framework, added to the `AttributesItemData` metadata union. +- **UI icons**: `ui/components/icons/compliance/` + `IconCompliance.tsx`. + Registration is an ordered substring match (`COMPLIANCE_LOGOS`): put + framework-specific keywords **before** generic ones (`nist` before `nis2`, + `cisa` before `cis`; `aws` deliberately last). ### The CLI Pipeline (end-to-end) ```text -prowler aws --compliance ccc_aws +prowler aws --compliance cis_7.0_aws # framework key = JSON basename ↓ -Compliance.get_bulk("aws") → parses prowler/compliance/aws/*.json +Compliance.get_bulk("aws") # legacy frameworks +get_bulk_compliance_frameworks_universal("aws") # legacy (adapted) + universal ↓ -update_checks_metadata_with_compliance() → attaches compliance info to CheckMetadata +update_checks_metadata_with_compliance() # attaches compliance to CheckMetadata ↓ -execute_checks() → runs checks, produces Finding objects +execute_checks() → Finding objects ↓ -get_check_compliance(finding, "aws", bulk_checks_metadata) - → dict "{Framework}-{Version}" → [requirement_ids] +get_check_compliance(finding, "aws", bulk) # dict "{Framework}-{Version}" → [req_ids] ↓ -CCC_AWS(findings, compliance).transform() → per-provider class builds CSV rows +process_universal_compliance_frameworks() # universal: CSV + OCSF, then removed from set +per-provider elif branches in __main__.py # legacy: AWSCIS(...).batch_write_data_to_file() ↓ -batch_write_data_to_file() → writes {output_filename}_ccc_aws.csv - ↓ -display_compliance_table() → get_ccc_table() → prints stdout summary +display_compliance_table() # universal table first, then legacy elifs, + # then generic fallback ``` --- -## Compliance Framework Location +## Compliance Catalog Coverage -Frameworks are JSON files located in: `prowler/compliance/{provider}/{framework_name}_{provider}.json` +Counts as of 2026-07 (109 JSON files). Regenerate before trusting them: -**Supported Providers:** -- `aws` - Amazon Web Services -- `azure` - Microsoft Azure -- `gcp` - Google Cloud Platform -- `kubernetes` - Kubernetes -- `github` - GitHub -- `m365` - Microsoft 365 -- `alibabacloud` - Alibaba Cloud -- `cloudflare` - Cloudflare -- `oraclecloud` - Oracle Cloud -- `oci` - Oracle Cloud Infrastructure -- `nhn` - NHN Cloud -- `mongodbatlas` - MongoDB Atlas -- `iac` - Infrastructure as Code -- `llm` - Large Language Models +```bash +for d in prowler/compliance/*/; do printf "%s: %s\n" "$(basename $d)" "$(ls $d*.json 2>/dev/null | wc -l)"; done +ls prowler/compliance/*.json # universal, top-level +``` -## Base Framework Structure +**Universal (top-level, multi-provider)**: `cis_controls_8.1.json` (18 +providers), `csa_ccm_4.0.json` (aws/azure/gcp/alibabacloud/oraclecloud), +`dora_2022_2554.json` (aws/azure/gcp/alibabacloud/cloudflare). -All compliance frameworks share this base structure: +**Legacy per-provider** (families, not exhaustive versions): + +| Provider | # | Framework families | +|---|---|---| +| aws | 45 | CIS 1.4–7.0, NIST 800-53 r4/r5, NIST 800-171 r2, NIST CSF 1.1/2.0, PCI 3.2.1/4.0, ISO 27001 2013/2022, HIPAA, GDPR, SOC2, FedRAMP low/moderate r4 + 20x KSI low, ENS RD2022, MITRE ATT&CK, C5, CCC, CISA, FFIEC, RBI, Well-Architected (security/reliability), FTR, FSBP, AWS AI Security Framework, AWS Account Security Onboarding, Audit Manager Control Tower, GxP 21 CFR 11 / EU Annex 11, KISA ISMS-P 2023 (en+ko), NIS2, ASD Essential Eight, SecNumCloud 3.2, Prowler ThreatScore | +| azure | 19 | CIS 2.0–6.0, ISO 27001 2022, ENS RD2022, MITRE ATT&CK, PCI 4.0, HIPAA, SOC2, NIS2, RBI, C5, CCC, FedRAMP 20x KSI low, SecNumCloud 3.2, Prowler ThreatScore | +| gcp | 17 | CIS 2.0–5.0, ISO 27001 2022, ENS RD2022, MITRE ATT&CK, PCI 4.0, HIPAA, SOC2, NIS2, RBI, C5, CCC, FedRAMP 20x KSI low, SecNumCloud 3.2, Prowler ThreatScore | +| kubernetes | 8 | CIS 1.8–2.0.1, ISO 27001 2022, PCI 4.0, Prowler ThreatScore | +| m365 | 5 | CIS 4.0/6.0/7.0, ISO 27001 2022, Prowler ThreatScore | +| alibabacloud | 3 | CIS 2.0, SecNumCloud 3.2, Prowler ThreatScore | +| oraclecloud | 3 | CIS 3.0/3.1, SecNumCloud 3.2 | +| github | 2 | CIS 1.0/1.2.0 | +| googleworkspace | 2 | CIS 1.3, CISA SCuBA 0.6 | +| okta | 1 | Okta IDaaS STIG V1R2 | +| nhn | 1 | ISO 27001 2022 | + +Providers with a compliance directory but no frameworks yet: cloudflare, iac, +linode, llm, mongodbatlas, openstack, stackit. Provider keys inside universal +`checks` dicts must match directory names under `prowler/providers/` (lowercase). + +--- + +## Universal Schema Reference + +Full spec in `docs/developer-guide/security-compliance-framework.mdx`. Skeleton: + +```json +{ + "framework": "DORA", + "name": "Digital Operational Resilience Act (DORA) 2022/2554", + "version": "2022/2554", + "description": "Shown in --list-compliance and PDF reports.", + "icon": "dora", + "attributes_metadata": [ + {"key": "Pillar", "label": "Pillar", "type": "str", "required": true, + "enum": ["ICT Risk Management", "..."], + "output_formats": {"csv": true, "ocsf": true}}, + {"key": "Article", "type": "str", "required": true} + ], + "outputs": { + "table_config": {"group_by": "Pillar"}, + "pdf_config": {"group_by_field": "Pillar", "charts": ["..."]} + }, + "requirements": [ + { + "id": "DORA-Art5", + "name": "Governance and organisation", + "description": "Requirement text verbatim from the source.", + "attributes": {"Pillar": "ICT Risk Management", "Article": "Article 5"}, + "checks": { + "aws": ["iam_no_root_access_key"], + "azure": [], + "gcp": [] + }, + "config_requirements": [ + {"Check": "iam_user_accesskey_unused", "Provider": "aws", + "ConfigKey": "max_unused_access_keys_days", "Operator": "lte", "Value": 45} + ] + } + ] +} +``` + +### Universal fields, top level (`ComplianceFramework`) + +| Field | Type | Required | Notes | +|---|---|---|---| +| `framework` | string | Yes | Short identifier (`DORA`, `CSA-CCM`, `CIS-Controls`). This is the key the UI mapper routes on. | +| `name` | string | Yes | Human-readable full name. | +| `version` | string | No (never leave empty) | Framework version/edition (`8.1`, `2022/2554`). | +| `description` | string | Yes | Shown in `--list-compliance` and PDF reports. | +| `provider` | string | No | Fallback only — the effective provider list is derived from `checks` keys across requirements (`get_providers()`). | +| `icon` | string | No | Short icon slug. | +| `attributes_metadata` | array | No (strongly recommended) | Declares the schema of every `attributes` key. **If omitted, no attribute validation runs at all.** | +| `outputs` | object | No | `table_config` (CLI table) + `pdf_config` (modeled, not yet consumed by the API). | +| `requirements` | array | Yes | List of requirement objects (below). | + +### Universal fields, per requirement (`UniversalComplianceRequirement`) + +| Field | Type | Required | Notes | +|---|---|---|---| +| `id` | string | Yes | Unique within the framework. | +| `description` | string | Yes | Requirement text verbatim from the source. | +| `name` | string | No | Short title. | +| `attributes` | dict | No (default `{}`) | Flat dict; every key must be declared in `attributes_metadata` (unknown keys are rejected at load when metadata exists). | +| `checks` | dict | No (default `{}`) | `{provider: [check_ids]}`, lowercase keys matching `prowler/providers/` dirs. Empty list = manual requirement for that provider. | +| `config_requirements` | array | No | Guardrails; each constraint **must** carry `Provider`. | +| `tactics`, `sub_techniques`, `platforms`, `technique_url` | — | No | MITRE-style extras (auto-populated when adapting legacy MITRE files). | + +### `attributes_metadata` entry fields (`AttributeMetadata`) + +| Field | Type | Notes | +|---|---|---| +| `key` | string (required) | Attribute name as used in `requirement.attributes`. | +| `label` | string | Human-readable label for CSV headers / PDF. | +| `type` | string | `str` (default), `int`, `float`, `bool`, `list_str`, `list_dict`. Only int/float/bool are enforced at load; the rest are documentation. | +| `enum` | list | Allowed values — enforced at load. Use it whenever the value set is closed. | +| `required` | bool | Enforced at load: every requirement must carry the key non-null. | +| `enum_display` / `enum_order` | dict / list | Per-enum-value visual metadata (label, abbreviation, color, icon) and ordering for PDF rendering. | +| `chart_label` | string | Axis label when the attribute is used in charts. | +| `output_formats` | object | `{"csv": bool, "ocsf": bool}`, both default `true` — toggles inclusion per output. | + +Key rules: + +- `--compliance` key = JSON basename without `.json` (`dora_2022_2554`). +- Auto-discovered: no `__init__.py`, no formatter, no dispatcher registration. +- `table_config.group_by`, `pdf_config.group_by_field` and every + `charts[].group_by` must reference a key declared in `attributes_metadata`. +- Runtime type validation only covers `int`/`float`/`bool`; `str`/`list_str`/ + `list_dict` are documentation-only. +- Extending to a new provider = adding a key to `requirement.checks`. Nothing else. +- **No automatic check-existence validation at load time** — a typo'd check id + silently produces a requirement with no findings. Always run the + check-existence cross-check (see Validation). +- In universal files, always set `Provider` on every config constraint so a + guardrail authored for an AWS check never affects Azure/GCP scans of the + same requirement. + +## Legacy Schema Reference + +Base legacy file structure: ```json { "Framework": "FRAMEWORK_NAME", "Name": "Full Framework Name with Version", "Version": "X.X", - "Provider": "PROVIDER", + "Provider": "AWS", "Description": "Framework description...", "Requirements": [ { "Id": "requirement_id", - "Description": "Requirement description", "Name": "Optional requirement name", - "Attributes": [...], - "Checks": ["check_name_1", "check_name_2"] + "Description": "Requirement description", + "Attributes": [ ... ], + "Checks": ["check_name_1"], + "ConfigRequirements": [ ... ] } ] } ``` -## Framework-Specific Attribute Structures +### Legacy fields, top level (`Compliance`) -Each framework type has its own attribute model. Below are the exact structures used by Prowler: +| Field | Type | Required | Notes | +|---|---|---|---| +| `Framework` | string | Yes (non-empty, validated) | Canonical identifier (`CIS`, `ENS`, `NIST-800-53-Revision-5`). | +| `Name` | string | Yes (non-empty, validated) | Human-readable name with version. | +| `Version` | string | Optional in the model — **never leave it empty in practice** | Empty Version silently degrades the `get_check_compliance()` key to `"{Framework}"` (gotcha #4). Must match the version substring in the filename. | +| `Provider` | string | Yes (non-empty, validated) | Upper-cased single provider (`AWS`, `AZURE`, `GCP`, `M365`, ...). One file = one provider. | +| `Description` | string | Yes | Framework scope and purpose. | +| `Requirements` | array | Yes | Requirement objects (below), or `Mitre_Requirement` objects for MITRE files. | -### CIS (Center for Internet Security) +### Legacy fields, per requirement (`Compliance_Requirement`) -**Framework ID format:** `cis_{version}_{provider}` (e.g., `cis_5.0_aws`) +| Field | Type | Required | Notes | +|---|---|---|---| +| `Id` | string | Yes | Unique within the framework; follow the source numbering exactly (`1.1`, `A.5.1`, `CCC.Core.CN01.AR01`). | +| `Description` | string | Yes | Verbatim from the source catalog. | +| `Name` | string | No | Optional short title (NIST-style catalogs use it). | +| `Attributes` | array of objects | Yes | Parsed against the Union of attribute classes below; only `Attributes[0]` survives the universal adaptation and drives UI grouping. | +| `Checks` | array of strings | Yes | Check ids automating the requirement; `[]` = manual. | +| `ConfigRequirements` | array | No | Guardrails; `Provider` is omitted (the file is single-provider). | + +MITRE files use `Mitre_Requirement` instead, which adds `Tactics`, +`SubTechniques`, `Platforms`, `TechniqueURL` at the requirement top level. + +### Attribute shapes per framework family + +Unlike universal (schema in-file), a legacy requirement's `Attributes` must +match one of the Pydantic classes registered in +`Compliance_Requirement.Attributes` — a shape matching no class **silently +falls through to Generic**, dropping its specific fields. The most common +shapes (full field sets in `compliance_models.py`): + +### CIS — `cis_{version}_{provider}` ```json { - "Id": "1.1", - "Description": "Maintain current contact details", - "Checks": ["account_maintain_current_contact_details"], - "Attributes": [ - { - "Section": "1 Identity and Access Management", - "SubSection": "Optional subsection", - "Profile": "Level 1", - "AssessmentStatus": "Automated", - "Description": "Detailed attribute description", - "RationaleStatement": "Why this control matters", - "ImpactStatement": "Impact of implementing this control", - "RemediationProcedure": "Steps to fix the issue", - "AuditProcedure": "Steps to verify compliance", - "AdditionalInformation": "Extra notes", - "DefaultValue": "Default configuration value", - "References": "https://docs.example.com/reference" - } - ] + "Section": "1 Identity and Access Management", + "SubSection": "Optional subsection", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "...", "RationaleStatement": "...", "ImpactStatement": "...", + "RemediationProcedure": "...", "AuditProcedure": "...", + "AdditionalInformation": "...", "DefaultValue": "...", "References": "https://..." } ``` -**Profile values:** `Level 1`, `Level 2`, `E3 Level 1`, `E3 Level 2`, `E5 Level 1`, `E5 Level 2` -**AssessmentStatus values:** `Automated`, `Manual` +`Profile`: `Level 1|Level 2|E3 Level 1|E3 Level 2|E5 Level 1|E5 Level 2`. +`AssessmentStatus`: `Automated|Manual`. ---- - -### ISO 27001 - -**Framework ID format:** `iso27001_{year}_{provider}` (e.g., `iso27001_2022_aws`) +### ENS — `ens_rd2022_{provider}` ```json { - "Id": "A.5.1", - "Description": "Policies for information security should be defined...", - "Name": "Policies for information security", - "Checks": ["securityhub_enabled"], - "Attributes": [ - { - "Category": "A.5 Organizational controls", - "Objetive_ID": "A.5.1", - "Objetive_Name": "Policies for information security", - "Check_Summary": "Summary of what is being checked" - } - ] + "IdGrupoControl": "op.acc.1", "Marco": "operacional", + "Categoria": "control de acceso", "DescripcionControl": "...", + "Nivel": "alto", "Tipo": "requisito", + "Dimensiones": ["trazabilidad", "autenticidad"], + "ModoEjecucion": "automatico", "Dependencias": [] } ``` -**Note:** `Objetive_ID` and `Objetive_Name` use this exact spelling (not "Objective"). +`Nivel`: `opcional|bajo|medio|alto`. `Tipo`: `refuerzo|requisito|recomendacion|medida`. +`Dimensiones`: `confidencialidad|integridad|trazabilidad|autenticidad|disponibilidad`. ---- - -### ENS (Esquema Nacional de Seguridad - Spain) - -**Framework ID format:** `ens_rd2022_{provider}` (e.g., `ens_rd2022_aws`) +### ISO 27001 — `iso27001_{year}_{provider}` ```json { - "Id": "op.acc.1.aws.iam.2", - "Description": "Proveedor de identidad centralizado", - "Checks": ["iam_check_saml_providers_sts"], - "Attributes": [ - { - "IdGrupoControl": "op.acc.1", - "Marco": "operacional", - "Categoria": "control de acceso", - "DescripcionControl": "Detailed control description in Spanish", - "Nivel": "alto", - "Tipo": "requisito", - "Dimensiones": ["trazabilidad", "autenticidad"], - "ModoEjecucion": "automatico", - "Dependencias": [] - } - ] + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.1", "Objetive_Name": "Policies for information security", + "Check_Summary": "Summary of what is being checked" } ``` -**Nivel values:** `opcional`, `bajo`, `medio`, `alto` -**Tipo values:** `refuerzo`, `requisito`, `recomendacion`, `medida` -**Dimensiones values:** `confidencialidad`, `integridad`, `trazabilidad`, `autenticidad`, `disponibilidad` +Note: `Objetive_ID` / `Objetive_Name` use this exact (mis)spelling. ---- - -### MITRE ATT&CK - -**Framework ID format:** `mitre_attack_{provider}` (e.g., `mitre_attack_aws`) - -MITRE uses a different requirement structure: +### MITRE ATT&CK — `mitre_attack_{provider}` (separate requirement model) ```json { - "Name": "Exploit Public-Facing Application", - "Id": "T1190", - "Tactics": ["Initial Access"], - "SubTechniques": [], - "Platforms": ["Containers", "IaaS", "Linux", "Network", "Windows", "macOS"], - "Description": "Adversaries may attempt to exploit a weakness...", + "Name": "Exploit Public-Facing Application", "Id": "T1190", + "Tactics": ["Initial Access"], "SubTechniques": [], + "Platforms": ["IaaS"], "Description": "...", "TechniqueURL": "https://attack.mitre.org/techniques/T1190/", - "Checks": ["guardduty_is_enabled", "inspector2_is_enabled"], + "Checks": ["guardduty_is_enabled"], "Attributes": [ - { - "AWSService": "Amazon GuardDuty", - "Category": "Detect", - "Value": "Minimal", - "Comment": "Explanation of how this service helps..." - } + {"AWSService": "Amazon GuardDuty", "Category": "Detect", + "Value": "Minimal", "Comment": "..."} ] } ``` -**For Azure:** Use `AzureService` instead of `AWSService` -**For GCP:** Use `GCPService` instead of `AWSService` -**Category values:** `Detect`, `Protect`, `Respond` -**Value values:** `Minimal`, `Partial`, `Significant` +`AzureService`/`GCPService` for the other providers. `Category`: +`Detect|Protect|Respond`. `Value`: `Minimal|Partial|Significant`. ---- - -### NIST 800-53 - -**Framework ID format:** `nist_800_53_revision_{version}_{provider}` (e.g., `nist_800_53_revision_5_aws`) +### CCC — `ccc_{provider}` ```json { - "Id": "ac_2_1", - "Name": "AC-2(1) Automated System Account Management", - "Description": "Support the management of system accounts...", - "Checks": ["iam_password_policy_minimum_length_14"], - "Attributes": [ - { - "ItemId": "ac_2_1", - "Section": "Access Control (AC)", - "SubSection": "Account Management (AC-2)", - "SubGroup": "AC-2(3) Disable Accounts", - "Service": "iam" - } - ] + "FamilyName": "Data", "FamilyDescription": "...", + "Section": "CCC.Core.CN01 Encrypt Data for Transmission", "SubSection": "", + "SubSectionObjective": "...", + "Applicability": ["tlp-green", "tlp-amber", "tlp-red"], + "Recommendation": "...", + "SectionThreatMappings": [{"ReferenceId": "CCC", "Identifiers": ["CCC.Core.TH02"]}], + "SectionGuidelineMappings": [{"ReferenceId": "NIST-CSF", "Identifiers": ["PR.DS-02"]}] } ``` ---- +`Applicability` holds TLP tags (`tlp-clear|tlp-green|tlp-amber|tlp-red`). -### Generic Compliance (Fallback) - -For frameworks without specific attribute models: +### ASD Essential Eight — `asd_essential_eight_aws` ```json { - "Id": "requirement_id", - "Description": "Requirement description", - "Name": "Optional name", - "Checks": ["check_name"], - "Attributes": [ - { - "ItemId": "item_id", - "Section": "Section name", - "SubSection": "Subsection name", - "SubGroup": "Subgroup name", - "Service": "service_name", - "Type": "type" - } - ] + "Section": "Patch applications", "MaturityLevel": "ML1", + "AssessmentStatus": "Automated", "CloudApplicability": "partial", + "MitigatedThreats": ["..."], "Description": "...", + "RationaleStatement": "...", "ImpactStatement": "...", + "RemediationProcedure": "...", "AuditProcedure": "...", + "AdditionalInformation": "...", "References": "..." } ``` ---- +`MaturityLevel`: `ML1|ML2|ML3`. `CloudApplicability`: `full|partial|limited|non-applicable`. -### AWS Well-Architected Framework - -**Framework ID format:** `aws_well_architected_framework_{pillar}_pillar_aws` +### DISA STIG — `okta_idaas_stig_v1r2_okta` ```json { - "Id": "SEC01-BP01", - "Description": "Establish common guardrails...", - "Name": "Establish common guardrails", - "Checks": ["account_part_of_organizations"], - "Attributes": [ - { - "Name": "Establish common guardrails", - "WellArchitectedQuestionId": "securely-operate", - "WellArchitectedPracticeId": "sec_securely_operate_multi_accounts", - "Section": "Security", - "SubSection": "Security foundations", - "LevelOfRisk": "High", - "AssessmentMethod": "Automated", - "Description": "Detailed description", - "ImplementationGuidanceUrl": "https://docs.aws.amazon.com/..." - } - ] + "Section": "...", "Severity": "high", "RuleID": "...", "StigID": "...", + "CCI": ["CCI-000015"], "CheckText": "...", "FixText": "..." } ``` ---- +`Severity`: `high|medium|low` (maps to CAT I/II/III). -### KISA ISMS-P (Korea) +### Other registered shapes -**Framework ID format:** `kisa_isms_p_{year}_{provider}` (e.g., `kisa_isms_p_2023_aws`) +- **AWS Well-Architected** (`aws_well_architected_framework_{pillar}_pillar_aws`): + `Name`, `WellArchitectedQuestionId`, `WellArchitectedPracticeId`, `Section`, + `SubSection`, `LevelOfRisk`, `AssessmentMethod`, `Description`, + `ImplementationGuidanceUrl`. +- **KISA ISMS-P** (`kisa_isms_p_2023_{provider}`): `Domain`, `Subdomain`, + `Section`, `AuditChecklist`, `RelatedRegulations`, `AuditEvidence`, + `NonComplianceCases`. +- **C5** (`c5_{provider}`): `Section`, `SubSection`, `Type`, `AboutCriteria`, + `ComplementaryCriteria`. +- **CSA CCM** (legacy shape; the shipped CSA CCM 4.0 is universal): `Section`, + `CCMLite`, `IaaS`, `PaaS`, `SaaS`, `ScopeApplicability`. +- **Prowler ThreatScore** (`prowler_threatscore_{provider}`): `Title`, + `Section`, `SubSection`, `AttributeDescription`, `AdditionalInformation`, + `LevelOfRisk` (1–5), `Weight` (1/8/10/100/1000). Pillars: 1 IAM, 2 Attack + Surface, 3 Logging and Monitoring, 4 Encryption. Available for aws, + azure, gcp, kubernetes, m365, alibabacloud. +- **Generic (fallback)**: `ItemId`, `Section`, `SubSection`, `SubGroup`, + `Service`, `Type`, `Comment` — all optional. Used by NIST, PCI, GDPR, + HIPAA, SOC2, FedRAMP, CISA, FFIEC, RBI, NIS2, GxP, SecNumCloud, etc. + +## Config Guardrails (`ConfigRequirements`) + +Requirements backed by [configurable checks](https://docs.prowler.com/developer-guide/configurable-checks) +can be silently "satisfied" by a loosened `audit_config` (e.g. CIS demands +45-day unused credentials but the scan ran with `max_unused_access_keys_days: 120`). +Guardrails force such requirements to FAIL: ```json -{ - "Id": "1.1.1", - "Description": "Requirement description", - "Name": "Requirement name", - "Checks": ["check_name"], - "Attributes": [ - { - "Domain": "1. Management System", - "Subdomain": "1.1 Management System Establishment", - "Section": "1.1.1 Section Name", - "AuditChecklist": ["Checklist item 1", "Checklist item 2"], - "RelatedRegulations": ["Regulation 1"], - "AuditEvidence": ["Evidence type 1"], - "NonComplianceCases": ["Non-compliance example"] - } - ] -} +"ConfigRequirements": [ + {"Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", "Operator": "lte", "Value": 45} +] ``` ---- - -### C5 (Germany Cloud Computing Compliance Criteria Catalogue) - -**Framework ID format:** `c5_{provider}` (e.g., `c5_aws`) - -```json -{ - "Id": "BCM-01", - "Description": "Requirement description", - "Name": "Requirement name", - "Checks": ["check_name"], - "Attributes": [ - { - "Section": "BCM Business Continuity Management", - "SubSection": "BCM-01", - "Type": "Basic Criteria", - "AboutCriteria": "Description of criteria", - "ComplementaryCriteria": "Additional criteria" - } - ] -} -``` +- Operators: `lte`/`gte` (numeric thresholds), `eq` (toggles/exact — use JSON + booleans, not 0/1), `in` (scalar in allowed set), `subset` (allowlists — + widening breaks it), `superset` (denylists — removing an entry breaks it). +- `Value` must be the **strictest** setting the control text tolerates. +- `ConfigKey` must be spelled exactly as the check reads it; unknown keys are + silently skipped (defaults assumed OK). +- Guardrails only tighten (PASS→FAIL), never relax. +- Universal files: lowercase `config_requirements` + mandatory `Provider` per + constraint. +- Tests: `tests/lib/check/compliance_config_eval_test.py`, + `compliance_config_constraint_model_test.py`, + `compliance_config_requirements_data_test.py`, plus per-output tests under + `tests/lib/outputs/compliance/`. --- -### CCC (Cloud Computing Compliance) - -**Framework ID format:** `ccc_{provider}` (e.g., `ccc_aws`) - -```json -{ - "Id": "CCC.C01", - "Description": "Requirement description", - "Name": "Requirement name", - "Checks": ["check_name"], - "Attributes": [ - { - "FamilyName": "Cryptography & Key Management", - "FamilyDescription": "Family description", - "Section": "CCC.C01", - "SubSection": "Key Management", - "SubSectionObjective": "Objective description", - "Applicability": ["IaaS", "PaaS", "SaaS"], - "Recommendation": "Recommended action", - "SectionThreatMappings": [{"threat": "T1190"}], - "SectionGuidelineMappings": [{"guideline": "NIST"}] - } - ] -} -``` - ---- - -### Prowler ThreatScore - -**Framework ID format:** `prowler_threatscore_{provider}` (e.g., `prowler_threatscore_aws`) - -Prowler ThreatScore is a custom security scoring framework developed by Prowler that evaluates AWS account security based on **four main pillars**: - -| Pillar | Description | -|--------|-------------| -| **1. IAM** | Identity and Access Management controls (authentication, authorization, credentials) | -| **2. Attack Surface** | Network exposure, public resources, security group rules | -| **3. Logging and Monitoring** | Audit logging, threat detection, forensic readiness | -| **4. Encryption** | Data at rest and in transit encryption | - -**Scoring System:** -- **LevelOfRisk** (1-5): Severity of the security issue - - `5` = Critical (e.g., root MFA, public S3 buckets) - - `4` = High (e.g., user MFA, public EC2) - - `3` = Medium (e.g., password policies, encryption) - - `2` = Low - - `1` = Informational -- **Weight**: Impact multiplier for score calculation - - `1000` = Critical controls (root security, public exposure) - - `100` = High-impact controls (user authentication, monitoring) - - `10` = Standard controls (password policies, encryption) - - `1` = Low-impact controls (best practices) - -```json -{ - "Id": "1.1.1", - "Description": "Ensure MFA is enabled for the 'root' user account", - "Checks": ["iam_root_mfa_enabled"], - "Attributes": [ - { - "Title": "MFA enabled for 'root'", - "Section": "1. IAM", - "SubSection": "1.1 Authentication", - "AttributeDescription": "The root user account holds the highest level of privileges within an AWS account. Enabling MFA enhances security by adding an additional layer of protection.", - "AdditionalInformation": "Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.", - "LevelOfRisk": 5, - "Weight": 1000 - } - ] -} -``` - -**Available for providers:** AWS, Kubernetes, M365 - ---- - -## Available Compliance Frameworks - -### AWS (41 frameworks) - -| Framework | File Name | -|-----------|-----------| -| CIS 1.4, 1.5, 2.0, 3.0, 4.0, 5.0 | `cis_{version}_aws.json` | -| ISO 27001:2013, 2022 | `iso27001_{year}_aws.json` | -| NIST 800-53 Rev 4, 5 | `nist_800_53_revision_{version}_aws.json` | -| NIST 800-171 Rev 2 | `nist_800_171_revision_2_aws.json` | -| NIST CSF 1.1, 2.0 | `nist_csf_{version}_aws.json` | -| PCI DSS 3.2.1, 4.0 | `pci_{version}_aws.json` | -| HIPAA | `hipaa_aws.json` | -| GDPR | `gdpr_aws.json` | -| SOC 2 | `soc2_aws.json` | -| FedRAMP Low/Moderate | `fedramp_{level}_revision_4_aws.json` | -| ENS RD2022 | `ens_rd2022_aws.json` | -| MITRE ATT&CK | `mitre_attack_aws.json` | -| C5 Germany | `c5_aws.json` | -| CISA | `cisa_aws.json` | -| FFIEC | `ffiec_aws.json` | -| RBI Cyber Security | `rbi_cyber_security_framework_aws.json` | -| AWS Well-Architected | `aws_well_architected_framework_{pillar}_pillar_aws.json` | -| AWS FTR | `aws_foundational_technical_review_aws.json` | -| GxP 21 CFR Part 11, EU Annex 11 | `gxp_{standard}_aws.json` | -| KISA ISMS-P 2023 | `kisa_isms_p_2023_aws.json` | -| NIS2 | `nis2_aws.json` | - -### Azure (15+ frameworks) - -| Framework | File Name | -|-----------|-----------| -| CIS 2.0, 2.1, 3.0, 4.0 | `cis_{version}_azure.json` | -| ISO 27001:2022 | `iso27001_2022_azure.json` | -| ENS RD2022 | `ens_rd2022_azure.json` | -| MITRE ATT&CK | `mitre_attack_azure.json` | -| PCI DSS 4.0 | `pci_4.0_azure.json` | -| NIST CSF 2.0 | `nist_csf_2.0_azure.json` | - -### GCP (15+ frameworks) - -| Framework | File Name | -|-----------|-----------| -| CIS 2.0, 3.0, 4.0 | `cis_{version}_gcp.json` | -| ISO 27001:2022 | `iso27001_2022_gcp.json` | -| HIPAA | `hipaa_gcp.json` | -| MITRE ATT&CK | `mitre_attack_gcp.json` | -| PCI DSS 4.0 | `pci_4.0_gcp.json` | -| NIST CSF 2.0 | `nist_csf_2.0_gcp.json` | - -### Kubernetes (6 frameworks) - -| Framework | File Name | -|-----------|-----------| -| CIS 1.8, 1.10, 1.11 | `cis_{version}_kubernetes.json` | -| ISO 27001:2022 | `iso27001_2022_kubernetes.json` | -| PCI DSS 4.0 | `pci_4.0_kubernetes.json` | - -### Other Providers -- **GitHub:** `cis_1.0_github.json` -- **M365:** `cis_4.0_m365.json`, `iso27001_2022_m365.json` -- **NHN:** `iso27001_2022_nhn.json` - ## Workflow A: Sync a Framework With an Upstream Catalog -Use when the framework is maintained upstream (CIS Benchmarks, FINOS CCC, CSA CCM, NIST, ENS, etc.) and Prowler needs to catch up. +Use when the framework is maintained upstream (CIS Benchmarks, FINOS CCC, CSA +CCM, NIST, ENS, etc.) and Prowler needs to catch up. ### Step 1 — Cache the upstream source -Download every upstream file to a local cache so subsequent iterations don't hit the network. For FINOS CCC: +Download every upstream file to a local cache so iterations don't hit the +network. For FINOS CCC: ```bash mkdir -p /tmp/ccc_upstream @@ -563,492 +715,480 @@ done ### Step 2 — Run the generic sync runner against a framework config -The sync tooling is split into three layers so adding a new framework only takes a YAML config (and optionally a new parser module for an unfamiliar upstream format): +The sync tooling is three layers, so adding a framework only takes a YAML +config (plus a parser module for an unfamiliar upstream format): ```text skills/prowler-compliance/assets/ ├── sync_framework.py # generic runner — works for any framework -├── configs/ -│ └── ccc.yaml # per-framework config (canonical example) -└── parsers/ - ├── __init__.py - └── finos_ccc.py # parser module for FINOS CCC YAML +├── configs/ccc.yaml # per-framework config (canonical example) +└── parsers/finos_ccc.py # parser module for FINOS CCC YAML ``` -**For frameworks that already have a config + parser** (today: FINOS CCC), run: - ```bash python skills/prowler-compliance/assets/sync_framework.py \ skills/prowler-compliance/assets/configs/ccc.yaml ``` -The runner loads the config, validates it, dynamically imports the parser declared in `parser.module`, calls `parser.parse_upstream(config) -> list[dict]`, then applies generic post-processing (id uniqueness safety net, `FamilyName` normalization, legacy check-mapping preservation) and writes the provider JSONs. +The runner loads the config, dynamically imports `parser.module`, calls +`parse_upstream(config) -> list[dict]`, then applies generic post-processing +(id-uniqueness safety net, `FamilyName` normalization, legacy check-mapping +preservation with config-driven fallback keys) and writes the provider JSONs +with Pydantic post-validation. **To add a new framework sync**: -1. **Write a config file** at `skills/prowler-compliance/assets/configs/{framework}.yaml`. See `configs/ccc.yaml` as the canonical example. Required top-level sections: - - `framework` — `name`, `display_name`, `version` (**never empty** — empty Version silently breaks `get_check_compliance()` key construction, so the runner refuses to start), `description_template` (accepts `{provider_display}`, `{provider_key}`, `{framework_name}`, `{framework_display}`, `{version}` placeholders). - - `providers` — list of `{key, display}` pairs, one per Prowler provider the framework targets. - - `output.path_template` — supports `{provider}`, `{framework}`, `{version}` placeholders. Examples: `"prowler/compliance/{provider}/ccc_{provider}.json"` for unversioned file names, `"prowler/compliance/{provider}/cis_{version}_{provider}.json"` for versioned ones. - - `upstream.dir` — local cache directory (populate via Step 1). - - `parser.module` — name of the module under `parsers/` to load (without `.py`). Everything else under `parser.` is opaque to the runner and passed to the parser as config. - - `post_processing.check_preservation.primary_key` — top-level field name for the primary legacy-mapping lookup (almost always `Id`). - - `post_processing.check_preservation.fallback_keys` — **config-driven fallback keys** for preserving check mappings when ids change. Each entry is a list of `Attributes[0]` field names composed into a tuple. Examples: - - CCC: `- [Section, Applicability]` (because `Applicability` is a CCC-only attribute, verified in `compliance_models.py:213`). - - CIS would use `- [Section, Profile]`. - - NIST would use `- [ItemId]`. - - List-valued fields (like `Applicability`) are automatically frozen to `frozenset` so the tuple is hashable. - - `post_processing.family_name_normalization` (optional) — map of raw → canonical `FamilyName` values. The UI groups by `Attributes[0].FamilyName` exactly, so inconsistent upstream variants otherwise become separate tree branches. +1. Write `assets/configs/{framework}.yaml` (see `ccc.yaml`). Required sections: + - `framework` — `name`, `display_name`, `version` (**never empty** — the + runner refuses to start, because empty Version breaks the + `get_check_compliance()` key), `description_template`. + - `providers` — list of `{key, display}` pairs. + - `output.path_template` — e.g. + `"prowler/compliance/{provider}/cis_{version}_{provider}.json"`. + - `upstream.dir` — local cache (Step 1). + - `parser.module` — module under `parsers/`; the rest of `parser.` is + passed through opaque. + - `post_processing.check_preservation.primary_key` (almost always `Id`) and + `fallback_keys` — lists of `Attributes[0]` field names composed into + tuples for recovering mappings when ids change. CCC: + `- [Section, Applicability]`; CIS: `- [Section, Profile]`; NIST: + `- [ItemId]`. List-valued fields are frozen to `frozenset` automatically. + - `post_processing.family_name_normalization` (optional) — raw → canonical + map; the UI groups by the exact attribute value, so upstream variants + otherwise become separate tree branches. +2. Reuse an existing parser or write `parsers/{name}.py` implementing + `parse_upstream(config) -> list[dict]` returning Prowler-format + requirements with **guaranteed-unique ids**. The runner raises on + duplicates — it never silently renumbers, because mutating a canonical + upstream id (CIS `1.1.1`, NIST `AC-2(1)`) would be catastrophic. The parser + owns all upstream quirks: foreign-prefix rewriting, genuine collision + renumbering, multi-shape handling. -2. **Reuse an existing parser** if the upstream format matches one (currently only `finos_ccc` exists). Otherwise, **write a new parser** at `parsers/{name}.py` implementing: +**Gotchas the runner already handles** (from the FINOS CCC v2025.10 sync): - ```python - def parse_upstream(config: dict) -> list[dict]: - """Return Prowler-format requirements {Id, Description, Attributes: [...], Checks: []}. - - Ids MUST be unique in the returned list. The runner raises ValueError - on duplicates — it does NOT silently renumber, because mutating a - canonical upstream id (e.g. CIS '1.1.1' or NIST 'AC-2(1)') would be - catastrophic. The parser owns all upstream-format quirks: foreign-prefix - rewriting, genuine collision renumbering, shape handling. - """ - ``` - - The parser reads its own settings from `config['upstream']` and `config['parser']`. It does NOT load existing Prowler JSONs (the runner does that for check preservation) and does NOT write output (the runner does that too). - -**Gotchas the runner already handles for you** (learned from the FINOS CCC v2025.10 sync — they're documented here so you don't re-discover them): - -- **Multiple upstream YAML shapes**. Most FINOS CCC catalogs use `control-families: [...]`, but `storage/object` uses a top-level `controls: [...]` with a `family: "CCC.X.Y"` reference id and no human-readable family name. A parser that only handles shape 1 silently drops the shape-2 catalog — this exact bug dropped ObjStor from Prowler for a full iteration. `parsers/finos_ccc.py` handles both shapes; if you write a new parser for a similar format, test with at least one file of each shape. -- **Whitespace collapse**. Upstream YAML multi-line block scalars (`|`) preserve newlines. Prowler stores descriptions single-line. Collapse with `" ".join(value.split())` before emitting (see `parsers/finos_ccc.py::clean()`). -- **Foreign-prefix AR id rewriting**. Upstream sometimes aliases requirements across catalogs by keeping the original prefix (e.g., `CCC.AuditLog.CN08.AR01` appears nested under `CCC.Logging.CN03`). Rewrite the foreign id to fit its parent control: `CCC.Logging.CN03.AR01`. This logic is parser-specific because the id structure varies per framework (CCC uses 3-dot depth; CIS uses numeric dots; NIST uses `AC-2(1)`). -- **Genuine upstream collision renumbering**. Sometimes upstream has a real typo where two different requirements share the same id (e.g., `CCC.Core.CN14.AR02` defined twice for 30-day and 14-day backup variants). Renumber the second copy to the next free AR number (`.AR03`). The parser handles this; the runner asserts the final list has unique ids as a safety net. -- **Existing check mapping preservation**. The runner uses the `primary_key` + `fallback_keys` declared in config to look up the old `Checks` list for each requirement. For CCC this means primary index by `Id` plus fallback index by `(Section, frozenset(Applicability))` — the fallback recovers mappings for requirements whose ids were rewritten or renumbered by the parser. -- **FamilyName normalization**. Configured via `post_processing.family_name_normalization` — no code changes needed to collapse upstream variants like `"Logging & Monitoring"` → `"Logging and Monitoring"`. -- **Populate `Version`**. The runner refuses to start on empty `framework.version` — fail-fast replaces the silent bug where `get_check_compliance()` would build the key as just `"{Framework}"`. +- **Multiple upstream YAML shapes.** Most FINOS CCC catalogs use + `control-families: [...]` but `storage/object` uses top-level + `controls: [...]`. A single-shape parser silently drops entire catalogs — + this exact bug dropped ObjStor for a full iteration. Test with one file of + each shape. +- **Whitespace collapse.** Upstream `|` block scalars keep newlines; Prowler + stores single-line. Collapse with `" ".join(value.split())`. +- **Foreign-prefix id rewriting.** Upstream aliases requirements across + catalogs keeping the original prefix (`CCC.AuditLog.CN08.AR01` nested under + `CCC.Logging.CN03`) — rewrite to fit the parent (`CCC.Logging.CN03.AR01`). +- **Genuine upstream collisions.** Two different requirements sharing one id + (upstream typo): renumber the second to the next free number; check-mapping + preservation recovers by the fallback keys. +- **Populate `Version`** — fail-fast beats the silent broken-key bug. ### Step 3 — Validate before committing -```python -from prowler.lib.check.compliance_models import Compliance -for prov in ['aws', 'azure', 'gcp']: - c = Compliance.parse_file(f"prowler/compliance/{prov}/ccc_{prov}.json") - print(f"{prov}: {len(c.Requirements)} reqs, version={c.Version}") -``` +Run the full Validation section below (universal loader + check existence + +CLI smoke + pytest). -Any `ValidationError` means the Attribute fields don't match the `*_Requirement_Attribute` model. Either fix the JSON or extend the model in `compliance_models.py` (remember: Generic stays last). +### Step 4 — Add an attribute model if needed -### Step 4 — Verify every check id exists - -```python -import json -from pathlib import Path -for prov in ['aws', 'azure', 'gcp']: - existing = {p.stem.replace('.metadata','') - for p in Path(f'prowler/providers/{prov}/services').rglob('*.metadata.json')} - with open(f'prowler/compliance/{prov}/ccc_{prov}.json') as f: - data = json.load(f) - refs = {c for r in data['Requirements'] for c in r['Checks']} - missing = refs - existing - assert not missing, f"{prov} missing: {missing}" -``` - -A stale check id silently becomes dead weight — no finding will ever map to it. This pre-validation **must run on every write**; bake it into the generator script. - -### Step 5 — Add an attribute model if needed - -Only if the framework has fields beyond `Generic_Compliance_Requirement_Attribute`. Add the class to `prowler/lib/check/compliance_models.py` and register it in `Compliance_Requirement.Attributes: list[Union[...]]`. **Generic stays last.** +Only if the framework has fields beyond +`Generic_Compliance_Requirement_Attribute` and must stay legacy. Add the class +to `compliance_models.py` and register it in the +`Compliance_Requirement.Attributes` Union **before Generic** (Generic stays +last). For new frameworks, prefer universal `attributes_metadata` instead. --- ## Workflow B: Audit Check Mappings as a Cloud Auditor -Use when the user asks to review existing mappings ("are these correct?", "verify that the checks apply", "audit the CCC mappings"). This is the highest-value compliance task — it surfaces padded mappings with zero actual coverage and missing mappings for legitimate coverage. +Use when the user asks to review existing mappings. This is the +highest-value compliance task — it surfaces padded mappings with zero actual +coverage and missing mappings for legitimate coverage. ### The golden rule -> A Prowler check's title/risk MUST **literally describe what the requirement text says**. "Related" is not enough. If no check actually addresses the requirement, leave `Checks: []` (MANUAL) — **honest MANUAL is worth more than padded coverage**. +> A Prowler check's title/risk MUST **literally describe what the requirement +> text says**. "Related" is not enough. If no check actually addresses the +> requirement, leave the checks list empty (MANUAL) — **honest MANUAL is worth +> more than padded coverage**. ### Audit process -**Step 1 — Build a per-provider check inventory** (cache in `/tmp/`): +1. **Build a per-provider check inventory** — `assets/build_inventory.py` + (writes `/tmp/checks_{provider}.json` for every provider discovered under + `prowler/providers/`). +2. **Query it** — `assets/query_checks.py` (run from the repository root): -```python -import json -from pathlib import Path -for provider in ['aws', 'azure', 'gcp']: - inv = {} - for meta in Path(f'prowler/providers/{provider}/services').rglob('*.metadata.json'): - with open(meta) as f: - d = json.load(f) - cid = d.get('CheckID') or meta.stem.replace('.metadata','') - inv[cid] = { - 'service': d.get('ServiceName', ''), - 'title': d.get('CheckTitle', ''), - 'risk': d.get('Risk', ''), - 'description': d.get('Description', ''), - } - with open(f'/tmp/checks_{provider}.json', 'w') as f: - json.dump(inv, f, indent=2) -``` + ```bash + python skills/prowler-compliance/assets/query_checks.py aws encryption transit # keyword AND-search + python skills/prowler-compliance/assets/query_checks.py aws --service iam # all iam checks + python skills/prowler-compliance/assets/query_checks.py aws --id kms_cmk_rotation_enabled + ``` -**Step 2 — Keyword/service query helper** — see [assets/query_checks.py](assets/query_checks.py): +3. **Dump a framework section with current mappings** — `assets/dump_section.py`: -```bash -python assets/query_checks.py aws encryption transit # keyword AND-search -python assets/query_checks.py aws --service iam # all iam checks -python assets/query_checks.py aws --id kms_cmk_rotation_enabled # full metadata -``` + ```bash + python skills/prowler-compliance/assets/dump_section.py ccc "CCC.Core." + python skills/prowler-compliance/assets/dump_section.py cis_5.0_aws "1." + ``` -**Step 3 — Dump a framework section with current mappings** — see [assets/dump_section.py](assets/dump_section.py): +4. **Encode explicit REPLACE decisions** — `assets/audit_framework_template.py`: -```bash -python assets/dump_section.py ccc "CCC.Core." # all Core ARs across 3 providers -python assets/dump_section.py ccc "CCC.AuditLog." # all AuditLog ARs -``` + ```python + DECISIONS = {} + DECISIONS["CCC.Core.CN01.AR01"] = { + "aws": ["cloudfront_distributions_https_enabled", ...], + "azure": ["storage_secure_transfer_required_is_enabled", ...], + "gcp": ["cloudsql_instance_ssl_connections"], + # Missing provider key = leave the legacy mapping untouched + } + # Empty list = EXPLICITLY MANUAL (overwrites legacy) + DECISIONS["CCC.Core.CN01.AR07"] = {"aws": [], "azure": [], "gcp": []} + ``` -**Step 4 — Encode explicit REPLACE decisions** — see [assets/audit_framework_template.py](assets/audit_framework_template.py). Structure: + **REPLACE, not PATCH.** Full lists make the audit reproducible and surface + hidden assumptions in the legacy data. +5. **Pre-validate** every check id against the inventory; the script MUST + abort with stderr listing typos (real audits caught + `storage_secure_transfer_required_enabled` → + `storage_secure_transfer_required_is_enabled`, + `sqlserver_minimum_tls_version_12` → + `sqlserver_recommended_minimal_tls_version`, and several checks that + simply don't exist). +6. **Apply + validate + test**: -```python -DECISIONS = {} + ```bash + python /path/to/audit_script.py + uv run pytest -n auto tests/lib/outputs/compliance/ tests/lib/check/ -q + ``` -DECISIONS["CCC.Core.CN01.AR01"] = { - "aws": [ - "cloudfront_distributions_https_enabled", - "cloudfront_distributions_origin_traffic_encrypted", - # ... - ], - "azure": [ - "storage_secure_transfer_required_is_enabled", - "app_minimum_tls_version_12", - # ... - ], - "gcp": [ - "cloudsql_instance_ssl_connections", - ], - # Missing provider key = leave the legacy mapping untouched -} - -# Empty list = EXPLICITLY MANUAL (overwrites legacy) -DECISIONS["CCC.Core.CN01.AR07"] = { - "aws": [], # Prowler has no IANA port/protocol check - "azure": [], - "gcp": [], -} -``` - -**REPLACE, not PATCH.** Encoding every mapping as a full list (not add/remove delta) makes the audit reproducible and surfaces hidden assumptions from the legacy data. - -**Step 5 — Pre-validation**. The audit script MUST validate every check id against the inventory and **abort with stderr listing typos**. Common typos caught during a real audit: - -- `fsx_file_system_encryption_at_rest_using_kms` (doesn't exist) -- `cosmosdb_account_encryption_at_rest_with_cmk` (doesn't exist) -- `sqlserver_geo_replication` (doesn't exist) -- `redshift_cluster_audit_logging` (should be `redshift_cluster_encrypted_at_rest`) -- `postgresql_flexible_server_require_secure_transport` (should be `postgresql_flexible_server_enforce_ssl_enabled`) -- `storage_secure_transfer_required_enabled` (should be `storage_secure_transfer_required_is_enabled`) -- `sqlserver_minimum_tls_version_12` (should be `sqlserver_recommended_minimal_tls_version`) - -**Step 6 — Apply + validate + test**: - -```bash -python /path/to/audit_script.py # applies decisions, pre-validates -python -m pytest tests/lib/outputs/compliance/ tests/lib/check/ -q -``` - -### Audit Reference Table: Requirement Text → Prowler Checks - -Use this table to map CCC-style / NIST-style / ISO-style requirements to the checks that actually verify them. Built from a real audit of 172 CCC ARs × 3 providers. - -| Requirement text | AWS checks | Azure checks | GCP checks | -|---|---|---|---| -| **TLS in transit enforced** | `cloudfront_distributions_https_enabled`, `s3_bucket_secure_transport_policy`, `elbv2_ssl_listeners`, `elbv2_insecure_ssl_ciphers`, `elb_ssl_listeners`, `elb_insecure_ssl_ciphers`, `opensearch_service_domains_https_communications_enforced`, `rds_instance_transport_encrypted`, `redshift_cluster_in_transit_encryption_enabled`, `elasticache_redis_cluster_in_transit_encryption_enabled`, `dynamodb_accelerator_cluster_in_transit_encryption_enabled`, `dms_endpoint_ssl_enabled`, `kafka_cluster_in_transit_encryption_enabled`, `transfer_server_in_transit_encryption_enabled`, `glue_database_connections_ssl_enabled`, `sns_subscription_not_using_http_endpoints` | `storage_secure_transfer_required_is_enabled`, `storage_ensure_minimum_tls_version_12`, `postgresql_flexible_server_enforce_ssl_enabled`, `mysql_flexible_server_ssl_connection_enabled`, `mysql_flexible_server_minimum_tls_version_12`, `sqlserver_recommended_minimal_tls_version`, `app_minimum_tls_version_12`, `app_ensure_http_is_redirected_to_https`, `app_ftp_deployment_disabled` | `cloudsql_instance_ssl_connections` (almost only option) | -| **TLS 1.3 specifically** | Partial: `cloudfront_distributions_using_deprecated_ssl_protocols`, `elb*_insecure_ssl_ciphers`, `*_minimum_tls_version_12` | Partial: `*_minimum_tls_version_12` checks | None — accept as MANUAL | -| **SSH / port 22 hardening** | `ec2_instance_port_ssh_exposed_to_internet`, `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22`, `ec2_networkacl_allow_ingress_tcp_port_22` | `network_ssh_internet_access_restricted`, `vm_linux_enforce_ssh_authentication` | `compute_firewall_ssh_access_from_the_internet_allowed`, `compute_instance_block_project_wide_ssh_keys_disabled`, `compute_project_os_login_enabled`, `compute_project_os_login_2fa_enabled` | -| **mTLS (mutual TLS)** | `kafka_cluster_mutual_tls_authentication_enabled`, `apigateway_restapi_client_certificate_enabled` | `app_client_certificates_on` | None — MANUAL | -| **Data at rest encrypted** | `s3_bucket_default_encryption`, `s3_bucket_kms_encryption`, `ec2_ebs_default_encryption`, `ec2_ebs_volume_encryption`, `rds_instance_storage_encrypted`, `rds_cluster_storage_encrypted`, `rds_snapshots_encrypted`, `dynamodb_tables_kms_cmk_encryption_enabled`, `redshift_cluster_encrypted_at_rest`, `neptune_cluster_storage_encrypted`, `documentdb_cluster_storage_encrypted`, `opensearch_service_domains_encryption_at_rest_enabled`, `kinesis_stream_encrypted_at_rest`, `firehose_stream_encrypted_at_rest`, `sns_topics_kms_encryption_at_rest_enabled`, `sqs_queues_server_side_encryption_enabled`, `efs_encryption_at_rest_enabled`, `athena_workgroup_encryption`, `glue_data_catalogs_metadata_encryption_enabled`, `backup_vaults_encrypted`, `backup_recovery_point_encrypted`, `cloudtrail_kms_encryption_enabled`, `cloudwatch_log_group_kms_encryption_enabled`, `eks_cluster_kms_cmk_encryption_in_secrets_enabled`, `sagemaker_notebook_instance_encryption_enabled`, `apigateway_restapi_cache_encrypted`, `kafka_cluster_encryption_at_rest_uses_cmk`, `dynamodb_accelerator_cluster_encryption_enabled`, `storagegateway_fileshare_encryption_enabled` | `storage_infrastructure_encryption_is_enabled`, `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encryption_enabled`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled`, `monitor_storage_account_with_activity_logs_cmk_encrypted` | `compute_instance_encryption_with_csek_enabled`, `dataproc_encrypted_with_cmks_disabled`, `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption` | -| **CMEK required (customer-managed keys)** | `kms_cmk_are_used` | `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled` | `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption`, `dataproc_encrypted_with_cmks_disabled`, `compute_instance_encryption_with_csek_enabled` | -| **Key rotation enabled** | `kms_cmk_rotation_enabled` | `keyvault_key_rotation_enabled`, `storage_key_rotation_90_days` | `kms_key_rotation_enabled` | -| **MFA for UI access** | `iam_root_mfa_enabled`, `iam_root_hardware_mfa_enabled`, `iam_user_mfa_enabled_console_access`, `iam_user_hardware_mfa_enabled`, `iam_administrator_access_with_mfa`, `cognito_user_pool_mfa_enabled` | `entra_privileged_user_has_mfa`, `entra_non_privileged_user_has_mfa`, `entra_user_with_vm_access_has_mfa`, `entra_security_defaults_enabled` | `compute_project_os_login_2fa_enabled` | -| **API access / credentials** | `iam_no_root_access_key`, `iam_user_no_setup_initial_access_key`, `apigateway_restapi_authorizers_enabled`, `apigateway_restapi_public_with_authorizer`, `apigatewayv2_api_authorizers_enabled` | `entra_conditional_access_policy_require_mfa_for_management_api`, `app_function_access_keys_configured`, `app_function_identity_is_configured` | `apikeys_api_restrictions_configured`, `apikeys_key_exists`, `apikeys_key_rotated_in_90_days` | -| **Log all admin/config changes** | `cloudtrail_multi_region_enabled`, `cloudtrail_multi_region_enabled_logging_management_events`, `cloudtrail_cloudwatch_logging_enabled`, `cloudtrail_log_file_validation_enabled`, `cloudwatch_log_metric_filter_*`, `cloudwatch_changes_to_*_alarm_configured`, `config_recorder_all_regions_enabled` | `monitor_diagnostic_settings_exists`, `monitor_diagnostic_setting_with_appropriate_categories`, `monitor_alert_*` | `iam_audit_logs_enabled`, `logging_log_metric_filter_and_alert_for_*`, `logging_sink_created` | -| **Log integrity (digital signatures)** | `cloudtrail_log_file_validation_enabled` (exact) | None | None | -| **Public access denied** | `s3_bucket_public_access`, `s3_bucket_public_list_acl`, `s3_bucket_public_write_acl`, `s3_account_level_public_access_blocks`, `apigateway_restapi_public`, `awslambda_function_url_public`, `awslambda_function_not_publicly_accessible`, `rds_instance_no_public_access`, `rds_snapshots_public_access`, `ec2_securitygroup_allow_ingress_from_internet_to_all_ports`, `sns_topics_not_publicly_accessible`, `sqs_queues_not_publicly_accessible` | `storage_blob_public_access_level_is_disabled`, `storage_ensure_private_endpoints_in_storage_accounts`, `containerregistry_not_publicly_accessible`, `keyvault_private_endpoints`, `app_function_not_publicly_accessible`, `aks_clusters_public_access_disabled`, `network_http_internet_access_restricted` | `cloudstorage_bucket_public_access`, `compute_instance_public_ip`, `cloudsql_instance_public_ip`, `compute_firewall_*_access_from_the_internet_allowed` | -| **IAM least privilege** | `iam_*_no_administrative_privileges`, `iam_policy_allows_privilege_escalation`, `iam_inline_policy_allows_privilege_escalation`, `iam_role_administratoraccess_policy`, `iam_group_administrator_access_policy`, `iam_user_administrator_access_policy`, `iam_policy_attached_only_to_group_or_roles`, `iam_role_cross_service_confused_deputy_prevention` | `iam_role_user_access_admin_restricted`, `iam_subscription_roles_owner_custom_not_created`, `iam_custom_role_has_permissions_to_administer_resource_locks` | `iam_sa_no_administrative_privileges`, `iam_no_service_roles_at_project_level`, `iam_role_kms_enforce_separation_of_duties`, `iam_role_sa_enforce_separation_of_duties` | -| **Password policy** | `iam_password_policy_minimum_length_14`, `iam_password_policy_uppercase`, `iam_password_policy_lowercase`, `iam_password_policy_symbol`, `iam_password_policy_number`, `iam_password_policy_expires_passwords_within_90_days_or_less`, `iam_password_policy_reuse_24` | None | None | -| **Credential rotation / unused** | `iam_rotate_access_key_90_days`, `iam_user_accesskey_unused`, `iam_user_console_access_unused` | None | `iam_sa_user_managed_key_rotate_90_days`, `iam_sa_user_managed_key_unused`, `iam_service_account_unused` | -| **VPC / flow logs** | `vpc_flow_logs_enabled` | `network_flow_log_captured_sent`, `network_watcher_enabled`, `network_flow_log_more_than_90_days` | `compute_subnet_flow_logs_enabled` | -| **Backup / DR / Multi-AZ** | `backup_vaults_exist`, `backup_plans_exist`, `backup_reportplans_exist`, `rds_instance_backup_enabled`, `rds_*_protected_by_backup_plan`, `rds_cluster_multi_az`, `neptune_cluster_backup_enabled`, `documentdb_cluster_backup_enabled`, `efs_have_backup_enabled`, `s3_bucket_cross_region_replication`, `dynamodb_table_protected_by_backup_plan` | `vm_backup_enabled`, `vm_sufficient_daily_backup_retention_period`, `storage_geo_redundant_enabled` | `cloudsql_instance_automated_backups`, `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_sufficient_retention_period` | -| **Access analysis / discovery** | `accessanalyzer_enabled`, `accessanalyzer_enabled_without_findings` | None specific | `iam_account_access_approval_enabled`, `iam_cloud_asset_inventory_enabled` | -| **Object lock / retention** | `s3_bucket_object_lock`, `s3_bucket_object_versioning`, `s3_bucket_lifecycle_enabled`, `cloudtrail_bucket_requires_mfa_delete`, `s3_bucket_no_mfa_delete` | `storage_ensure_soft_delete_is_enabled`, `storage_blob_versioning_is_enabled`, `storage_ensure_file_shares_soft_delete_is_enabled` | `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_soft_delete_enabled`, `cloudstorage_bucket_versioning_enabled`, `cloudstorage_bucket_sufficient_retention_period` | -| **Uniform bucket-level access** | `s3_bucket_acl_prohibited` | `storage_account_key_access_disabled`, `storage_default_to_entra_authorization_enabled` | `cloudstorage_bucket_uniform_bucket_level_access` | -| **Container vulnerability scanning** | `ecr_registry_scan_images_on_push_enabled`, `ecr_repositories_scan_vulnerabilities_in_latest_image` | `defender_container_images_scan_enabled`, `defender_container_images_resolved_vulnerabilities` | `artifacts_container_analysis_enabled`, `gcr_container_scanning_enabled` | -| **WAF / rate limiting** | `wafv2_webacl_with_rules`, `waf_*_webacl_with_rules`, `wafv2_webacl_logging_enabled`, `waf_global_webacl_logging_enabled` | None | None | -| **Deployment region restriction** | `organizations_scp_check_deny_regions` | None | None | -| **Secrets automatic rotation** | `secretsmanager_automatic_rotation_enabled`, `secretsmanager_secret_rotated_periodically` | `keyvault_rbac_secret_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None | -| **Certificate management** | `acm_certificates_expiration_check`, `acm_certificates_with_secure_key_algorithms`, `acm_certificates_transparency_logs_enabled` | `keyvault_key_expiration_set_in_non_rbac`, `keyvault_rbac_key_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None | -| **GenAI guardrails / input/output filtering** | `bedrock_guardrail_prompt_attack_filter_enabled`, `bedrock_guardrail_sensitive_information_filter_enabled`, `bedrock_agent_guardrail_enabled`, `bedrock_model_invocation_logging_enabled`, `bedrock_api_key_no_administrative_privileges`, `bedrock_api_key_no_long_term_credentials` | None | None | -| **ML dev environment security** | `sagemaker_notebook_instance_root_access_disabled`, `sagemaker_notebook_instance_without_direct_internet_access_configured`, `sagemaker_notebook_instance_vpc_settings_configured`, `sagemaker_models_vpc_settings_configured`, `sagemaker_training_jobs_vpc_settings_configured`, `sagemaker_training_jobs_network_isolation_enabled`, `sagemaker_training_jobs_volume_and_output_encryption_enabled` | None | None | -| **Threat detection / anomalous behavior** | `cloudtrail_threat_detection_enumeration`, `cloudtrail_threat_detection_privilege_escalation`, `cloudtrail_threat_detection_llm_jacking`, `guardduty_is_enabled`, `guardduty_no_high_severity_findings` | None | None | -| **Serverless private access** | `awslambda_function_inside_vpc`, `awslambda_function_not_publicly_accessible`, `awslambda_function_url_public` | `app_function_not_publicly_accessible` | None | - -### What Prowler Does NOT Cover (accept MANUAL honestly) - -Don't pad mappings for these — mark `Checks: []` and move on: - -- **TLS 1.3 version specifically** — Prowler verifies TLS is enforced, not always the exact version -- **IANA port-protocol consistency** — no check for "protocol running on its assigned port" -- **mTLS on most Azure/GCP services** — limited to App Service client certs on Azure, nothing on GCP -- **Rate limiting** on monitoring endpoints, load balancers, serverless invocations, vector ingestion -- **Session cookie expiry** (LB stickiness) -- **HTTP header scrubbing** (Server, X-Powered-By) -- **Certificate transparency verification for imports** -- **Model version pinning, red teaming, AI quality review** -- **Vector embedding validation, dimensional constraints, ANN vs exact search** -- **Secret region replication** (cross-region residency) -- **Lifecycle cleanup policies on container registries** -- **Row-level / column-level security in data warehouses** -- **Deployment region restriction on Azure/GCP** (AWS has `organizations_scp_check_deny_regions`, others don't) -- **Cross-tenant alert silencing permissions** -- **Field-level masking in logs** -- **Managed view enforcement for database access** -- **Automatic MFA delete on all S3 buckets** (only CloudTrail bucket variant exists for some frameworks — AWS has the generic `s3_bucket_no_mfa_delete` though) +For the curated mapping table (requirement text → AWS/Azure/GCP checks) and +the list of controls Prowler genuinely cannot verify, see +[references/check-mapping-reference.md](references/check-mapping-reference.md). --- -## Workflow C: Add a New Output Formatter +## Workflow C: Add a New Universal Framework -Use when a new framework needs its own CSV columns or terminal table. Follow the c5/csa/ens layout exactly: +1. Author `prowler/compliance/{framework}_{version}.json` following the + Universal Schema Reference above (use `dora_2022_2554.json` or + `csa_ccm_4.0.json` as template). +2. Declare every attribute in `attributes_metadata` (with `required`/`enum` + where possible — that's your load-time validation) and a + `outputs.table_config.group_by`. +3. Map checks per provider; add `config_requirements` (with `Provider`) for + configurable checks; leave empty lists for manual requirements — **include + every requirement of the source catalog** (coverage percentages depend on + the full denominator). +4. Validate (section below). No Python registration of any kind is needed for + CLI table/CSV/OCSF. +5. Optional first-class UI: mapper in `ui/lib/compliance/{framework}.tsx`, + registration in `getComplianceMappers()` under the JSON's `framework` value, + detail panel, `*AttributesMetadata` type, and icon (ordered keyword!). Until + then the generic mapper renders it. +6. Optional API extras: CSV exporter entry in `COMPLIANCE_CLASS_MAP`; PDF + generator + `FRAMEWORK_REGISTRY` entry if a PDF is required. +7. Tests: extend `tests/lib/check/universal_compliance_models_test.py` with a + case loading the new JSON. The parametrized `test_loads_as_universal` + already picks the file up automatically. +8. Changelog fragment `prowler/changelog.d/.added.md` + user-guide + tutorial under `docs/user-guide/compliance/tutorials/` for high-profile + frameworks. -```bash -mkdir -p prowler/lib/outputs/compliance/{framework} -touch prowler/lib/outputs/compliance/{framework}/__init__.py -``` +## Workflow D: Add a New Legacy Output Formatter -### Step 1 — Create `{framework}.py` (table dispatcher ONLY) +Only for new members of an existing legacy family. Follow the `c5/` or `ccc/` +layout exactly: -Copy from `prowler/lib/outputs/compliance/c5/c5.py` and change the function name + framework string. The `diff` between your file and `c5.py` should be just those two lines. **No function docstring** — other frameworks don't have one, stay consistent. +1. `mkdir prowler/lib/outputs/compliance/{framework}` with `__init__.py`. +2. `{framework}.py` — copy `c5/c5.py`, change function name + framework + string; the diff should be just those lines. No docstring (legacy style). +3. `models.py` — one Pydantic CSV row model per provider. Column sets differ + per provider (`AccountId`/`Region` vs `SubscriptionId`/`Location` vs + `ProjectId`/`Location`); per-provider files are the convention — don't + collapse them into a parameterized class, reviewers will reject it. +4. `{framework}_{provider}.py` — `{Framework}_{Provider}(ComplianceOutput)` + with `transform()`; this file may import `Finding`. +5. Register: + - `compliance.py` → `display_compliance_table()` `elif` branch (+ top import). + - `prowler/__main__.py` → per-provider `elif compliance_name.startswith(...)` + branches instantiating the writer classes. + - `api/src/backend/tasks/jobs/export.py` → `COMPLIANCE_CLASS_MAP` entries + (`startswith` for families, exact match only for true singletons). +6. Tests under `tests/lib/outputs/compliance/{framework}/` + fixtures in + `tests/lib/outputs/compliance/fixtures.py` (1 evaluated + 1 manual + requirement to exercise both `transform()` paths). -### Step 2 — Create `models.py` +**Circular import warning**: the table file must not import `Finding` directly +or transitively (cycle: `compliance.compliance` → table → `ComplianceOutput` → +`Finding` → `get_check_compliance` → `compliance.compliance`). Keep it bare; +use `TYPE_CHECKING`/function-local imports where both are genuinely needed. -One Pydantic v2 `BaseModel` per provider. Field names become CSV column headers (public API — don't rename later without a migration). +--- -```python -from typing import Optional -from pydantic import BaseModel +## Validation (run before every commit) -class {Framework}_AWSModel(BaseModel): - Provider: str - Description: str - AccountId: str - Region: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - # ... provider-specific columns - Status: str - StatusExtended: str - ResourceId: str - ResourceName: str - CheckId: str - Muted: bool -``` +1. **Schema load (both formats)**: -### Step 3 — Create `{framework}_{provider}.py` for each provider + ```python + from prowler.lib.check.compliance_models import ( + load_compliance_framework_universal, + get_bulk_compliance_frameworks_universal, + ) + fw = load_compliance_framework_universal("prowler/compliance/.json") + assert fw is not None, "check logs for the ValidationError" + print(fw.framework, len(fw.requirements), fw.get_providers()) + assert "" in get_bulk_compliance_frameworks_universal("aws") + ``` -Copy from `prowler/lib/outputs/compliance/c5/c5_aws.py` etc. Contains the `{Framework}_AWS(ComplianceOutput)` class with `transform()` that walks findings and emits model rows. This file IS allowed to import `Finding`. + Remember: the universal loader is lenient (skips broken files with a log + line) — an `assert fw is not None` is mandatory, a green scan is not proof. -### Step 4 — Register everywhere +2. **Check existence** — no loader validates this; a stale id is silent dead + weight: -**`prowler/lib/outputs/compliance/compliance.py`** (CLI table dispatcher): -```python -from prowler.lib.outputs.compliance.{framework}.{framework} import get_{framework}_table + ```python + import json + from pathlib import Path + for prov in ["aws", "azure", "gcp"]: + real = {p.stem.replace(".metadata", "") + for p in Path(f"prowler/providers/{prov}/services").rglob("*.metadata.json")} + data = json.load(open(f"prowler/compliance/{prov}/.json")) + refs = {c for r in data["Requirements"] for c in r["Checks"]} + missing = refs - real + assert not missing, f"{prov} missing: {missing}" + ``` -def display_compliance_table(...): - ... - elif compliance_framework.startswith("{framework}_"): - get_{framework}_table(findings, bulk_checks_metadata, - compliance_framework, output_filename, - output_directory, compliance_overview) -``` + (For universal files use `r.get("checks", {}).get(prov, [])` instead — + requirements may legitimately omit a provider key.) -**`prowler/__main__.py`** (CLI output writer per provider): -Add imports at the top: -```python -from prowler.lib.outputs.compliance.{framework}.{framework}_aws import {Framework}_AWS -from prowler.lib.outputs.compliance.{framework}.{framework}_azure import {Framework}_Azure -from prowler.lib.outputs.compliance.{framework}.{framework}_gcp import {Framework}_GCP -``` -Add provider-specific `elif compliance_name.startswith("{framework}_"):` branches that instantiate the class and call `batch_write_data_to_file()`. +3. **CLI smoke test**: -**`api/src/backend/tasks/jobs/export.py`** (API export dispatcher): -```python -from prowler.lib.outputs.compliance.{framework}.{framework}_aws import {Framework}_AWS -# ... azure, gcp + ```bash + uv run python prowler-cli.py --list-compliance # appears? + uv run python prowler-cli.py --compliance --log-level ERROR + ``` -COMPLIANCE_CLASS_MAP = { - "aws": [ - # ... - (lambda name: name.startswith("{framework}_"), {Framework}_AWS), - ], - # ... azure, gcp -} -``` + Verify the CSV under `output/compliance/`, the summary table sections, and + the findings roll-up. -**Always use `startswith`**, never `name == "framework_aws"`. Exact match is a regression. +4. **Tests**: -### Step 5 — Add tests + ```bash + uv run pytest -n auto tests/lib/check/universal_compliance_models_test.py \ + tests/lib/outputs/compliance/ + ``` -Create `tests/lib/outputs/compliance/{framework}/` with `{framework}_aws_test.py`, `{framework}_azure_test.py`, `{framework}_gcp_test.py`. See the test template in [references/test_template.md](references/test_template.md). + `test_loads_as_universal` is parametrized over **every** JSON in + `prowler/compliance/` (top-level + subdirectories) — a malformed file fails + CI here even if you never wrote a dedicated test. -Add fixtures to `tests/lib/outputs/compliance/fixtures.py`: one `Compliance` object per provider with 1 evaluated + 1 manual requirement to exercise both code paths in `transform()`. +5. **What CI/pre-commit do and don't cover**: pre-commit only guarantees + well-formed/pretty JSON (`check-json`, `pretty-format-json`) — no semantic + validation. The workflow `.github/workflows/pr-check-compliance-mapping.yml` + flags PRs adding new checks without mapping them to any framework (label + `needs-compliance-review`; skip with label `no-compliance-check`). Semantic + validation happens in the pytest suite above and manually via + `skills/prowler-compliance-review/assets/validate_compliance.py` (note: + that validator assumes the **legacy** schema). -### Circular import warning - -**The table dispatcher file (`{framework}.py`) MUST NOT import `Finding`** (directly or transitively). The cycle is: - -```text -compliance.compliance imports get_{framework}_table - → {framework}.py imports ComplianceOutput - → compliance_output imports Finding - → finding imports get_check_compliance from compliance.compliance - → CIRCULAR -``` - -Keep `{framework}.py` bare — only `colorama`, `tabulate`, `prowler.config.config`. Put anything that imports `Finding` in the per-provider `{framework}_{provider}.py` files. +6. **Prowler Local Server**: `docker compose up` and confirm the compliance + page renders requirements, sections and widgets. --- ## Conventions and Hard-Won Gotchas -These are lessons from the FINOS CCC v2025.10 sync + 172-AR audit pass (April 2026). Learn them once; save days of debugging. - -1. **Per-provider files are non-negotiable.** Never collapse `{framework}_aws.py`, `{framework}_azure.py`, `{framework}_gcp.py` into a single parameterized class, no matter how DRY-tempting. Every other framework in the codebase follows the per-provider pattern and reviewers will reject the refactor. The CSV column names differ per provider — three classes is the convention. -2. **`{framework}.py` has NO function docstring.** Other frameworks don't have them. Don't add one to be "helpful". -3. **Circular import protection**: the table dispatcher file MUST NOT import `Finding` (directly or transitively). Split the code so `{framework}.py` only has `get_{framework}_table()` with bare imports, and `{framework}_{provider}.py` holds the class that needs `Finding`. -4. **`Generic_Compliance_Requirement_Attribute` is the fallback** — in the `Compliance_Requirement.Attributes` Union in `compliance_models.py`, Generic MUST be LAST because Pydantic v1 tries union members in order. Putting Generic first means every framework-specific attribute falls through to Generic and the specific model is never used. -5. **Pydantic v1 imports.** `from pydantic.v1 import BaseModel` in `compliance_models.py` — not v2. Mixing causes validation errors. Pydantic v2 is used in the CSV models (`models.py`) — that's fine because they're separate trees. -6. **`get_check_compliance()` key format** is `f"{Framework}-{Version}"` ONLY if Version is set. Empty Version → key is `"{Framework}"` (no version suffix). Tests that mock compliance dicts must match this exact format — when a framework ships with `Version: ""`, downstream code and tests break silently. -7. **CSV column names from `models.py` are public API.** Don't rename a field without migrating downstream consumers — CSV headers change. -8. **Upstream YAML multi-line scalars** (`|` block scalars) preserve newlines. Collapse to single-line with `" ".join(value.split())` before writing to JSON. -9. **Upstream catalogs can use multiple shapes.** FINOS CCC uses `control-families: [...]` in most catalogs but `controls: [...]` at the top level in `storage/object`. Any sync script must handle both or silently drop entire catalogs. -10. **Foreign-prefix AR ids.** Upstream sometimes "imports" requirements from one catalog into another by keeping the original id prefix (e.g., `CCC.AuditLog.CN08.AR01` appearing under `CCC.Logging.CN03`). Prowler's compliance model requires unique ids within a catalog — rewrite the foreign id to fit the parent control: `CCC.AuditLog.CN08.AR01` (inside `CCC.Logging.CN03`) → `CCC.Logging.CN03.AR01`. -11. **Genuine upstream id collisions.** Sometimes upstream has a real typo where two different requirements share the same id (e.g., `CCC.Core.CN14.AR02` defined twice for 30-day and 14-day backup variants). Renumber the second copy to the next free AR number. Preserve check mappings by matching on `(Section, frozenset(Applicability))` since the renumbered id won't match by id. -12. **`COMPLIANCE_CLASS_MAP` in `export.py` uses `startswith` predicates** for all modern frameworks. Exact match (`name == "ccc_aws"`) is an anti-pattern — it was present for CCC until April 2026 and was the reason CCC couldn't have versioned variants. -13. **Pre-validate every check id** against the per-provider inventory before writing the JSON. A typo silently creates an unreferenced check that will fail when findings try to map to it. The audit script MUST abort with stderr listing typos, not swallow them. -14. **REPLACE is better than PATCH** for audit decisions. Encoding every mapping explicitly makes the audit reproducible and surfaces hidden assumptions from the legacy data. A PATCH system that adds/removes is too easy to forget. -15. **When no check applies, MANUAL is correct.** Do not pad mappings with tangential checks "just in case". Prowler's compliance reports are meant to be actionable — padding them with noise breaks that. Honest manual reqs can be mapped later when new checks land. -16. **UI groups by `Attributes[0].FamilyName` and `Attributes[0].Section`.** If FamilyName has inconsistent variants within the same JSON (e.g., "Logging & Monitoring" vs "Logging and Monitoring"), the UI renders them as separate categories. Section empty → the requirement falls into an orphan control with label "". Normalize before shipping. -17. **Provider coverage is asymmetric.** AWS has dense coverage (~586 checks across 80+ services): in-transit encryption, IAM, database encryption, backup. Azure (~167 checks) and GCP (~102 checks) are thinner especially for in-transit encryption, mTLS, and ML/AI. Accept the asymmetry in mappings — don't force GCP parity where Prowler genuinely can't verify. +1. **Universal first.** A new framework that starts as legacy needs 3 output + files + 3 registrations; the same framework as universal needs zero. Only + extend legacy families. +2. **`Generic_Compliance_Requirement_Attribute` stays LAST** in the legacy + Attributes Union — Pydantic v1 tries members in order; Generic first + silently swallows every specific shape. +3. **Pydantic v1 everywhere in `compliance_models.py`** + (`from pydantic.v1 import ...`). Don't mix in v2. +4. **`get_check_compliance()` lives in + `prowler/lib/outputs/compliance/compliance_check.py`** and keys the dict + `f"{Framework}-{Version}"` only when Version is non-empty. Never ship + `Version: ""` — the key silently degrades to `"{Framework}"` and breaks + filters, tests and `--compliance`. For legacy files the filename version + substring must match `Version` (the CLI reads + `compliance_framework.split("_")[1]`). +5. **`Compliance.get_bulk()` does not see top-level universal files** — only + `get_bulk_compliance_frameworks_universal()` does. Wire new code paths + against the universal loader. +6. **Loader leniency differs**: legacy loader exits the process on a broken + JSON; universal loader logs and skips. A missing framework after your edit + usually means the universal loader dropped it — check the logs. +7. **Circular import protection**: legacy table dispatcher files must not + import `Finding` (directly or transitively). Use `TYPE_CHECKING` or + function-local imports when a module needs both sides (that's how the + universal formatter does it). +8. **Per-provider formatter files are the legacy convention** — but know the + exceptions before flagging them (iso27001 has no table file, + aws_well_architected has no per-provider files, cisa_scuba is + googleworkspace-only). CSV model field names are public API. +9. **CSV output**: `;` delimiter, UPPERCASE headers. OCSF compliance output is + always generated for universal frameworks regardless of `--output-formats`. +10. **`COMPLIANCE_CLASS_MAP` mixes predicate styles**: `startswith` for + multi-version families, exact `==` for singletons. When in doubt use + `startswith` — exact match blocked versioned CCC variants until 2026. +11. **UI grouping is per-mapper, always on `attributes[0]`**: generic/cis → + `Section`/`SubSection`, iso → `Category`, ccc → `FamilyName`. Inconsistent + values (or empty Section) create orphan/duplicate tree branches — normalize + before shipping. +12. **UI has a generic fallback** — an unregistered framework still renders. + A dedicated mapper/panel/icon is an upgrade, not a prerequisite. +13. **Icon registration is ordered substring matching** in + `IconCompliance.tsx` — specific keywords before generic (`nist` before + `nis2`, `cisa` before `cis`, `aws` last). +14. **API PDF pipeline is not `PDFConfig`-driven yet** — it has its own + `FRAMEWORK_REGISTRY` (5 frameworks). Don't assume adding `pdf_config` to a + JSON produces a PDF in Prowler App. +15. **Pre-validate every check id** against the per-provider inventory before + writing JSON. No loader will catch a typo; the requirement just never + matches a finding. +16. **REPLACE beats PATCH** for audit decisions — full explicit lists are + reproducible and surface legacy assumptions. +17. **When no check applies, MANUAL is correct.** Don't pad mappings with + tangential checks; compliance reports must stay actionable. +18. **Include every requirement of the source catalog**, automated or not — + compliance percentages use the full requirement count as denominator. +19. **Provider coverage is asymmetric** (AWS dense; Azure/GCP thinner; new + providers minimal). Accept it — don't force parity Prowler can't verify. +20. **Guardrail authoring**: strictest tolerated `Value`, exact `ConfigKey` + spelling, `Provider` mandatory in universal files, booleans as JSON + booleans. Malformed constraints are treated as satisfied — validate with + the config tests, don't trust silence. --- ## Useful One-Liners ```bash -# Count requirements per service prefix (CCC, CIS sections, etc.) -jq -r '.Requirements[].Id | split(".")[1]' prowler/compliance/aws/ccc_aws.json | sort | uniq -c - -# Find duplicate requirement IDs +# Find duplicate requirement IDs (legacy | universal) jq -r '.Requirements[].Id' file.json | sort | uniq -d +jq -r '.requirements[].id' file.json | sort | uniq -d -# Count manual requirements (no checks) +# Count manual requirements (legacy | universal, per provider) jq '[.Requirements[] | select((.Checks | length) == 0)] | length' file.json +jq '[.requirements[] | select((.checks.aws // [] | length) == 0)] | length' file.json -# List all unique check references in a framework +# List unique check references (legacy | universal) jq -r '.Requirements[].Checks[]' file.json | sort -u +jq -r '.requirements[].checks[]? | .[]' file.json | sort -u -# List all unique Sections (to spot inconsistency) +# Providers covered by a universal framework +jq '[.requirements[].checks | keys[]] | unique' file.json + +# Spot inconsistent grouping values (UI tree branches) jq '[.Requirements[].Attributes[0].Section] | unique' file.json - -# List all unique FamilyNames (to spot inconsistency) jq '[.Requirements[].Attributes[0].FamilyName] | unique' file.json -# Diff requirement ids between two versions of the same framework +# Requirements with config guardrails (empty arrays are truthy in jq — check length) +jq '[.Requirements[] | select((.ConfigRequirements // []) | length > 0)] | length' file.json + +# Diff requirement ids between two versions diff <(jq -r '.Requirements[].Id' a.json | sort) <(jq -r '.Requirements[].Id' b.json | sort) -# Find where a check id is used across all frameworks +# Where is a check mapped across all frameworks? grep -rl "my_check_name" prowler/compliance/ -# Check if a Prowler check exists +# Does a check exist? find prowler/providers/aws/services -name "{check_id}.metadata.json" -# Validate a JSON with Pydantic -python -c "from prowler.lib.check.compliance_models import Compliance; print(Compliance.parse_file('prowler/compliance/aws/ccc_aws.json').Framework)" +# Validate one file with the universal loader +python -c "from prowler.lib.check.compliance_models import load_compliance_framework_universal as l; fw=l('prowler/compliance/aws/cis_7.0_aws.json'); print(fw.framework, len(fw.requirements))" ``` ---- - -## Best Practices - -1. **Requirement IDs**: Follow the original framework numbering exactly (e.g., "1.1", "A.5.1", "T1190", "ac_2_1") -2. **Check Mapping**: Map to existing checks when possible. Use `Checks: []` for manual-only requirements — honest MANUAL beats padded coverage -3. **Completeness**: Include all framework requirements, even those without automated checks -4. **Version Control**: Include framework version in `Name` and `Version` fields. **Never leave `Version: ""`** — it breaks `get_check_compliance()` key format -5. **File Naming**: Use format `{framework}_{version}_{provider}.json` -6. **Validation**: Prowler validates JSON against Pydantic models at startup — invalid JSON will cause errors -7. **Pre-validate check ids** against the provider's `*.metadata.json` inventory before every commit -8. **Normalize FamilyName and Section** to avoid inconsistent UI tree branches -9. **Register everywhere**: SDK model (if needed) → `compliance.py` dispatcher → `__main__.py` CLI writer → `export.py` API map → UI mapper. Skipping any layer results in silent failures -10. **Audit, don't pad**: when reviewing mappings, apply the golden rule — the check's title/risk MUST literally describe what the requirement text says. Tangential relation doesn't count - ## Commands ```bash -# List available frameworks for a provider prowler {provider} --list-compliance - -# Run scan with specific compliance framework -prowler aws --compliance cis_5.0_aws - -# Run scan with multiple frameworks -prowler aws --compliance cis_5.0_aws pci_4.0_aws - -# Output compliance report in multiple formats -prowler aws --compliance cis_5.0_aws -M csv json html +prowler {provider} --compliance cis_7.0_aws +prowler aws --compliance cis_7.0_aws pci_4.0_aws +prowler aws --compliance dora_2022_2554 # universal key = file basename +prowler aws --list-compliance-requirements cis_7.0_aws +prowler aws --compliance cis_7.0_aws -M csv json html ``` ## Code References ### Layer 1 — SDK / Core -- **Compliance Models:** `prowler/lib/check/compliance_models.py` (Pydantic v1 model tree) -- **Compliance Processing / Linker:** `prowler/lib/check/compliance.py` (`get_check_compliance`, `update_checks_metadata_with_compliance`) -- **Check Utils:** `prowler/lib/check/utils.py` (`list_compliance_modules`) + +- `prowler/lib/check/compliance_models.py` — legacy + universal model trees, + `Compliance_Requirement_ConfigConstraint`, all loaders and the + legacy→universal adapter +- `prowler/lib/check/compliance.py` — `update_checks_metadata_with_compliance` +- `prowler/lib/check/compliance_config_eval.py` — guardrail evaluation + (shared with the API) +- `prowler/lib/outputs/compliance/compliance_check.py` — `get_check_compliance` +- `prowler/lib/check/utils.py` — `list_compliance_modules` ### Layer 2 — JSON Catalogs -- **Framework JSONs:** `prowler/compliance/{provider}/` (auto-discovered via directory walk) + +- `prowler/compliance/*.json` — universal, multi-provider (auto-discovered) +- `prowler/compliance/{provider}/` — legacy, per-provider (auto-discovered) ### Layer 3 — Output Formatters -- **Per-framework folders:** `prowler/lib/outputs/compliance/{framework}/` -- **Shared base class:** `prowler/lib/outputs/compliance/compliance_output.py` (`ComplianceOutput` + `batch_write_data_to_file`) -- **CLI table dispatcher:** `prowler/lib/outputs/compliance/compliance.py` (`display_compliance_table`) -- **Finding model:** `prowler/lib/outputs/finding.py` (**do not import transitively from table dispatcher files — circular import**) -- **CLI writer:** `prowler/__main__.py` (per-provider `elif compliance_name.startswith(...)` branches that instantiate per-provider classes) + +- `prowler/lib/outputs/compliance/universal/` — `universal_table.py`, + `universal_output.py`, `ocsf_compliance.py` +- `prowler/lib/outputs/compliance/{framework}/` — legacy per-framework packages +- `prowler/lib/outputs/compliance/compliance.py` — + `process_universal_compliance_frameworks`, `display_compliance_table` +- `prowler/lib/outputs/compliance/compliance_output.py` — `ComplianceOutput` + base + CSV writer +- `prowler/__main__.py` — universal processing + per-provider legacy writer + branches ### Layer 4 — API / UI -- **API lazy loader:** `api/src/backend/api/compliance.py` (`LazyComplianceTemplate`, `LazyChecksMapping`) -- **API export dispatcher:** `api/src/backend/tasks/jobs/export.py` (`COMPLIANCE_CLASS_MAP` with `startswith` predicates) -- **UI framework router:** `ui/lib/compliance/compliance-mapper.ts` -- **UI per-framework mapper:** `ui/lib/compliance/{framework}.tsx` -- **UI detail panel:** `ui/components/compliance/compliance-custom-details/{framework}-details.tsx` -- **UI types:** `ui/types/compliance.ts` -- **UI icon:** `ui/components/icons/compliance/{framework}.svg` + registration in `IconCompliance.tsx` + +- `api/src/backend/api/compliance.py` — `LazyComplianceTemplate`, + `LazyChecksMapping`, cache warm-up +- `api/src/backend/tasks/jobs/export.py` — `COMPLIANCE_CLASS_MAP` +- `api/src/backend/tasks/jobs/scan.py` — `create_compliance_requirements` + (overview ingestion) +- `api/src/backend/tasks/jobs/reports/` — PDF generators + `FRAMEWORK_REGISTRY` +- `ui/lib/compliance/compliance-mapper.ts` — mapper routing + generic fallback +- `ui/lib/compliance/{framework}.tsx` — per-framework mappers +- `ui/components/compliance/compliance-custom-details/` — detail panels +- `ui/types/compliance.ts` — attribute metadata types +- `ui/components/icons/compliance/` + `IconCompliance.tsx` — icons (ordered) ### Tests -- **Output formatter tests:** `tests/lib/outputs/compliance/{framework}/{framework}_{provider}_test.py` -- **Shared fixtures:** `tests/lib/outputs/compliance/fixtures.py` + +- `tests/lib/check/universal_compliance_models_test.py` — includes the + parametrized `test_loads_as_universal` over every shipped JSON +- `tests/lib/check/compliance_check_test.py`, + `compliance_config_eval_test.py`, `compliance_config_constraint_model_test.py`, + `compliance_config_requirements_data_test.py`, `mitre_config_requirements_test.py` +- `tests/lib/outputs/compliance/` — per-framework + universal + dispatcher + + config-status coverage tests; shared `fixtures.py` ## Resources -- **JSON Templates:** See [assets/](assets/) for framework JSON templates (cis, ens, iso27001, mitre_attack, prowler_threatscore, generic) -- **Config-driven compliance sync** (any upstream-backed framework): - - [assets/sync_framework.py](assets/sync_framework.py) — generic runner. Loads a YAML config, dynamically imports the declared parser, applies generic post-processing (id uniqueness safety net, `FamilyName` normalization, legacy check-mapping preservation with config-driven fallback keys), and writes the provider JSONs with Pydantic post-validation. Framework-agnostic — works for any compliance framework. - - [assets/configs/ccc.yaml](assets/configs/ccc.yaml) — canonical config example (FINOS CCC v2025.10). Copy and adapt for new frameworks. - - [assets/parsers/finos_ccc.py](assets/parsers/finos_ccc.py) — FINOS CCC YAML parser. Handles both upstream shapes (`control-families` and top-level `controls`), foreign-prefix AR rewriting, and genuine collision renumbering. Exposes `parse_upstream(config) -> list[dict]`. - - [assets/parsers/](assets/parsers/) — add new parser modules here for unfamiliar upstream formats (NIST OSCAL JSON, MITRE STIX, CIS Benchmarks, etc.). Each parser is a `{name}.py` file implementing `parse_upstream(config) -> list[dict]` with guaranteed-unique ids. -- **Reusable audit tooling** (added April 2026 after the FINOS CCC v2025.10 sync): - - [assets/audit_framework_template.py](assets/audit_framework_template.py) — explicit REPLACE decision ledger with pre-validation against the per-provider inventory. Drop-in template for auditing any framework. - - [assets/query_checks.py](assets/query_checks.py) — keyword/service/id query helper over `/tmp/checks_{provider}.json`. - - [assets/dump_section.py](assets/dump_section.py) — dumps every AR for a given id prefix across all 3 providers with current check mappings. - - [assets/build_inventory.py](assets/build_inventory.py) — generates `/tmp/checks_{provider}.json` from `*.metadata.json` files. -- **Documentation:** See [references/compliance-docs.md](references/compliance-docs.md) for additional resources -- **Related skill:** [prowler-compliance-review](../prowler-compliance-review/SKILL.md) — PR review checklist and validator script for compliance framework PRs +- **Docs (source of truth for contributors)**: + `docs/developer-guide/security-compliance-framework.mdx` (both schemas, + guardrails, validation, PR process), + `docs/user-guide/compliance/tutorials/compliance.mdx`, + `docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx` +- **Repo tooling** (`util/compliance/`): CSV→JSON generators + (`generate_json_from_csv/`), `ccc/from_yaml_to_json.py`, + `compliance_mapper/`, `threatscore/` +- **Skill assets** ([assets/](assets/)): + - `sync_framework.py` + `configs/ccc.yaml` + `parsers/finos_ccc.py` — + config-driven upstream sync (Workflow A) + - `build_inventory.py`, `query_checks.py`, `dump_section.py`, + `audit_framework_template.py` — audit tooling (Workflow B) + - Legacy JSON templates: `cis_framework.json`, `ens_framework.json`, + `iso27001_framework.json`, `mitre_attack_framework.json`, + `prowler_threatscore_framework.json`, `generic_framework.json` +- **References**: + [references/compliance-docs.md](references/compliance-docs.md) — model/loader + quick reference; + [references/check-mapping-reference.md](references/check-mapping-reference.md) + — curated requirement-text → checks mapping table + honest-MANUAL list +- **Sister skill**: + [prowler-compliance-review](../prowler-compliance-review/SKILL.md) — PR + review checklist + `validate_compliance.py` (legacy-schema validator) +- After editing this skill's frontmatter, run + `./skills/skill-sync/assets/sync.sh` to regenerate the AGENTS.md auto-invoke + tables. diff --git a/skills/prowler-compliance/references/check-mapping-reference.md b/skills/prowler-compliance/references/check-mapping-reference.md new file mode 100644 index 0000000000..cae9701ae7 --- /dev/null +++ b/skills/prowler-compliance/references/check-mapping-reference.md @@ -0,0 +1,78 @@ +# Audit Reference: Requirement Text → Prowler Checks + +Built from a real audit of 172 CCC ARs × 3 providers (April 2026). Use it to map +CCC-style / NIST-style / ISO-style requirement text to the checks that actually +verify them. Always re-validate every check id against the current inventory +(`assets/build_inventory.py` + `assets/query_checks.py`) before using a row — +checks get renamed and added over time. + +**Entries containing `*` are glob patterns, NOT literal check ids** (e.g. +`iam_*_no_administrative_privileges`, `cloudwatch_log_metric_filter_*`, +`*_minimum_tls_version_12`). Copied verbatim into a compliance JSON they map +nothing — expand each pattern to the concrete check ids via +`python skills/prowler-compliance/assets/query_checks.py ` +before writing any mapping. + +| Requirement text | AWS checks | Azure checks | GCP checks | +|---|---|---|---| +| **TLS in transit enforced** | `cloudfront_distributions_https_enabled`, `s3_bucket_secure_transport_policy`, `elbv2_ssl_listeners`, `elbv2_insecure_ssl_ciphers`, `elb_ssl_listeners`, `elb_insecure_ssl_ciphers`, `opensearch_service_domains_https_communications_enforced`, `rds_instance_transport_encrypted`, `redshift_cluster_in_transit_encryption_enabled`, `elasticache_redis_cluster_in_transit_encryption_enabled`, `dynamodb_accelerator_cluster_in_transit_encryption_enabled`, `dms_endpoint_ssl_enabled`, `kafka_cluster_in_transit_encryption_enabled`, `transfer_server_in_transit_encryption_enabled`, `glue_database_connections_ssl_enabled`, `sns_subscription_not_using_http_endpoints` | `storage_secure_transfer_required_is_enabled`, `storage_ensure_minimum_tls_version_12`, `postgresql_flexible_server_enforce_ssl_enabled`, `mysql_flexible_server_ssl_connection_enabled`, `mysql_flexible_server_minimum_tls_version_12`, `sqlserver_recommended_minimal_tls_version`, `app_minimum_tls_version_12`, `app_ensure_http_is_redirected_to_https`, `app_ftp_deployment_disabled` | `cloudsql_instance_ssl_connections` (almost only option) | +| **TLS 1.3 specifically** | Partial: `cloudfront_distributions_using_deprecated_ssl_protocols`, `elb*_insecure_ssl_ciphers`, `*_minimum_tls_version_12` | Partial: `*_minimum_tls_version_12` checks | None — accept as MANUAL | +| **SSH / port 22 hardening** | `ec2_instance_port_ssh_exposed_to_internet`, `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22`, `ec2_networkacl_allow_ingress_tcp_port_22` | `network_ssh_internet_access_restricted`, `vm_linux_enforce_ssh_authentication` | `compute_firewall_ssh_access_from_the_internet_allowed`, `compute_instance_block_project_wide_ssh_keys_disabled`, `compute_project_os_login_enabled`, `compute_project_os_login_2fa_enabled` | +| **mTLS (mutual TLS)** | `kafka_cluster_mutual_tls_authentication_enabled`, `apigateway_restapi_client_certificate_enabled` | `app_client_certificates_on` | None — MANUAL | +| **Data at rest encrypted** | `s3_bucket_default_encryption`, `s3_bucket_kms_encryption`, `ec2_ebs_default_encryption`, `ec2_ebs_volume_encryption`, `rds_instance_storage_encrypted`, `rds_cluster_storage_encrypted`, `rds_snapshots_encrypted`, `dynamodb_tables_kms_cmk_encryption_enabled`, `redshift_cluster_encrypted_at_rest`, `neptune_cluster_storage_encrypted`, `documentdb_cluster_storage_encrypted`, `opensearch_service_domains_encryption_at_rest_enabled`, `kinesis_stream_encrypted_at_rest`, `firehose_stream_encrypted_at_rest`, `sns_topics_kms_encryption_at_rest_enabled`, `sqs_queues_server_side_encryption_enabled`, `efs_encryption_at_rest_enabled`, `athena_workgroup_encryption`, `glue_data_catalogs_metadata_encryption_enabled`, `backup_vaults_encrypted`, `backup_recovery_point_encrypted`, `cloudtrail_kms_encryption_enabled`, `cloudwatch_log_group_kms_encryption_enabled`, `eks_cluster_kms_cmk_encryption_in_secrets_enabled`, `sagemaker_notebook_instance_encryption_enabled`, `apigateway_restapi_cache_encrypted`, `kafka_cluster_encryption_at_rest_uses_cmk`, `dynamodb_accelerator_cluster_encryption_enabled`, `storagegateway_fileshare_encryption_enabled` | `storage_infrastructure_encryption_is_enabled`, `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encryption_enabled`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled`, `monitor_storage_account_with_activity_logs_cmk_encrypted` | `compute_instance_encryption_with_csek_enabled`, `dataproc_encrypted_with_cmks_disabled`, `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption` | +| **CMEK required (customer-managed keys)** | `kms_cmk_are_used` | `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled` | `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption`, `dataproc_encrypted_with_cmks_disabled`, `compute_instance_encryption_with_csek_enabled` | +| **Key rotation enabled** | `kms_cmk_rotation_enabled` | `keyvault_key_rotation_enabled`, `storage_key_rotation_90_days` | `kms_key_rotation_enabled` | +| **MFA for UI access** | `iam_root_mfa_enabled`, `iam_root_hardware_mfa_enabled`, `iam_user_mfa_enabled_console_access`, `iam_user_hardware_mfa_enabled`, `iam_administrator_access_with_mfa`, `cognito_user_pool_mfa_enabled` | `entra_privileged_user_has_mfa`, `entra_non_privileged_user_has_mfa`, `entra_user_with_vm_access_has_mfa`, `entra_security_defaults_enabled` | `compute_project_os_login_2fa_enabled` | +| **API access / credentials** | `iam_no_root_access_key`, `iam_user_no_setup_initial_access_key`, `apigateway_restapi_authorizers_enabled`, `apigateway_restapi_public_with_authorizer`, `apigatewayv2_api_authorizers_enabled` | `entra_conditional_access_policy_require_mfa_for_management_api`, `app_function_access_keys_configured`, `app_function_identity_is_configured` | `apikeys_api_restrictions_configured`, `apikeys_key_exists`, `apikeys_key_rotated_in_90_days` | +| **Log all admin/config changes** | `cloudtrail_multi_region_enabled`, `cloudtrail_multi_region_enabled_logging_management_events`, `cloudtrail_cloudwatch_logging_enabled`, `cloudtrail_log_file_validation_enabled`, `cloudwatch_log_metric_filter_*`, `cloudwatch_changes_to_*_alarm_configured`, `config_recorder_all_regions_enabled` | `monitor_diagnostic_settings_exists`, `monitor_diagnostic_setting_with_appropriate_categories`, `monitor_alert_*` | `iam_audit_logs_enabled`, `logging_log_metric_filter_and_alert_for_*`, `logging_sink_created` | +| **Log integrity (digital signatures)** | `cloudtrail_log_file_validation_enabled` (exact) | None | None | +| **Public access denied** | `s3_bucket_public_access`, `s3_bucket_public_list_acl`, `s3_bucket_public_write_acl`, `s3_account_level_public_access_blocks`, `apigateway_restapi_public`, `awslambda_function_url_public`, `awslambda_function_not_publicly_accessible`, `rds_instance_no_public_access`, `rds_snapshots_public_access`, `ec2_securitygroup_allow_ingress_from_internet_to_all_ports`, `sns_topics_not_publicly_accessible`, `sqs_queues_not_publicly_accessible` | `storage_blob_public_access_level_is_disabled`, `storage_ensure_private_endpoints_in_storage_accounts`, `containerregistry_not_publicly_accessible`, `keyvault_private_endpoints`, `app_function_not_publicly_accessible`, `aks_clusters_public_access_disabled`, `network_http_internet_access_restricted` | `cloudstorage_bucket_public_access`, `compute_instance_public_ip`, `cloudsql_instance_public_ip`, `compute_firewall_*_access_from_the_internet_allowed` | +| **IAM least privilege** | `iam_*_no_administrative_privileges`, `iam_policy_allows_privilege_escalation`, `iam_inline_policy_allows_privilege_escalation`, `iam_role_administratoraccess_policy`, `iam_group_administrator_access_policy`, `iam_user_administrator_access_policy`, `iam_policy_attached_only_to_group_or_roles`, `iam_role_cross_service_confused_deputy_prevention` | `iam_role_user_access_admin_restricted`, `iam_subscription_roles_owner_custom_not_created`, `iam_custom_role_has_permissions_to_administer_resource_locks` | `iam_sa_no_administrative_privileges`, `iam_no_service_roles_at_project_level`, `iam_role_kms_enforce_separation_of_duties`, `iam_role_sa_enforce_separation_of_duties` | +| **Password policy** | `iam_password_policy_minimum_length_14`, `iam_password_policy_uppercase`, `iam_password_policy_lowercase`, `iam_password_policy_symbol`, `iam_password_policy_number`, `iam_password_policy_expires_passwords_within_90_days_or_less`, `iam_password_policy_reuse_24` | None | None | +| **Credential rotation / unused** | `iam_rotate_access_key_90_days`, `iam_user_accesskey_unused`, `iam_user_console_access_unused` | None | `iam_sa_user_managed_key_rotate_90_days`, `iam_sa_user_managed_key_unused`, `iam_service_account_unused` | +| **VPC / flow logs** | `vpc_flow_logs_enabled` | `network_flow_log_captured_sent`, `network_watcher_enabled`, `network_flow_log_more_than_90_days` | `compute_subnet_flow_logs_enabled` | +| **Backup / DR / Multi-AZ** | `backup_vaults_exist`, `backup_plans_exist`, `backup_reportplans_exist`, `rds_instance_backup_enabled`, `rds_*_protected_by_backup_plan`, `rds_cluster_multi_az`, `neptune_cluster_backup_enabled`, `documentdb_cluster_backup_enabled`, `efs_have_backup_enabled`, `s3_bucket_cross_region_replication`, `dynamodb_table_protected_by_backup_plan` | `vm_backup_enabled`, `vm_sufficient_daily_backup_retention_period`, `storage_geo_redundant_enabled` | `cloudsql_instance_automated_backups`, `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_sufficient_retention_period` | +| **Access analysis / discovery** | `accessanalyzer_enabled`, `accessanalyzer_enabled_without_findings` | None specific | `iam_account_access_approval_enabled`, `iam_cloud_asset_inventory_enabled` | +| **Object lock / retention** | `s3_bucket_object_lock`, `s3_bucket_object_versioning`, `s3_bucket_lifecycle_enabled`, `cloudtrail_bucket_requires_mfa_delete`, `s3_bucket_no_mfa_delete` | `storage_ensure_soft_delete_is_enabled`, `storage_blob_versioning_is_enabled`, `storage_ensure_file_shares_soft_delete_is_enabled` | `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_soft_delete_enabled`, `cloudstorage_bucket_versioning_enabled`, `cloudstorage_bucket_sufficient_retention_period` | +| **Uniform bucket-level access** | `s3_bucket_acl_prohibited` | `storage_account_key_access_disabled`, `storage_default_to_entra_authorization_enabled` | `cloudstorage_bucket_uniform_bucket_level_access` | +| **Container vulnerability scanning** | `ecr_registry_scan_images_on_push_enabled`, `ecr_repositories_scan_vulnerabilities_in_latest_image` | `defender_container_images_scan_enabled`, `defender_container_images_resolved_vulnerabilities` | `artifacts_container_analysis_enabled`, `gcr_container_scanning_enabled` | +| **WAF / rate limiting** | `wafv2_webacl_with_rules`, `waf_*_webacl_with_rules`, `wafv2_webacl_logging_enabled`, `waf_global_webacl_logging_enabled` | None | None | +| **Deployment region restriction** | `organizations_scp_check_deny_regions` | None | None | +| **Secrets automatic rotation** | `secretsmanager_automatic_rotation_enabled`, `secretsmanager_secret_rotated_periodically` | `keyvault_rbac_secret_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None | +| **Certificate management** | `acm_certificates_expiration_check`, `acm_certificates_with_secure_key_algorithms`, `acm_certificates_transparency_logs_enabled` | `keyvault_key_expiration_set_in_non_rbac`, `keyvault_rbac_key_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None | +| **GenAI guardrails / input/output filtering** | `bedrock_guardrail_prompt_attack_filter_enabled`, `bedrock_guardrail_sensitive_information_filter_enabled`, `bedrock_agent_guardrail_enabled`, `bedrock_model_invocation_logging_enabled`, `bedrock_api_key_no_administrative_privileges`, `bedrock_api_key_no_long_term_credentials` | None | None | +| **ML dev environment security** | `sagemaker_notebook_instance_root_access_disabled`, `sagemaker_notebook_instance_without_direct_internet_access_configured`, `sagemaker_notebook_instance_vpc_settings_configured`, `sagemaker_models_vpc_settings_configured`, `sagemaker_training_jobs_vpc_settings_configured`, `sagemaker_training_jobs_network_isolation_enabled`, `sagemaker_training_jobs_volume_and_output_encryption_enabled` | None | None | +| **Threat detection / anomalous behavior** | `cloudtrail_threat_detection_enumeration`, `cloudtrail_threat_detection_privilege_escalation`, `cloudtrail_threat_detection_llm_jacking`, `guardduty_is_enabled`, `guardduty_no_high_severity_findings` | None | None | +| **Serverless private access** | `awslambda_function_inside_vpc`, `awslambda_function_not_publicly_accessible`, `awslambda_function_url_public` | `app_function_not_publicly_accessible` | None | + +## What Prowler Does NOT Cover (accept MANUAL honestly) + +Don't pad mappings for these — mark the requirement's checks empty and move on: + +- **TLS 1.3 version specifically** — Prowler verifies TLS is enforced, not always the exact version +- **IANA port-protocol consistency** — no check for "protocol running on its assigned port" +- **mTLS on most Azure/GCP services** — limited to App Service client certs on Azure, nothing on GCP +- **Rate limiting** on monitoring endpoints, load balancers, serverless invocations, vector ingestion +- **Session cookie expiry** (LB stickiness) +- **HTTP header scrubbing** (Server, X-Powered-By) +- **Certificate transparency verification for imports** +- **Model version pinning, red teaming, AI quality review** +- **Vector embedding validation, dimensional constraints, ANN vs exact search** +- **Secret region replication** (cross-region residency) +- **Lifecycle cleanup policies on container registries** +- **Row-level / column-level security in data warehouses** +- **Deployment region restriction on Azure/GCP** (AWS has `organizations_scp_check_deny_regions`, others don't) +- **Cross-tenant alert silencing permissions** +- **Field-level masking in logs** +- **Managed view enforcement for database access** +- **Automatic MFA delete on all S3 buckets** (only CloudTrail bucket variant exists for some frameworks — AWS has the generic `s3_bucket_no_mfa_delete` though) + +## Provider coverage asymmetry + +AWS has dense coverage (in-transit encryption, IAM, database encryption, backup, +GenAI). Azure and GCP are thinner, especially for in-transit encryption, mTLS, +and ML/AI. Accept the asymmetry in mappings — don't force GCP parity where +Prowler genuinely can't verify. Newer providers (alibabacloud, oraclecloud, +googleworkspace, okta, cloudflare, linode...) have far smaller inventories: +always rebuild the inventory with `assets/build_inventory.py` before assuming +a mapping exists. diff --git a/skills/prowler-compliance/references/compliance-docs.md b/skills/prowler-compliance/references/compliance-docs.md index a8d11484a9..272aa10ef1 100644 --- a/skills/prowler-compliance/references/compliance-docs.md +++ b/skills/prowler-compliance/references/compliance-docs.md @@ -1,137 +1,154 @@ -# Compliance Framework Documentation +# Compliance Framework Quick Reference ## Code References -Key files for understanding and modifying compliance frameworks: - | File | Purpose | |------|---------| -| `prowler/lib/check/compliance_models.py` | Pydantic models defining attribute structures for each framework type | -| `prowler/lib/check/compliance.py` | Core compliance processing logic | -| `prowler/lib/check/utils.py` | Utility functions including `list_compliance_modules()` | -| `prowler/lib/outputs/compliance/` | Framework-specific output generators | -| `prowler/compliance/{provider}/` | JSON compliance framework definitions | +| `prowler/lib/check/compliance_models.py` | Legacy + universal Pydantic (v1) model trees, config-constraint model, loaders, legacy→universal adapter | +| `prowler/lib/check/compliance.py` | `update_checks_metadata_with_compliance()` (only) | +| `prowler/lib/check/compliance_config_eval.py` | Shared `ConfigRequirements` guardrail evaluation (SDK outputs + API) | +| `prowler/lib/outputs/compliance/compliance_check.py` | `get_check_compliance()` — per-finding `{Framework}-{Version}` → requirement ids | +| `prowler/lib/check/utils.py` | `list_compliance_modules()` | +| `prowler/lib/outputs/compliance/` | Output formatters (legacy per-framework + `universal/`) | +| `prowler/compliance/*.json` | Universal multi-provider framework definitions | +| `prowler/compliance/{provider}/` | Legacy per-provider framework definitions | -## Attribute Model Classes +## Attribute Model Classes (legacy schema) -Each framework type has a specific Pydantic model in `compliance_models.py`: +Registered in the `Compliance_Requirement.Attributes` Union, in this order +(order is load-bearing; Generic must stay last): -| Framework | Model Class | +| Framework family | Model Class | |-----------|-------------| +| ASD Essential Eight | `ASDEssentialEight_Requirement_Attribute` | | CIS | `CIS_Requirement_Attribute` | -| ISO 27001 | `ISO27001_2013_Requirement_Attribute` | | ENS | `ENS_Requirement_Attribute` | -| MITRE ATT&CK | `Mitre_Requirement` (uses different structure) | +| ISO 27001 | `ISO27001_2013_Requirement_Attribute` | | AWS Well-Architected | `AWS_Well_Architected_Requirement_Attribute` | | KISA ISMS-P | `KISA_ISMSP_Requirement_Attribute` | | Prowler ThreatScore | `Prowler_ThreatScore_Requirement_Attribute` | | CCC | `CCC_Requirement_Attribute` | | C5 Germany | `C5Germany_Requirement_Attribute` | -| Generic/Fallback | `Generic_Compliance_Requirement_Attribute` | +| CSA CCM (legacy shape) | `CSA_CCM_Requirement_Attribute` | +| DISA STIG (Okta IDaaS) | `STIG_Requirement_Attribute` | +| Generic/Fallback (NIST, PCI, GDPR, HIPAA, SOC2, FedRAMP, ...) | `Generic_Compliance_Requirement_Attribute` | -## How Compliance Frameworks are Loaded +MITRE ATT&CK uses the separate `Mitre_Requirement` model with per-provider +`Mitre_Requirement_Attribute_{AWS,Azure,GCP}` attribute classes. -1. `Compliance.get_bulk(provider)` is called at startup -2. Scans `prowler/compliance/{provider}/` for `.json` files -3. Each file is parsed using `load_compliance_framework()` -4. Pydantic validates against `Compliance` model -5. Framework is stored in dictionary with filename (without `.json`) as key +`Compliance_Requirement_ConfigConstraint` models each `ConfigRequirements` / +`config_requirements` entry (`Check`, `ConfigKey`, `Operator`, `Value`, +optional `Provider`) with load-time operator/value type validation. + +## Universal Schema Models + +| Model | Purpose | +|-------|---------| +| `ComplianceFramework` | Top-level container (`framework`, `name`, `version`, `requirements`, `attributes_metadata`, `outputs`); validates attributes against metadata at load | +| `UniversalComplianceRequirement` | Flat `attributes: dict`, `checks: dict[provider, list]`, `config_requirements`, MITRE extras | +| `AttributeMetadata` | Per-attribute schema descriptor (key/label/type/enum/required/`enum_display`/`enum_order`/`output_formats`) | +| `OutputsConfig` → `TableConfig` | CLI table rendering (`group_by`, `split_by`, `scoring`, `labels`) — consumed by `universal_table.py` | +| `OutputsConfig` → `PDFConfig` (+ `ChartConfig`, `ScoringFormula`, `I18nLabels`, ...) | Declarative PDF config — modeled but **not yet consumed** by the API PDF pipeline (it uses its own `FRAMEWORK_REGISTRY`) | + +## How Frameworks Are Loaded + +Two entry points — they see different files: + +1. **Legacy**: `Compliance.get_bulk(provider)` scans only + `prowler/compliance/{provider}/` (exact provider-segment match) plus + external JSONs from the `prowler.compliance` entry-point group. Invalid + built-in file → `logger.critical` + `sys.exit(1)` + (`load_compliance_framework`, `fatal=True`). +2. **Universal**: `get_bulk_compliance_frameworks_universal(provider)` scans + the top-level `prowler/compliance/` **and** every provider subdirectory, + plus the `prowler.compliance.universal` entry-point group (built-ins win + collisions). Legacy files are adapted via `adapt_legacy_to_universal()` + (flattens `Attributes[0]` into a dict, wraps `Checks` as + `{provider: [...]}`, infers `attributes_metadata` from the matched Pydantic + class). Invalid file → logged and **skipped** + (`load_compliance_framework_universal` returns `None`). + +The framework key in both bulk dicts is the JSON basename without `.json` — +that's also the `--compliance` CLI key. ## How Checks Map to Compliance -1. After loading, `update_checks_metadata_with_compliance()` is called -2. For each check, it finds all compliance requirements that reference it -3. Compliance info is attached to `CheckMetadata.Compliance` list -4. During output, `get_check_compliance()` retrieves mappings per finding +1. `update_checks_metadata_with_compliance()` attaches, per check, every + framework requirement that references it (`CheckMetadata.Compliance`). +2. During output, `get_check_compliance()` + (`prowler/lib/outputs/compliance/compliance_check.py`) returns the + per-finding dict `{"{Framework}-{Version}": [requirement_ids]}` — the + `-{Version}` suffix only exists when `Version` is non-empty. +3. `ConfigRequirements` guardrails are evaluated by + `evaluate_config_constraints()` (`compliance_config_eval.py`); a violated + constraint forces FAIL and prepends + `Configuration not valid for this requirement.` to `status_extended` in + every output format. -## File Naming Convention +## File Naming Conventions ```text -{framework}_{version}_{provider}.json +prowler/compliance/{framework}_{version}.json # universal +prowler/compliance/{provider}/{framework}_{version}_{provider}.json # legacy ``` -Examples: -- `cis_5.0_aws.json` -- `iso27001_2022_azure.json` -- `mitre_attack_gcp.json` -- `ens_rd2022_aws.json` -- `nist_800_53_revision_5_aws.json` +Examples: `dora_2022_2554.json`, `cis_controls_8.1.json`, `cis_7.0_aws.json`, +`iso27001_2022_azure.json`, `okta_idaas_stig_v1r2_okta.json`, +`cisa_scuba_0.6_googleworkspace.json`, `ccc_aws.json` (unversioned only when +the framework has no versioning). For legacy files the version substring in +the filename must equal `Version`. -## Validation +## Validation Summary -Prowler validates compliance JSON at startup. Invalid files cause: -- `ValidationError` logged with details -- Application exit with error code +- **Load time (universal)**: `attributes_metadata` root validator — required + keys, unknown-key drift guard, enums, int/float/bool types. Omit the + metadata and nothing is validated. +- **Load time (legacy)**: Pydantic attribute-class matching; a shape matching + no specific class silently falls through to Generic. +- **Never validated at load**: check-id existence. Cross-check manually + (see SKILL.md → Validation). +- **Test suite**: `tests/lib/check/universal_compliance_models_test.py::test_loads_as_universal` + is parametrized over every shipped JSON (top-level + per-provider). +- **CI**: `.github/workflows/pr-check-compliance-mapping.yml` flags new checks + not mapped in any framework (`needs-compliance-review` label; opt out with + `no-compliance-check`). +- **Pre-commit**: `check-json` + `pretty-format-json` only (syntax/format, no + semantics). +- **Manual**: `skills/prowler-compliance-review/assets/validate_compliance.py` + (legacy schema only). -Common validation errors: -- Missing required fields (`Id`, `Description`, `Checks`, `Attributes`) -- Invalid enum values (e.g., `Profile` must be "Level 1" or "Level 2" for CIS) -- Type mismatches (e.g., `Checks` must be array of strings) +## Repo Tooling (`util/compliance/`) -## Adding a New Framework - -1. Create JSON file in `prowler/compliance/{provider}/` -2. Use appropriate attribute model (see table above) -3. Map existing checks to requirements via `Checks` array -4. Use empty `Checks: []` for manual-only requirements -5. Test with `prowler {provider} --list-compliance` to verify loading -6. Run `prowler {provider} --compliance {framework_name}` to test execution - -## Templates - -See `assets/` directory for example templates: -- `cis_framework.json` - CIS Benchmark template -- `iso27001_framework.json` - ISO 27001 template -- `ens_framework.json` - ENS (Spain) template -- `mitre_attack_framework.json` - MITRE ATT&CK template -- `prowler_threatscore_framework.json` - Prowler ThreatScore template -- `generic_framework.json` - Generic/custom framework template +| Tool | Purpose | +|------|---------| +| `util/compliance/generate_json_from_csv/*.py` | CSV→JSON generators (CIS 1.5, CIS 2.0 GCP, CIS 1.0 GitHub, CIS 4.0 M365, ENS, ThreatScore) | +| `util/compliance/ccc/from_yaml_to_json.py` | FINOS CCC YAML→JSON converter | +| `util/compliance/compliance_mapper/` | Compliance mapper (see its README) | +| `util/compliance/threatscore/get_prowler_threatscore_from_generic_output.py` | Derive ThreatScore from generic output | ## Prowler ThreatScore Details -Prowler ThreatScore is a custom security scoring framework that calculates an overall security posture score based on: +Custom Prowler scoring framework. Pillars / ID prefixes: `1.x.x` IAM, `2.x.x` +Attack Surface, `3.x.x` Logging and Monitoring, `4.x.x` Encryption. -### Four Pillars -1. **IAM (Identity and Access Management)** - - SubSections: Authentication, Authorization, Credentials Management - -2. **Attack Surface** - - SubSections: Network Exposure, Storage Exposure, Service Exposure - -3. **Logging and Monitoring** - - SubSections: Audit Logging, Threat Detection, Alerting - -4. **Encryption** - - SubSections: Data at Rest, Data in Transit - -### Scoring Algorithm -The ThreatScore uses `LevelOfRisk` and `Weight` to calculate severity: - -| LevelOfRisk | Weight | Example Controls | -|-------------|--------|------------------| -| 5 (Critical) | 1000 | Root MFA, No root access keys, Public S3 buckets | -| 4 (High) | 100 | User MFA, Public EC2, GuardDuty enabled | -| 3 (Medium) | 10 | Password policies, EBS encryption, CloudTrail | -| 2 (Low) | 1-10 | Best practice recommendations | -| 1 (Info) | 1 | Informational controls | - -### ID Numbering Convention -- `1.x.x` - IAM controls -- `2.x.x` - Attack Surface controls -- `3.x.x` - Logging and Monitoring controls -- `4.x.x` - Encryption controls +Scoring: `LevelOfRisk` 1–5 (5=critical) × `Weight` (values in the shipped +catalogs: 1000 critical / 100 high / 8–10 standard / 1 low). Available for +aws, azure, gcp, kubernetes, m365, alibabacloud. ## External Resources -### Official Framework Documentation - [CIS Benchmarks](https://www.cisecurity.org/cis-benchmarks) -- [ISO 27001:2022](https://www.iso.org/standard/27001) +- [CIS Critical Security Controls](https://www.cisecurity.org/controls) +- [ISO 27001](https://www.iso.org/standard/27001) - [NIST 800-53](https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final) - [NIST CSF](https://www.nist.gov/cyberframework) - [PCI DSS](https://www.pcisecuritystandards.org/) - [MITRE ATT&CK](https://attack.mitre.org/) - [ENS (Spain)](https://www.ccn-cert.cni.es/es/ens.html) - -### Prowler Documentation -- [Prowler Docs - Compliance](https://docs.prowler.com/projects/prowler-open-source/en/latest/) -- [Prowler GitHub](https://github.com/prowler-cloud/prowler) +- [FINOS CCC](https://github.com/finos/common-cloud-controls) +- [CSA CCM](https://cloudsecurityalliance.org/research/cloud-controls-matrix) +- [DORA (EU 2022/2554)](https://eur-lex.europa.eu/eli/reg/2022/2554/oj) +- [ASD Essential Eight](https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/essential-eight) +- [CISA SCuBA](https://www.cisa.gov/resources-tools/services/secure-cloud-business-applications-scuba-project) +- [DISA STIGs](https://public.cyber.mil/stigs/) +- [Prowler Docs — Compliance developer guide](https://docs.prowler.com/developer-guide/security-compliance-framework) diff --git a/tests/config/schema/exclusions_test.py b/tests/config/schema/exclusions_test.py new file mode 100644 index 0000000000..a13ed2f786 --- /dev/null +++ b/tests/config/schema/exclusions_test.py @@ -0,0 +1,113 @@ +"""Coverage for the ``excluded_checks`` / ``excluded_services`` fields +added to :class:`prowler.config.schema.base.ProviderConfigBase`. + +Because the fields live on the base class, every registered provider +schema exposes them and every provider must therefore share the same +whitespace / uniqueness / non-empty guarantees. These tests lock in that +contract at the base level and at the JSON-Schema level (which the UI +editor consumes via ``ajv``). +""" + +import pytest +from pydantic import ValidationError + +from prowler.config.scan_config_schema import SCAN_CONFIG_SCHEMA +from prowler.config.schema.aws import AWSProviderConfig +from prowler.config.schema.registry import SCHEMAS +from prowler.config.schema.validator import validate_provider_config + +EXCLUSION_FIELDS = ("excluded_checks", "excluded_services") + + +class Test_JSON_Schema_Exposes_Exclusion_Fields: + @pytest.mark.parametrize("provider", sorted(SCHEMAS)) + @pytest.mark.parametrize("field", EXCLUSION_FIELDS) + def test_field_shape(self, provider, field): + field_schema = SCAN_CONFIG_SCHEMA["properties"][provider]["properties"][field] + assert field_schema["type"] == "array" + assert field_schema["items"] == {"type": "string", "minLength": 1} + assert field_schema["uniqueItems"] is True + assert field_schema["default"] == [] + + +class Test_Exclusion_Field_Validation: + def _model(self, **kwargs): + return AWSProviderConfig.model_validate(kwargs) + + @pytest.mark.parametrize("field", EXCLUSION_FIELDS) + def test_empty_string_is_rejected(self, field): + with pytest.raises(ValidationError): + self._model(**{field: [""]}) + + @pytest.mark.parametrize("field", EXCLUSION_FIELDS) + def test_whitespace_only_string_is_rejected(self, field): + with pytest.raises(ValidationError): + self._model(**{field: [" "]}) + + @pytest.mark.parametrize("field", EXCLUSION_FIELDS) + def test_raw_duplicates_are_rejected(self, field): + with pytest.raises(ValidationError): + self._model(**{field: ["s3", "s3"]}) + + @pytest.mark.parametrize("field", EXCLUSION_FIELDS) + def test_normalized_duplicates_are_rejected(self, field): + # After whitespace normalization ``" s3 "`` collapses to ``"s3"`` + # and must be caught by the duplicate check. + with pytest.raises(ValidationError): + self._model(**{field: ["s3", " s3 "]}) + + @pytest.mark.parametrize("field", EXCLUSION_FIELDS) + def test_whitespace_is_stripped(self, field): + model = self._model(**{field: [" identifier "]}) + assert getattr(model, field) == ["identifier"] + + @pytest.mark.parametrize("field", EXCLUSION_FIELDS) + def test_non_string_item_is_rejected(self, field): + with pytest.raises(ValidationError): + self._model(**{field: [123]}) + + +class Test_Exclusion_Defaults_Are_Not_Injected: + """The strict normalization path uses ``model_dump(exclude_unset=True)`` + so pre-existing configs that never set an exclusion field must round-trip + without the default empty list being materialized.""" + + def test_absent_fields_are_not_injected_by_validator(self): + # The lenient SDK runtime path also uses ``exclude_unset=True`` under + # the hood; asserting on the validator output guards the promise + # against future refactors. + assert validate_provider_config("aws", {}, SCHEMAS["aws"]) == {} + + def test_absent_fields_are_not_injected_when_other_keys_are_present(self): + assert validate_provider_config( + "aws", + {"max_ec2_instance_age_in_days": 180}, + SCHEMAS["aws"], + ) == {"max_ec2_instance_age_in_days": 180} + + def test_explicit_empty_list_round_trips(self): + # Explicitly setting ``excluded_checks: []`` is different from + # omitting it — the empty list is user-provided and must be + # preserved by the strict-normalization contract. + assert validate_provider_config( + "aws", + {"excluded_checks": []}, + SCHEMAS["aws"], + ) == {"excluded_checks": []} + + +class Test_Extra_Fields_Are_Preserved: + """``extra="allow"`` must keep plugin-provided keys around so the + ecosystem contract in ``validator_test.py`` still holds after adding + the exclusion fields.""" + + def test_unknown_keys_are_preserved_alongside_exclusions(self): + out = validate_provider_config( + "aws", + {"excluded_checks": ["s3_bucket_public_access"], "plugin_option": "kept"}, + SCHEMAS["aws"], + ) + assert out == { + "excluded_checks": ["s3_bucket_public_access"], + "plugin_option": "kept", + } diff --git a/tests/config/schema/scan_config_schema_test.py b/tests/config/schema/scan_config_schema_test.py new file mode 100644 index 0000000000..037fcd9501 --- /dev/null +++ b/tests/config/schema/scan_config_schema_test.py @@ -0,0 +1,393 @@ +"""Coverage for the strict scan-config validation and normalization +contract exposed to the Prowler App backend. + +Split from :mod:`tests.config.schema.validator_test` because the strict +API (``validate_and_normalize_scan_config``) has different guarantees: +it never silently drops keys, and it returns a JSON-serializable payload +the backend can persist verbatim in a Django ``JSONField``. +""" + +import json +from unittest.mock import call, patch + +import pytest + +from prowler.config.scan_config_schema import ( + _get_provider_check_ids, + _get_provider_services, + validate_and_normalize_scan_config, + validate_scan_config, +) + + +@pytest.fixture(autouse=True) +def clear_provider_catalog_caches(): + """Keep provider catalog cache state isolated between tests.""" + _get_provider_check_ids.cache_clear() + _get_provider_services.cache_clear() + yield + _get_provider_check_ids.cache_clear() + _get_provider_services.cache_clear() + + +class Test_Non_Dict_Root: + @pytest.mark.parametrize("payload", [None, "string", 42, [], (1, 2)]) + def test_non_mapping_root_is_rejected(self, payload): + normalized, errors = validate_and_normalize_scan_config(payload) + assert normalized == {} + assert len(errors) == 1 + assert errors[0]["path"] == "" + + +class Test_Registered_Provider_Section_Must_Be_Mapping: + @pytest.mark.parametrize("section", ["a-string", 42, ["s3"], None]) + def test_non_mapping_section_reports_provider_path(self, section): + normalized, errors = validate_and_normalize_scan_config({"aws": section}) + assert normalized == {} + assert errors == [{"path": "aws", "message": "section must be a mapping."}] + + +class Test_Success_Path: + def test_whitespace_is_normalized_in_exclusions(self): + normalized, errors = validate_and_normalize_scan_config( + { + "aws": { + "excluded_checks": [" s3_bucket_default_encryption "], + "excluded_services": [" s3 "], + } + } + ) + assert errors == [] + assert normalized == { + "aws": { + "excluded_checks": ["s3_bucket_default_encryption"], + "excluded_services": ["s3"], + } + } + + def test_plugin_options_are_preserved(self): + # Third-party plugins inject arbitrary keys inside a provider + # section; ``extra="allow"`` on the schema keeps them alive + # through the dump/normalize round-trip. + normalized, errors = validate_and_normalize_scan_config( + {"aws": {"plugin_option": "preserved", "another": 42}} + ) + assert errors == [] + assert normalized == {"aws": {"plugin_option": "preserved", "another": 42}} + + def test_plugin_catalog_identifiers_are_accepted_and_catalogs_are_cached(self): + payload = { + "aws": { + "excluded_checks": ["plugin_check"], + "excluded_services": ["plugin_service"], + } + } + with ( + patch( + "prowler.config.scan_config_schema.CheckMetadata.get_bulk", + return_value={"plugin_check": object()}, + ) as check_catalog, + patch( + "prowler.config.scan_config_schema.list_services", + return_value=["plugin_service"], + ) as service_catalog, + ): + first_result = validate_and_normalize_scan_config(payload) + second_result = validate_and_normalize_scan_config(payload) + + normalized, errors = first_result + assert errors == [] + assert normalized == { + "aws": { + "excluded_checks": ["plugin_check"], + "excluded_services": ["plugin_service"], + } + } + assert second_result == first_result + check_catalog.assert_called_once_with("aws") + service_catalog.assert_called_once_with("aws") + + def test_catalog_caches_are_keyed_by_provider(self): + with ( + patch( + "prowler.config.scan_config_schema.CheckMetadata.get_bulk", + side_effect=lambda provider: {f"{provider}_plugin_check": object()}, + ) as check_catalog, + patch( + "prowler.config.scan_config_schema.list_services", + side_effect=lambda provider: [f"{provider}_plugin_service"], + ) as service_catalog, + ): + payload = { + "aws": { + "excluded_checks": ["aws_plugin_check"], + "excluded_services": ["aws_plugin_service"], + }, + "azure": { + "excluded_checks": ["azure_plugin_check"], + "excluded_services": ["azure_plugin_service"], + }, + } + first_result = validate_and_normalize_scan_config(payload) + second_result = validate_and_normalize_scan_config(payload) + + assert first_result[1] == [] + assert second_result == first_result + assert check_catalog.call_args_list == [call("aws"), call("azure")] + assert service_catalog.call_args_list == [call("aws"), call("azure")] + + def test_omitted_defaults_are_not_injected(self): + normalized, errors = validate_and_normalize_scan_config( + {"aws": {"max_ec2_instance_age_in_days": 90}} + ) + assert errors == [] + assert normalized == {"aws": {"max_ec2_instance_age_in_days": 90}} + assert "excluded_checks" not in normalized["aws"] + assert "excluded_services" not in normalized["aws"] + + def test_unknown_provider_sections_are_preserved_verbatim(self): + payload = {"future_provider": {"custom_option": True, "nested": {"k": 1}}} + normalized, errors = validate_and_normalize_scan_config(payload) + assert errors == [] + assert normalized == payload + + def test_normalized_payload_is_json_serializable(self): + normalized, _ = validate_and_normalize_scan_config( + { + "aws": { + "excluded_checks": ["s3_bucket_public_access"], + "excluded_services": ["s3"], + } + } + ) + # If ``model_dump(mode="json", ...)`` is ever dropped this + # ``json.dumps`` call is what will notice. + json.dumps(normalized) + + def test_input_payload_is_not_mutated(self): + payload = { + "aws": { + "excluded_checks": [" s3_bucket_public_access "], + "excluded_services": [" s3 "], + } + } + snapshot = json.loads(json.dumps(payload)) + validate_and_normalize_scan_config(payload) + assert payload == snapshot + + +class Test_Error_Path: + def test_unknown_excluded_check_is_rejected(self): + normalized, errors = validate_and_normalize_scan_config( + {"aws": {"excluded_checks": ["aws_check_that_does_not_exist"]}} + ) + assert normalized == {} + assert errors == [ + { + "path": "aws.excluded_checks[0]", + "message": ( + "Unknown check 'aws_check_that_does_not_exist' for provider " + "'aws'." + ), + } + ] + + def test_unknown_excluded_service_is_rejected(self): + normalized, errors = validate_and_normalize_scan_config( + {"aws": {"excluded_services": ["not_a_real_aws_service"]}} + ) + assert normalized == {} + assert errors == [ + { + "path": "aws.excluded_services[0]", + "message": ( + "Unknown service 'not_a_real_aws_service' for provider 'aws'." + ), + } + ] + + def test_multiple_unknown_exclusions_return_deterministic_errors(self): + normalized, errors = validate_and_normalize_scan_config( + { + "aws": { + "excluded_checks": [ + "unknown_check_one", + "s3_bucket_default_encryption", + "unknown_check_two", + ], + "excluded_services": [ + "unknown_service_one", + "s3", + "unknown_service_two", + ], + } + } + ) + assert normalized == {} + assert errors == [ + { + "path": "aws.excluded_checks[0]", + "message": "Unknown check 'unknown_check_one' for provider 'aws'.", + }, + { + "path": "aws.excluded_checks[2]", + "message": "Unknown check 'unknown_check_two' for provider 'aws'.", + }, + { + "path": "aws.excluded_services[0]", + "message": ( + "Unknown service 'unknown_service_one' for provider 'aws'." + ), + }, + { + "path": "aws.excluded_services[2]", + "message": ( + "Unknown service 'unknown_service_two' for provider 'aws'." + ), + }, + ] + + def test_check_from_another_provider_is_rejected(self): + azure_check = "postgresql_flexible_server_allow_access_services_disabled" + normalized, errors = validate_and_normalize_scan_config( + {"aws": {"excluded_checks": [azure_check]}} + ) + assert normalized == {} + assert errors == [ + { + "path": "aws.excluded_checks[0]", + "message": f"Unknown check '{azure_check}' for provider 'aws'.", + } + ] + + def test_invalid_input_returns_empty_normalized_and_errors(self): + normalized, errors = validate_and_normalize_scan_config( + {"aws": {"excluded_services": ["s3", " s3 "]}} + ) + assert normalized == {} + assert errors + assert any(err["path"].startswith("aws.excluded_services") for err in errors) + + def test_partial_error_zeros_the_normalized_payload(self): + # One valid provider + one invalid provider must not leak the + # valid section into a partially normalized result. + normalized, errors = validate_and_normalize_scan_config( + { + "aws": {"excluded_services": ["s3", "s3"]}, + "azure": {"vm_backup_min_daily_retention_days": 7}, + } + ) + assert normalized == {} + assert errors + assert any(err["path"].startswith("aws.") for err in errors) + + def test_value_error_prefix_is_stripped_from_user_facing_messages(self): + # Pydantic prefixes messages emitted from ``field_validator`` + # ValueError with ``"Value error, "``. If this test starts to fail + # because the prefix reappears, either pydantic changed the format + # or the strip in ``validate_and_normalize_scan_config`` was + # dropped — either way the UI would render the noisy prefix, so + # we lock the cleaned message in explicitly. + _, errors = validate_and_normalize_scan_config( + {"aws": {"excluded_services": ["s3", "s3"]}} + ) + assert errors + message = errors[0]["message"] + assert not message.startswith("Value error, ") + assert "duplicate values are not allowed" in message + + def test_all_errors_are_reported_not_only_the_first(self): + normalized, errors = validate_and_normalize_scan_config( + { + "aws": { + "excluded_checks": ["", ""], + "excluded_services": ["", ""], + } + } + ) + assert normalized == {} + # ``excluded_checks`` yields per-item empty-string errors AND a + # duplicate error; ``excluded_services`` yields the same set. + paths = {err["path"] for err in errors} + assert any(p.startswith("aws.excluded_checks") for p in paths) + assert any(p.startswith("aws.excluded_services") for p in paths) + + +class Test_Non_String_Provider_Keys: + """The normalized payload is later persisted in a Django JSONField + keyed by provider. Two entries whose ``str()`` collide (e.g. ``123`` + and ``"123"``) would silently overwrite each other, so non-string + keys are rejected up front instead of silently coerced.""" + + def test_non_string_key_is_rejected(self): + normalized, errors = validate_and_normalize_scan_config({123: {}}) + assert normalized == {} + assert errors == [{"path": "123", "message": "provider keys must be strings."}] + + def test_string_and_int_collision_does_not_silently_overwrite(self): + # If only ``str()`` coercion happened both keys would collapse to + # ``"aws"`` in the output — this test guards against that regression. + normalized, errors = validate_and_normalize_scan_config( + {"aws": {}, 123: {"a": 1}} + ) + assert normalized == {} + assert any(err["path"] == "123" for err in errors) + + +class Test_Unknown_Sections_Must_Be_JSON_Serializable: + """``normalized`` is persisted by the API in a Django JSONField, so + unknown provider sections must fail fast here instead of blowing up + at persist time. Registered sections cannot hit this path — they go + through ``model_dump(mode="json", ...)`` which already coerces.""" + + def test_set_inside_unknown_section_is_rejected(self): + # ``set`` is a common trap: ``yaml.safe_load`` never produces it, + # but a hand-built dict might. + normalized, errors = validate_and_normalize_scan_config( + {"future_provider": {"values": {1, 2, 3}}} + ) + assert normalized == {} + assert errors + assert errors[0]["path"] == "future_provider" + assert "JSON-serializable" in errors[0]["message"] + + def test_json_safe_unknown_section_is_still_preserved(self): + payload = {"future_provider": {"nested": {"k": [1, 2, 3]}}} + normalized, errors = validate_and_normalize_scan_config(payload) + assert errors == [] + assert normalized == payload + + +class Test_Backward_Compatible_Wrapper: + def test_valid_payload_yields_no_errors(self): + assert ( + validate_scan_config( + {"aws": {"excluded_checks": ["s3_bucket_public_access"]}} + ) + == [] + ) + + def test_invalid_payload_yields_only_the_errors(self): + errors = validate_scan_config({"aws": {"excluded_checks": ["", ""]}}) + assert errors + assert all(set(err) == {"path", "message"} for err in errors) + + def test_unknown_exclusion_yields_the_semantic_error(self): + assert validate_scan_config( + {"aws": {"excluded_services": ["not_a_real_aws_service"]}} + ) == [ + { + "path": "aws.excluded_services[0]", + "message": ( + "Unknown service 'not_a_real_aws_service' for provider 'aws'." + ), + } + ] + + def test_non_mapping_root_matches_new_contract(self): + assert validate_scan_config(None) == [ + { + "path": "", + "message": "Scan config must be a mapping with provider sections.", + } + ] diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index de4c901fc3..f38c1ba376 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -98,6 +98,41 @@ class TestJiraIntegration: return found return None + @staticmethod + def _find_link_mark_by_href(nodes: List[dict], href: str) -> Optional[dict]: + for node in nodes: + if node.get("type") == "text": + for mark in node.get("marks", []): + if ( + mark.get("type") == "link" + and mark.get("attrs", {}).get("href") == href + ): + return mark + found = TestJiraIntegration._find_link_mark_by_href( + node.get("content", []), href + ) + if found: + return found + return None + + @staticmethod + def _collect_link_texts_by_href(nodes: List[dict], href: str) -> List[str]: + link_texts: List[str] = [] + + for node in nodes: + if node.get("type") == "text" and any( + mark.get("type") == "link" and mark.get("attrs", {}).get("href") == href + for mark in node.get("marks", []) + ): + link_texts.append(node.get("text", "")) + link_texts.extend( + TestJiraIntegration._collect_link_texts_by_href( + node.get("content", []), href + ) + ) + + return link_texts + @staticmethod def _find_table_row(rows: List[dict], header: str) -> dict: for row in rows: @@ -918,6 +953,12 @@ class TestJiraIntegration: intro_text = intro_paragraph["content"][0] assert intro_text["type"] == "text" assert intro_text["text"] == "Prowler has discovered the following finding:" + assert all( + self._collect_text_from_cell({"content": node.get("content", [])}) + != "Summary" + for node in description_content + if node.get("type") == "heading" + ) table = description_content[1] assert table["type"] == "table" @@ -1201,6 +1242,218 @@ class TestJiraIntegration: value_cell = row["content"][1] assert self._collect_text_from_cell(value_cell) == "-" + def test_get_grouped_adf_description_uses_capped_finding_group_link_copy(self): + finding_group_url = ( + "https://security.example.com/findings?" + "filter%5Bcheck_id%5D=admincenter_users_admins_reduced_license_footprint&" + "expandedCheckId=admincenter_users_admins_reduced_license_footprint" + ) + finding_group_link_text = "View the remaining grouped findings." + recommendation_url = ( + "https://hub.prowler.com/check/" + "admincenter_users_admins_reduced_license_footprint" + ) + adf_description = self.jira_integration.get_grouped_adf_description( + check_id="admincenter_users_admins_reduced_license_footprint", + check_title="Administrative user has no license or an allowed license", + check_description="Administrative users are assigned productivity licenses.", + severity="HIGH", + status="FAIL", + provider="m365", + service="exchange", + affected_failing_resources=123, + last_seen="Jul 09, 2026 11:38AM UTC", + failing_for="< 1 day", + grouped_resources=[ + { + "resource_name": "rich@prowler.com", + "resource_uid": "3f9a216b-b66b-4d5d-a812-2ad538732cfb", + "provider": "m365", + "service": "exchange", + "provider_account": "ProwlerPro.onmicrosoft.com", + "status": "FAIL", + "severity": "high", + "region": "global", + "last_seen": "Jul 09, 2026 11:38AM UTC", + "failing_for": "< 1 day", + "triage": "Open", + } + ], + resources_total=123, + resources_shown=100, + finding_group_url=finding_group_url, + finding_group_link_text=finding_group_link_text, + risk="Productivity licenses on privileged identities create risk.", + recommendation_text="Maintain dedicated admin accounts.", + recommendation_url=recommendation_url, + ) + + assert adf_description["type"] == "doc" + assert self._find_empty_text_nodes(adf_description) == [] + + main_table = adf_description["content"][1] + main_rows = {} + for row in main_table["content"]: + key_cell, value_cell = row["content"] + main_rows[self._collect_text_from_cell(key_cell)] = ( + self._collect_text_from_cell(value_cell) + ) + + assert ( + main_rows["Check Id"] + == "admincenter_users_admins_reduced_license_footprint" + ) + assert main_rows["Service"] == "exchange" + assert main_rows["Affected Failing Resources"] == "123" + assert ( + main_rows["Risk"] + == "Productivity licenses on privileged identities create risk." + ) + assert main_rows["Recommendation"] == ( + "Maintain dedicated admin accounts. " + recommendation_url + ) + assert "Finding Group Link" not in main_rows + assert "Region" not in main_rows + + top_level_headings = [ + self._collect_text_from_cell({"content": node.get("content", [])}) + for node in adf_description["content"] + if node.get("type") == "heading" + ] + assert "Risk" not in top_level_headings + assert "Recommendation" not in top_level_headings + assert "Summary" not in top_level_headings + + def text_marks(cell: dict) -> list[dict]: + return cell["content"][0]["content"][0]["marks"] + + severity_marks = text_marks( + self._find_table_row(main_table["content"], "Severity")["content"][1] + ) + status_marks = text_marks( + self._find_table_row(main_table["content"], "Status")["content"][1] + ) + assert { + "type": "backgroundColor", + "attrs": {"color": "#FFA500"}, + } in severity_marks + assert {"type": "textColor", "attrs": {"color": "#FF0000"}} in status_marks + + resource_table = next( + node + for node in adf_description["content"] + if node.get("type") == "table" + and self._collect_text_from_cell(node["content"][0]["content"][0]) + == "Resource" + ) + resource_cells = resource_table["content"][1]["content"] + assert {"type": "textColor", "attrs": {"color": "#FF0000"}} in text_marks( + resource_cells[5] + ) + assert { + "type": "backgroundColor", + "attrs": {"color": "#FFA500"}, + } in text_marks(resource_cells[6]) + + document_text = self._collect_text_from_cell( + {"content": adf_description["content"]} + ) + assert ( + "Administrative users are assigned productivity licenses." + not in document_text + ) + assert "Affected failing resources" in document_text + capped_link_copy = ( + f"Showing 100 of 123 Findings in this Jira issue. {finding_group_link_text}" + ) + assert document_text.count(capped_link_copy) == 1 + assert "Finding Group Link" not in document_text + assert recommendation_url in document_text + recommendation_link_mark = self._find_link_mark_by_href( + adf_description["content"], recommendation_url + ) + assert recommendation_link_mark is not None + link_mark = self._find_link_mark_by_href( + adf_description["content"], finding_group_url + ) + assert link_mark is not None + assert link_mark["attrs"]["href"] == finding_group_url + assert self._collect_link_texts_by_href( + adf_description["content"], finding_group_url + ) == [finding_group_link_text] + assert ( + len( + self._collect_link_texts_by_href( + adf_description["content"], finding_group_url + ) + ) + == 1 + ) + assert "filter%5Bcheck_id%5D=" in link_mark["attrs"]["href"] + assert "expandedCheckId=" in link_mark["attrs"]["href"] + + def test_get_grouped_adf_description_includes_link_when_not_capped(self): + finding_group_url = ( + "https://security.example.com/findings?" + "filter%5Bcheck_id%5D=s3_bucket_public_access&" + "expandedCheckId=s3_bucket_public_access" + ) + finding_group_link_text = "View this grouped finding." + adf_description = self.jira_integration.get_grouped_adf_description( + check_id="s3_bucket_public_access", + check_title="S3 bucket public access", + severity="HIGH", + status="FAIL", + provider="aws", + service="s3", + affected_failing_resources=1, + grouped_resources=[ + { + "resource_name": "bucket-a", + "resource_uid": "arn:aws:s3:::bucket-a", + "provider": "aws", + "service": "s3", + "provider_account": "production (123456789012)", + "status": "FAIL", + "severity": "high", + "region": "us-east-1", + "last_seen": "Jul 09, 2026 11:38AM UTC", + "failing_for": "< 1 day", + "triage": "Open", + } + ], + resources_total=1, + resources_shown=1, + finding_group_url=finding_group_url, + finding_group_link_text=finding_group_link_text, + ) + + document_text = self._collect_text_from_cell( + {"content": adf_description["content"]} + ) + assert "Showing 1 of 1 Findings." not in document_text + assert "remaining Findings" not in document_text + assert document_text.count(finding_group_link_text) == 1 + assert "Finding Group Link" not in document_text + main_table = adf_description["content"][1] + main_row_headers = [ + self._collect_text_from_cell(row["content"][0]) + for row in main_table["content"] + ] + assert "Finding Group Link" not in main_row_headers + link_mark = self._find_link_mark_by_href( + adf_description["content"], finding_group_url + ) + assert link_mark is not None + assert link_mark["attrs"]["href"] == finding_group_url + assert self._collect_link_texts_by_href( + adf_description["content"], finding_group_url + ) == [finding_group_link_text] + assert ( + "filter%5Bcheck_id%5D=s3_bucket_public_access" in link_mark["attrs"]["href"] + ) + assert "expandedCheckId=s3_bucket_public_access" in link_mark["attrs"]["href"] + @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] @@ -1709,6 +1962,54 @@ class TestJiraIntegration: assert result is True mock_post.assert_called_once() + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" + ) + @patch.object(Jira, "get_projects", return_value={"TEST": {"name": "Test Project"}}) + @patch.object(Jira, "get_available_issue_types", return_value=["Bug"]) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_send_finding_sanitizes_summary_control_characters( + self, + mock_post, + mock_get_issue_types, + mock_get_projects, + mock_cloud_id, + mock_get_access_token, + ): + """Test that Jira summary is sent as one line.""" + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + mock_get_projects = mock_get_projects + mock_get_issue_types = mock_get_issue_types + + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "ISSUE-123", "key": "TEST-123"} + mock_post.return_value = mock_response + long_check_id = "check\nwith\rcontrol\tcharacters " + "x" * 260 + + result = self.jira_integration.send_finding( + check_id=long_check_id, + check_title="Test Finding", + severity="High\n", + status="FAIL", + project_key="TEST", + issue_type="Bug", + affected_failing_resources=2, + grouped_resources=[], + ) + + assert result is True + payload = mock_post.call_args.kwargs["json"] + expected_summary = ( + f"[Prowler] HIGH - {' '.join(long_check_id.split())} - " + "2 affected failing resources" + )[:255] + assert payload["fields"]["summary"] == expected_summary + assert len(payload["fields"]["summary"]) == 255 + @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" diff --git a/tests/lib/scan/scan_exclusions_test.py b/tests/lib/scan/scan_exclusions_test.py new file mode 100644 index 0000000000..2c8f260c20 --- /dev/null +++ b/tests/lib/scan/scan_exclusions_test.py @@ -0,0 +1,178 @@ +"""Coverage for ``Scan`` constructor exclusion semantics. + +The Scan class is the single execution entry point used by both the CLI +and the API worker. Its exclusion validation must: + +- Reject duplicates and unknown identifiers with actionable errors. +- Validate excluded checks against the FULL provider catalog so a global + configuration can exclude a valid check that is not part of a scoped + run (see the SDK acceptance criteria for scan-configuration exclusions). +- Refuse a configuration that would leave nothing to execute. +- Produce a deterministic, sorted final scope. + +The catalog dependencies (``CheckMetadata.get_bulk``, ``Compliance.get_bulk``, +``list_services``, ``load_checks_to_execute``) are patched so tests stay +focused on the exclusion logic and avoid walking the provider package tree. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.lib.scan.exceptions.exceptions import ( + ScanInvalidCheckError, + ScanInvalidServiceError, +) +from prowler.lib.scan.scan import Scan +from tests.providers.aws.utils import set_mocked_aws_provider + +# The provider catalog for these tests: three checks spread across two +# services (``accessanalyzer`` and ``s3``). Keeps assertions readable. +PROVIDER_CATALOG = { + "accessanalyzer_enabled", + "s3_bucket_encryption_enabled", + "s3_bucket_public_access", +} +PROVIDER_SERVICES = ["accessanalyzer", "s3"] + + +@pytest.fixture +def scan_provider(): + provider = set_mocked_aws_provider() + metadata = MagicMock() + metadata.Categories = [] + bulk = {check: metadata for check in PROVIDER_CATALOG} + + with ( + patch( + "prowler.lib.scan.scan.CheckMetadata.get_bulk", + return_value=bulk, + ), + patch("prowler.lib.scan.scan.Compliance.get_bulk", return_value={}), + patch( + "prowler.lib.scan.scan.update_checks_metadata_with_compliance", + side_effect=lambda _compliance, checks: checks, + ), + patch( + "prowler.lib.scan.scan.load_checks_to_execute", + side_effect=lambda **kwargs: set(kwargs["check_list"] or PROVIDER_CATALOG), + ), + patch( + "prowler.lib.scan.scan.list_services", + return_value=PROVIDER_SERVICES, + ), + ): + yield provider + + +class Test_Exclusion_No_Ops: + def test_none_lists_are_no_ops(self, scan_provider): + scan = Scan(scan_provider, excluded_checks=None, excluded_services=None) + assert scan.checks_to_execute == sorted(PROVIDER_CATALOG) + + def test_empty_lists_are_no_ops(self, scan_provider): + scan = Scan(scan_provider, excluded_checks=[], excluded_services=[]) + assert scan.checks_to_execute == sorted(PROVIDER_CATALOG) + + +class Test_Excluded_Checks: + def test_valid_check_is_removed_from_the_scope(self, scan_provider): + scan = Scan( + scan_provider, + excluded_checks=["s3_bucket_public_access"], + ) + assert scan.checks_to_execute == sorted( + PROVIDER_CATALOG - {"s3_bucket_public_access"} + ) + + def test_excluded_check_may_be_outside_the_selected_scope(self, scan_provider): + # ``s3_bucket_public_access`` is not in the explicitly selected + # ``checks`` list but is still a valid provider check, so the + # global exclusion must be accepted and be a no-op for this run. + scan = Scan( + scan_provider, + checks=["accessanalyzer_enabled"], + excluded_checks=["s3_bucket_public_access"], + ) + assert scan.checks_to_execute == ["accessanalyzer_enabled"] + + def test_unknown_check_is_rejected(self, scan_provider): + with pytest.raises(ScanInvalidCheckError): + Scan(scan_provider, excluded_checks=["not_a_real_check"]) + + def test_duplicate_checks_are_rejected(self, scan_provider): + with pytest.raises(ScanInvalidCheckError): + Scan( + scan_provider, + excluded_checks=[ + "s3_bucket_public_access", + "s3_bucket_public_access", + ], + ) + + +class Test_Excluded_Services: + def test_service_exclusion_removes_every_check_in_the_service(self, scan_provider): + scan = Scan(scan_provider, excluded_services=["s3"]) + assert scan.checks_to_execute == ["accessanalyzer_enabled"] + + def test_unknown_service_is_rejected(self, scan_provider): + with pytest.raises(ScanInvalidServiceError): + Scan(scan_provider, excluded_services=["not_a_real_service"]) + + def test_duplicate_services_are_rejected(self, scan_provider): + with pytest.raises(ScanInvalidServiceError): + Scan(scan_provider, excluded_services=["s3", "s3"]) + + +class Test_Combined_Exclusions: + def test_selected_checks_plus_excluded_checks_and_services(self, scan_provider): + scan = Scan( + scan_provider, + checks=["accessanalyzer_enabled", "s3_bucket_encryption_enabled"], + excluded_checks=["s3_bucket_public_access"], + excluded_services=["s3"], + ) + # The explicit ``checks`` selection is narrowed by both the + # excluded_checks (drops nothing extra here) and excluded_services + # (drops every s3 check), leaving accessanalyzer alone. + assert scan.checks_to_execute == ["accessanalyzer_enabled"] + + def test_result_is_sorted_and_deterministic(self, scan_provider): + scan = Scan( + scan_provider, + excluded_checks=["s3_bucket_public_access"], + ) + assert scan.checks_to_execute == sorted(scan.checks_to_execute) + + +class Test_Empty_Final_Scope_Is_Rejected: + def test_excluding_every_service_is_rejected(self, scan_provider): + with pytest.raises(ScanInvalidCheckError): + Scan(scan_provider, excluded_services=PROVIDER_SERVICES) + + def test_excluding_every_check_is_rejected(self, scan_provider): + with pytest.raises(ScanInvalidCheckError): + Scan(scan_provider, excluded_checks=sorted(PROVIDER_CATALOG)) + + +class Test_Already_Empty_Scope_Does_Not_Blame_Exclusions: + """When a positive filter (severity, categories, checks that resolve + to nothing) leaves the scope empty *before* exclusions run, the + exclusion pass must not falsely claim to be the cause. Otherwise the + real reason (empty selection) is masked by a misleading error.""" + + def test_empty_initial_scope_with_valid_exclusions_does_not_raise( + self, scan_provider + ): + # Force ``load_checks_to_execute`` to return an empty scope while + # keeping the exclusion inputs valid against the provider catalog. + with patch( + "prowler.lib.scan.scan.load_checks_to_execute", + return_value=set(), + ): + scan = Scan( + scan_provider, + excluded_checks=["s3_bucket_public_access"], + ) + assert scan.checks_to_execute == [] diff --git a/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet_test.py b/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet_test.py index 17709a94a7..adbd82d11a 100644 --- a/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet_test.py +++ b/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet_test.py @@ -37,7 +37,7 @@ class TestEcsSecurityGroupRestrictRdpInternet: "ip_protocol": "tcp", "source_cidr_ip": "0.0.0.0/0", "port_range": "3389/3389", - "policy": "accept", + "policy": "Accept", } ], ) @@ -80,7 +80,7 @@ class TestEcsSecurityGroupRestrictRdpInternet: "ip_protocol": "tcp", "source_cidr_ip": "10.0.0.0/24", "port_range": "3389/3389", - "policy": "accept", + "policy": "Accept", } ], ) diff --git a/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet_test.py b/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet_test.py index 3278ce9a80..718baedce0 100644 --- a/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet_test.py +++ b/tests/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet_test.py @@ -37,7 +37,7 @@ class TestEcsSecurityGroupRestrictSSHInternet: "ip_protocol": "tcp", "source_cidr_ip": "0.0.0.0/0", "port_range": "22/22", - "policy": "accept", + "policy": "Accept", } ], ) @@ -81,7 +81,7 @@ class TestEcsSecurityGroupRestrictSSHInternet: "ip_protocol": "tcp", "source_cidr_ip": "10.0.0.0/24", "port_range": "22/22", - "policy": "accept", + "policy": "Accept", } ], ) diff --git a/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets_test.py b/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets_test.py new file mode 100644 index 0000000000..106a3f2b1b --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_no_secrets/sagemaker_notebook_instance_no_secrets_test.py @@ -0,0 +1,269 @@ +from unittest import mock + +from prowler.lib.utils.utils import SecretsScanError +from prowler.providers.aws.services.sagemaker.sagemaker_service import ( + NotebookInstance, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +test_notebook_instance = "test-notebook-instance" +notebook_instance_arn = ( + f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:" + f"{AWS_ACCOUNT_NUMBER}:notebook-instance/{test_notebook_instance}" +) + +other_notebook_instance = "other-notebook-instance" +other_notebook_instance_arn = ( + f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:" + f"{AWS_ACCOUNT_NUMBER}:notebook-instance/{other_notebook_instance}" +) + +CHECK_MODULE = "prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets" + + +class Test_sagemaker_notebook_instance_no_secrets: + def test_no_instances(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_notebook_instances = [] + sagemaker_client.audit_config = {} + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import ( + sagemaker_notebook_instance_no_secrets, + ) + + check = sagemaker_notebook_instance_no_secrets() + result = check.execute() + + assert len(result) == 0 + + def test_pass_no_lifecycle_config(self): + sagemaker_client = mock.MagicMock + sagemaker_client.audit_config = {} + sagemaker_client.sagemaker_notebook_instances = [ + NotebookInstance( + name=test_notebook_instance, + arn=notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + lifecycle_config_name=None, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client), + mock.patch( + f"{CHECK_MODULE}.detect_secrets_scan_batch", + return_value={}, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import ( + sagemaker_notebook_instance_no_secrets, + ) + + check = sagemaker_notebook_instance_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "does not have a lifecycle configuration" in result[0].status_extended + ) + assert result[0].resource_id == test_notebook_instance + assert result[0].resource_arn == notebook_instance_arn + + def test_pass_lifecycle_config_scanned_clean(self): + sagemaker_client = mock.MagicMock + sagemaker_client.audit_config = {} + sagemaker_client.sagemaker_notebook_instances = [ + NotebookInstance( + name=test_notebook_instance, + arn=notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + lifecycle_config_name="test-lifecycle-config", + lifecycle_scripts={"OnCreate[0]": "echo hello"}, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client), + mock.patch( + f"{CHECK_MODULE}.detect_secrets_scan_batch", + return_value={}, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import ( + sagemaker_notebook_instance_no_secrets, + ) + + check = sagemaker_notebook_instance_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "No secrets found" in result[0].status_extended + assert result[0].resource_id == test_notebook_instance + assert result[0].resource_arn == notebook_instance_arn + + def test_fail_secret_found(self): + notebook_instance = NotebookInstance( + name=test_notebook_instance, + arn=notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + lifecycle_config_name="test-lifecycle-config", + lifecycle_scripts={"OnCreate[0]": "echo API_KEY=12345"}, + ) + + sagemaker_client = mock.MagicMock + sagemaker_client.audit_config = {} + sagemaker_client.sagemaker_notebook_instances = [notebook_instance] + + fake_secret = {"type": "Secret Keyword", "line_number": 1} + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client), + mock.patch( + f"{CHECK_MODULE}.detect_secrets_scan_batch", + return_value={(notebook_instance_arn, "OnCreate[0]"): [fake_secret]}, + ), + mock.patch( + f"{CHECK_MODULE}.annotate_verified_secrets", + lambda *_: None, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import ( + sagemaker_notebook_instance_no_secrets, + ) + + check = sagemaker_notebook_instance_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Secret Keyword" in result[0].status_extended + assert "OnCreate[0]" in result[0].status_extended + assert result[0].resource_id == test_notebook_instance + assert result[0].resource_arn == notebook_instance_arn + + def test_manual_lifecycle_describe_failed(self): + # Service could not fully describe/decode the lifecycle config. + notebook_instance = NotebookInstance( + name=test_notebook_instance, + arn=notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + lifecycle_config_name="test-lifecycle-config", + lifecycle_scripts={}, + lifecycle_scan_failed=True, + ) + + sagemaker_client = mock.MagicMock + sagemaker_client.audit_config = {} + sagemaker_client.sagemaker_notebook_instances = [notebook_instance] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client), + mock.patch( + f"{CHECK_MODULE}.detect_secrets_scan_batch", + return_value={}, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import ( + sagemaker_notebook_instance_no_secrets, + ) + + check = sagemaker_notebook_instance_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == test_notebook_instance + assert result[0].resource_arn == notebook_instance_arn + + def test_manual_scan_error_only_scanned_instances(self): + # Batch scan fails. The instance with scripts must be MANUAL; the + # instance without a lifecycle config (nothing to scan) must PASS. + scanned_instance = NotebookInstance( + name=test_notebook_instance, + arn=notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + lifecycle_config_name="test-lifecycle-config", + lifecycle_scripts={"OnStart[0]": "echo hello"}, + ) + unscanned_instance = NotebookInstance( + name=other_notebook_instance, + arn=other_notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + lifecycle_config_name=None, + lifecycle_scripts={}, + ) + + sagemaker_client = mock.MagicMock + sagemaker_client.audit_config = {} + sagemaker_client.sagemaker_notebook_instances = [ + scanned_instance, + unscanned_instance, + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.sagemaker_client", sagemaker_client), + mock.patch( + f"{CHECK_MODULE}.detect_secrets_scan_batch", + side_effect=SecretsScanError("scan failed"), + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_no_secrets.sagemaker_notebook_instance_no_secrets import ( + sagemaker_notebook_instance_no_secrets, + ) + + check = sagemaker_notebook_instance_no_secrets() + result = check.execute() + + assert len(result) == 2 + results_by_id = {report.resource_id: report for report in result} + + assert results_by_id[test_notebook_instance].status == "MANUAL" + assert results_by_id[other_notebook_instance].status == "PASS" + assert ( + "does not have a lifecycle configuration" + in results_by_id[other_notebook_instance].status_extended + ) diff --git a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py index 50431c2e13..bfadd59efe 100644 --- a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py +++ b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py @@ -28,6 +28,10 @@ test_training_job = "test-training-job" test_arn_training_job = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:training-job/{test_model}" subnet_id = "subnet-" + str(uuid4()) kms_key_id = str(uuid4()) +lifecycle_config_name = "test-lifecycle-config" +# base64 of "echo OnCreate" / "echo OnStart" +lifecycle_on_create_b64 = "ZWNobyBPbkNyZWF0ZQ==" +lifecycle_on_start_b64 = "ZWNobyBPblN0YXJ0" endpoint_config_name = "endpoint-config-test" endpoint_config_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:endpoint-config/{endpoint_config_name}" prod_variant_name = "Variant1" @@ -76,6 +80,12 @@ def mock_make_api_call(self, operation_name, kwarg): "KmsKeyId": kms_key_id, "DirectInternetAccess": "Enabled", "RootAccess": "Enabled", + "NotebookInstanceLifecycleConfigName": lifecycle_config_name, + } + if operation_name == "DescribeNotebookInstanceLifecycleConfig": + return { + "OnCreate": [{"Content": lifecycle_on_create_b64}], + "OnStart": [{"Content": lifecycle_on_start_b64}], } if operation_name == "DescribeModel": return { @@ -247,6 +257,21 @@ class Test_SageMaker_Service: assert sagemaker.sagemaker_notebook_instances[0].subnet_id == subnet_id assert sagemaker.sagemaker_notebook_instances[0].direct_internet_access assert sagemaker.sagemaker_notebook_instances[0].kms_key_id == kms_key_id + assert ( + sagemaker.sagemaker_notebook_instances[0].lifecycle_config_name + == lifecycle_config_name + ) + + # Test SageMaker describe notebook instance lifecycle config + def test_describe_notebook_instance_lifecycle_config(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + sagemaker = SageMaker(aws_provider) + notebook_instance = sagemaker.sagemaker_notebook_instances[0] + assert notebook_instance.lifecycle_scan_failed is False + assert notebook_instance.lifecycle_scripts == { + "OnCreate[0]": "echo OnCreate", + "OnStart[0]": "echo OnStart", + } # Test SageMaker describe model def test_describe_model(self): diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 26385b3adc..40cb61691f 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.35.0] (Prowler v5.35.0) + +### 🔄 Changed + +- AWS Organizations onboarding now deploys the management account role and the member-account StackSet from a single CloudFormation stack, replacing the manual StackSet console step [(#11927)](https://github.com/prowler-cloud/prowler/pull/11927) +- Dynamic providers can now be renamed and deleted from the Providers table [(#11957)](https://github.com/prowler-cloud/prowler/pull/11957) +- Sidebar navigation with grouped sections, clearer active states, and a responsive mobile overlay [(#11994)](https://github.com/prowler-cloud/prowler/pull/11994) +- Core Prowler tools in Lighthouse use the `prowler_*` namespace while preserving legacy `prowler_app_*` compatibility [(#12017)](https://github.com/prowler-cloud/prowler/pull/12017) + +### 🐞 Fixed + +- The AWS S3 integration CloudFormation quick-create link now sets the bucket owner account ID, preventing a stack validation error when S3 integration is enabled [(#11927)](https://github.com/prowler-cloud/prowler/pull/11927) +- `Scan ID` filter on the Findings page now shows the active scan when opening findings from a scan's `View Findings` action [(#11997)](https://github.com/prowler-cloud/prowler/pull/11997) + +### 🔐 Security + +- `js-yaml` to 4.3.0, `@sentry/nextjs` to 10.65.0 with `import-in-the-middle` 3.3.1, and transitive `hono`, `dompurify`, `ws`, `vite`, `@babel/core` and `@opentelemetry/core` to patched versions, resolving 13 npm audit advisories (3 high, 9 moderate, 1 low) plus `hono` CVE-2026-59896, published on NVD but not yet in the npm audit feed [(#12029)](https://github.com/prowler-cloud/prowler/pull/12029) + +--- + ## [1.34.0] (Prowler v5.34.0) ### 🚀 Added diff --git a/ui/Dockerfile b/ui/Dockerfile index 6ab9752972..41484a3fbe 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -4,7 +4,9 @@ FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b1 LABEL maintainer="https://github.com/prowler-cloud" # Patch Alpine OpenSSL runtime packages before all stages inherit the base image. -RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable +# The build uses pnpm via corepack, so npm is unused — remove it (and npx) to drop +# the bundled-npm CVE surface (node-tar CVE-2026-59873) from every stage, incl. prod. +RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx # Install dependencies only when needed FROM base AS deps @@ -79,9 +81,10 @@ ENV HOSTNAME="0.0.0.0" # Helm/K8s): # - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot) # - optional: UI_API_DOCS_URL +# - optional: UI_CLOUD_ENABLED ("true" only in Prowler Cloud deployments) # - gated integrations (load only when *_ENABLED="true"; the value is then -# required or boot fails). Legacy names (NEXT_PUBLIC_*, POSTHOG_KEY/HOST) -# still activate without the flag: +# required or boot fails). Their legacy names (NEXT_PUBLIC_SENTRY_*, +# NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, POSTHOG_KEY/HOST) still work: # UI_SENTRY_ENABLED + UI_SENTRY_DSN (+ optional UI_SENTRY_ENVIRONMENT) # UI_GOOGLE_TAG_MANAGER_ENABLED + UI_GOOGLE_TAG_MANAGER_ID # UI_POSTHOG_ENABLED + UI_POSTHOG_KEY + UI_POSTHOG_HOST (no consumer yet) diff --git a/ui/actions/finding-groups/finding-groups.adapter.test.ts b/ui/actions/finding-groups/finding-groups.adapter.test.ts index e4dcb66804..6d93467fd2 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.test.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.test.ts @@ -235,7 +235,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => { it("should attach adapter-produced triage DTOs to finding-level resource rows", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); const input = { data: [ { @@ -291,7 +291,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => { it("should leave triage editing disabled until a real capability is provided", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); const input = { data: [ { diff --git a/ui/actions/findings/findings-triage.options.ts b/ui/actions/findings/findings-triage.options.ts index 1d0ad6314a..1f1578ec5c 100644 --- a/ui/actions/findings/findings-triage.options.ts +++ b/ui/actions/findings/findings-triage.options.ts @@ -1,3 +1,4 @@ +import { isCloud } from "@/lib/shared/env"; import { FINDING_TRIAGE_DISABLED_REASON, type FindingTriageDisabledReason, @@ -9,7 +10,7 @@ interface FindingTriageAdapterOptions { } export function getFindingTriageAdapterOptions(): FindingTriageAdapterOptions { - const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const isCloudEnvironment = isCloud(); return { canEdit: isCloudEnvironment, diff --git a/ui/actions/findings/findings.test.ts b/ui/actions/findings/findings.test.ts index efe04f119c..349de1adfe 100644 --- a/ui/actions/findings/findings.test.ts +++ b/ui/actions/findings/findings.test.ts @@ -55,7 +55,7 @@ describe("findings actions triage projection", () => { beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal("fetch", fetchMock); - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); fetchMock.mockResolvedValue(new Response("", { status: 200 })); handleApiResponseMock.mockResolvedValue(findingsResponse); @@ -83,7 +83,7 @@ describe("findings actions triage projection", () => { it("should attach domain triage DTOs to latest findings responses", async () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); // When const result = await getLatestFindings({ page: 1, pageSize: 10 }); diff --git a/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts b/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts index 5e6f5fd1f2..508d9a46ad 100644 --- a/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts +++ b/ui/actions/overview/resources-inventory/resources-inventory.adapter.ts @@ -1,5 +1,5 @@ -import { LucideIcon } from "lucide-react"; import { + LucideIcon, Activity, BarChart3, Bot, diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts index 3ab84efa4f..7150fc2646 100644 --- a/ui/actions/resources/resources.ts +++ b/ui/actions/resources/resources.ts @@ -13,6 +13,7 @@ import { } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import { isCloud } from "@/lib/shared/env"; import { OrganizationResource } from "@/types/organizations"; export const getResources = async ({ @@ -287,7 +288,7 @@ export const getResourceDrawerData = async ({ pageSize?: number; query?: string; }) => { - const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const isCloudEnv = isCloud(); const [resourceData, findingsResponse, organizationsResponse] = await Promise.all([ diff --git a/ui/actions/roles/roles.test.ts b/ui/actions/roles/roles.test.ts index 1e5dfaf189..3546649604 100644 --- a/ui/actions/roles/roles.test.ts +++ b/ui/actions/roles/roles.test.ts @@ -74,7 +74,7 @@ describe("role actions", () => { it("includes manage_alerts when creating a role in Prowler Cloud", async () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); // When await addRole(makeRoleFormData()); @@ -85,7 +85,7 @@ describe("role actions", () => { it("omits manage_alerts when creating a role outside Prowler Cloud", async () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); // When await addRole(makeRoleFormData()); @@ -98,7 +98,7 @@ describe("role actions", () => { it("includes manage_alerts when updating a role in Prowler Cloud", async () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); // When await updateRole(makeRoleFormData(), "role-1"); diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts index 645db72951..cfe3837414 100644 --- a/ui/actions/roles/roles.ts +++ b/ui/actions/roles/roles.ts @@ -5,6 +5,7 @@ import { redirect } from "next/navigation"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import { isCloud } from "@/lib/shared/env"; export const getRoles = async ({ page = 1, @@ -108,7 +109,7 @@ export const addRole = async (formData: FormData) => { }; // Conditionally include Prowler Cloud permissions. - if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { + if (isCloud()) { payload.data.attributes.manage_billing = formData.get("manage_billing") === "true"; payload.data.attributes.manage_alerts = @@ -165,7 +166,7 @@ export const updateRole = async (formData: FormData, roleId: string) => { }; // Conditionally include Prowler Cloud permissions. - if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { + if (isCloud()) { payload.data.attributes.manage_billing = formData.get("manage_billing") === "true"; payload.data.attributes.manage_alerts = diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.tsx index 4854de012b..0415c6b0f3 100644 --- a/ui/app/(auth)/(guest-only)/sign-up/page.tsx +++ b/ui/app/(auth)/(guest-only)/sign-up/page.tsx @@ -1,6 +1,10 @@ import { AuthForm } from "@/components/auth/oss"; -import { getAuthUrl, isGithubOAuthEnabled } from "@/lib/helper"; -import { isGoogleOAuthEnabled } from "@/lib/helper"; +import { + getAuthUrl, + isGithubOAuthEnabled, + isGoogleOAuthEnabled, +} from "@/lib/helper"; +import { isCloud } from "@/lib/shared/env"; import { SearchParamsProps } from "@/types"; const SignUp = async ({ @@ -13,7 +17,7 @@ const SignUp = async ({ typeof resolvedSearchParams?.invitation_token === "string" ? resolvedSearchParams.invitation_token : null; - const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const isCloudEnv = isCloud(); const GOOGLE_AUTH_URL = getAuthUrl("google"); const GITHUB_AUTH_URL = getAuthUrl("github"); diff --git a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx b/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx index 255ac50c63..b407396ce8 100644 --- a/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx +++ b/ui/app/(prowler)/_overview/_components/lighthouse-overview-banner.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { LIGHTHOUSE_OVERVIEW_BANNER_HREF } from "../_lib/lighthouse-banner"; + import { LighthouseOverviewBanner } from "./lighthouse-overview-banner"; describe("LighthouseOverviewBanner", () => { diff --git a/ui/app/(prowler)/_overview/attack-surface/attack-surface.ssr.tsx b/ui/app/(prowler)/_overview/attack-surface/attack-surface.ssr.tsx index 7f5b0b0f5f..03b89db714 100644 --- a/ui/app/(prowler)/_overview/attack-surface/attack-surface.ssr.tsx +++ b/ui/app/(prowler)/_overview/attack-surface/attack-surface.ssr.tsx @@ -5,6 +5,7 @@ import { import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { AttackSurface } from "./_components/attack-surface"; export const AttackSurfaceSSR = async ({ searchParams }: SSRComponentProps) => { diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx index 1f4d3625d4..940362dfe6 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx @@ -13,6 +13,7 @@ import { filterProvidersByScope, parseFilterIds, } from "../../_lib/provider-scope"; + import { RiskPlotClient } from "./risk-plot-client"; export async function RiskPlotSSR({ diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view.ssr.tsx index 932251e08a..355c03c397 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-radar-view/risk-radar-view.ssr.tsx @@ -7,6 +7,7 @@ import { import { SearchParamsProps } from "@/types"; import { pickFilterParams } from "../../_lib/filter-params"; + import { RiskRadarViewClient } from "./risk-radar-view-client"; export async function RiskRadarViewSSR({ diff --git a/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx index a95f65f9d8..17a7df9f52 100644 --- a/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx +++ b/ui/app/(prowler)/_overview/resources-inventory/resources-inventory.ssr.tsx @@ -5,6 +5,7 @@ import { import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { ResourcesInventory } from "./_components/resources-inventory"; export const ResourcesInventorySSR = async ({ diff --git a/ui/app/(prowler)/_overview/risk-severity/risk-severity-chart.ssr.tsx b/ui/app/(prowler)/_overview/risk-severity/risk-severity-chart.ssr.tsx index c825748169..d59b961c92 100644 --- a/ui/app/(prowler)/_overview/risk-severity/risk-severity-chart.ssr.tsx +++ b/ui/app/(prowler)/_overview/risk-severity/risk-severity-chart.ssr.tsx @@ -2,6 +2,7 @@ import { getFindingsBySeverity } from "@/actions/overview"; import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { RiskSeverityChart } from "./_components/risk-severity-chart"; export const RiskSeverityChartSSR = async ({ diff --git a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx index b65b6a21f0..31cbc42cc4 100644 --- a/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx +++ b/ui/app/(prowler)/_overview/severity-over-time/_components/finding-severity-over-time.tsx @@ -15,6 +15,7 @@ import { } from "@/types/severities"; import { DEFAULT_TIME_RANGE } from "../_constants/time-range.constants"; + import { type TimeRange, TimeRangeSelector } from "./time-range-selector"; interface FindingSeverityOverTimeProps { diff --git a/ui/app/(prowler)/_overview/severity-over-time/finding-severity-over-time.ssr.tsx b/ui/app/(prowler)/_overview/severity-over-time/finding-severity-over-time.ssr.tsx index a2d7a36d5c..e1f9c6635e 100644 --- a/ui/app/(prowler)/_overview/severity-over-time/finding-severity-over-time.ssr.tsx +++ b/ui/app/(prowler)/_overview/severity-over-time/finding-severity-over-time.ssr.tsx @@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { FindingSeverityOverTime } from "./_components/finding-severity-over-time"; import { FindingSeverityOverTimeSkeleton } from "./_components/finding-severity-over-time.skeleton"; import { DEFAULT_TIME_RANGE } from "./_constants/time-range.constants"; diff --git a/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx b/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx index 7f5364d147..244c50527e 100644 --- a/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx +++ b/ui/app/(prowler)/_overview/threat-score/threat-score.ssr.tsx @@ -2,6 +2,7 @@ import { getThreatScore } from "@/actions/overview"; import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { ThreatScore } from "./_components/threat-score"; export const ThreatScoreSSR = async ({ searchParams }: SSRComponentProps) => { diff --git a/ui/app/(prowler)/_overview/watchlist/compliance-watchlist.ssr.tsx b/ui/app/(prowler)/_overview/watchlist/compliance-watchlist.ssr.tsx index d0aae42dad..e897a58ea6 100644 --- a/ui/app/(prowler)/_overview/watchlist/compliance-watchlist.ssr.tsx +++ b/ui/app/(prowler)/_overview/watchlist/compliance-watchlist.ssr.tsx @@ -5,6 +5,7 @@ import { import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { ComplianceWatchlist } from "./_components/compliance-watchlist"; export const ComplianceWatchlistSSR = async ({ diff --git a/ui/app/(prowler)/_overview/watchlist/service-watchlist.ssr.tsx b/ui/app/(prowler)/_overview/watchlist/service-watchlist.ssr.tsx index 9ec08a6cb1..a84d9f1c56 100644 --- a/ui/app/(prowler)/_overview/watchlist/service-watchlist.ssr.tsx +++ b/ui/app/(prowler)/_overview/watchlist/service-watchlist.ssr.tsx @@ -2,6 +2,7 @@ import { getServicesOverview, ServiceOverview } from "@/actions/overview"; import { pickFilterParams } from "../_lib/filter-params"; import { SSRComponentProps } from "../_types"; + import { ServiceWatchlist } from "./_components/service-watchlist"; export const ServiceWatchlistSSR = async ({ diff --git a/ui/app/(prowler)/alerts/_actions/alerts.test.ts b/ui/app/(prowler)/alerts/_actions/alerts.test.ts index 5f19b33733..4dfe3dd563 100644 --- a/ui/app/(prowler)/alerts/_actions/alerts.test.ts +++ b/ui/app/(prowler)/alerts/_actions/alerts.test.ts @@ -25,6 +25,7 @@ vi.mock("@/lib/server-actions-helper", () => ({ })); import { ALERT_AGGREGATE_OPS, ALERT_TRIGGER_KINDS } from "../_types"; + import { createAlert, deleteAlert, diff --git a/ui/app/(prowler)/alerts/_components/alerts-manager.tsx b/ui/app/(prowler)/alerts/_components/alerts-manager.tsx index 92f11631ad..c9b499b251 100644 --- a/ui/app/(prowler)/alerts/_components/alerts-manager.tsx +++ b/ui/app/(prowler)/alerts/_components/alerts-manager.tsx @@ -15,12 +15,10 @@ import { ALERT_TRIGGER_KINDS, type AlertRule, } from "@/app/(prowler)/alerts/_types"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { DOCS_URLS } from "@/lib/external-urls"; -import type { MetaDataProps } from "@/types"; -import type { ScanEntity } from "@/types"; +import type { MetaDataProps, ScanEntity } from "@/types"; import type { ProviderProps } from "@/types/providers"; import { toAlertPayload } from "../_lib/alert-adapter"; @@ -29,6 +27,7 @@ import type { AlertFormSubmitResult, AlertFormValues, } from "../_types/alert-form"; + import { AlertFormModal } from "./alert-form-modal"; import { AlertsEmptyState } from "./alerts-empty-state"; import { AlertsTable } from "./alerts-table"; diff --git a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx index 6b94549a9e..988f7e70f8 100644 --- a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx +++ b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx @@ -28,8 +28,9 @@ import { Tooltip, TooltipContent, TooltipTrigger, + ToastAction, + useToast, } from "@/components/shadcn"; -import { ToastAction, useToast } from "@/components/shadcn"; import { useCloudUpgradeStore } from "@/store"; import type { ScanEntity } from "@/types"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx index 20210a3126..f95aa99870 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ATTACK_PATHS_VIEW_STATES } from "../_lib/get-attack-paths-view-state"; + import { AttackPathsStatusPanel } from "./attack-paths-status-panel"; describe("AttackPathsStatusPanel", () => { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx index 6e9c608f64..a987e3a7ac 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx @@ -34,6 +34,7 @@ import { resolveHiddenFindingIds, } from "../../_lib"; import { isFindingNode, layoutWithDagre } from "../../_lib/layout"; + import { FindingNode } from "./nodes/finding-node"; import { InternetNode } from "./nodes/internet-node"; import { ResourceNode } from "./nodes/resource-node"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx index 789a379551..78583c628a 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx @@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths"; import { resolveNodeColors, resolveNodeVisual } from "../../../_lib"; import { FINDING_NODE_DIMENSIONS } from "../../../_lib/node-dimensions"; import { getNodeLabelDisplay } from "../../../_lib/node-label-lines"; + import { HiddenHandles } from "./hidden-handles"; interface FindingNodeData { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx index e2009f71c9..097e2903c5 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx @@ -5,6 +5,7 @@ import { type NodeProps } from "@xyflow/react"; import type { GraphNode } from "@/types/attack-paths"; import { resolveNodeColors } from "../../../_lib"; + import { HiddenHandles } from "./hidden-handles"; interface InternetNodeData { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx index 9860dbea27..3120129091 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx @@ -12,6 +12,7 @@ import type { GraphNode } from "@/types/attack-paths"; import { resolveNodeColors, resolveNodeVisual } from "../../../_lib"; import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions"; import { getNodeLabelDisplay } from "../../../_lib/node-label-lines"; + import { HiddenHandles } from "./hidden-handles"; interface ResourceNodeData { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index cc87406aa8..3fc2e59f94 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -28,13 +28,13 @@ import { import { StatusAlert } from "@/components/shared/status-alert"; import { useMountEffect } from "@/hooks/use-mount-effect"; import { isCloud } from "@/lib/shared/env"; +import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour"; import { attackPathsTour, type AttackPathsTourTarget, pickDemoQuery, pickDemoScan, } from "@/lib/tours/attack-paths.tour"; -import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour"; import { advanceActiveTour, useDriverTour } from "@/lib/tours/use-driver-tour"; import type { AttackPathQuery, diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx index 617d943030..531cfbe79d 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx @@ -6,6 +6,7 @@ import { useCloudUpgradeStore } from "@/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { COMPLIANCE_TAB } from "../_types"; + import { CompliancePageTabs } from "./compliance-page-tabs"; import { getComplianceTab } from "./compliance-page-tabs.shared"; diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx index 7972379b1f..4901294ad4 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx @@ -29,6 +29,7 @@ import { parseCrossProviderFilters, } from "../_lib/cross-provider-frameworks"; import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types"; + import { CrossProviderErrorAlert } from "./cross-provider-error-alert"; import type { CrossProviderAccountOption, diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx index b2fb3b66f4..1e5a71deba 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.test.tsx @@ -11,6 +11,7 @@ import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS, CROSS_PROVIDER_OVERVIEW_TYPE, } from "../_types"; + import { CrossProviderOverview } from "./cross-provider-overview"; vi.mock("../_actions/cross-provider", () => ({ diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx index 17af2b38bf..d115ed4e0e 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-overview.tsx @@ -15,6 +15,7 @@ import { } from "../_lib/cross-provider-frameworks"; import type { CrossProviderFrameworkSummary } from "../_types"; import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types"; + import { CrossProviderErrorAlert } from "./cross-provider-error-alert"; import type { CrossProviderAccountOption, diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx index 98a9f11aab..5ec45475ce 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-requirement-content.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import type { CheckProviderTypesMap, Requirement } from "@/types/compliance"; import type { CrossProviderRequirementExtras } from "../_types"; + import { CrossProviderRequirementContent } from "./cross-provider-requirement-content"; const { clientAccordionContentMock } = vi.hoisted(() => ({ diff --git a/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx b/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx index d1f73d264a..ac30a01f53 100644 --- a/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx +++ b/ui/app/(prowler)/compliance/_components/provider-coverage-card.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { ProviderBreakdownEntry } from "../_types"; + import { ProviderCoverageCard } from "./provider-coverage-card"; vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({ diff --git a/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx b/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx index 520760092b..d0372c4f4b 100644 --- a/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx +++ b/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { Suspense } from "react"; import { getRoles } from "@/actions/roles"; diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx index 0060dbe44e..9b687383bc 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx @@ -29,6 +29,7 @@ import { import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { ProviderIcon } from "../config/provider-icon"; + import { ChatComposerPanel } from "./composer"; import { ChatEmptyState } from "./empty-state"; import { useLighthouseChatStore } from "./lighthouse-chat-store-provider"; diff --git a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx index acecf64b32..371075a088 100644 --- a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx +++ b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx @@ -42,6 +42,7 @@ import { LIGHTHOUSE_CHAT_SURFACE, LighthouseV2ChatView, } from "../chat/lighthouse-v2-chat-view"; + import { LighthousePanelChatSkeleton } from "./lighthouse-panel-chat-skeleton"; const PANEL_CHAT_STATUS = { diff --git a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/connect/page.tsx b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/connect/page.tsx index acebbf9cb4..f0f7c29a57 100644 --- a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/connect/page.tsx +++ b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/connect/page.tsx @@ -6,6 +6,7 @@ import { Suspense } from "react"; import { ConnectLLMProvider } from "@/components/lighthouse-v1/connect-llm-provider"; import { SelectBedrockAuthMethod } from "@/components/lighthouse-v1/select-bedrock-auth-method"; import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes"; +import { isCloud } from "@/lib/shared/env"; import type { LighthouseProvider } from "@/types/lighthouse-v1"; export const BEDROCK_AUTH_MODES = { @@ -44,7 +45,7 @@ function ConnectContent() { } export default function ConnectLLMProviderPage() { - if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { + if (isCloud()) { redirect(LIGHTHOUSE_ROUTE.SETTINGS); } diff --git a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/layout.tsx b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/layout.tsx index 8c8db3f7c7..9b03f5b3a2 100644 --- a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/layout.tsx +++ b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/layout.tsx @@ -12,8 +12,7 @@ import { } from "@/actions/lighthouse-v1/lighthouse"; import { DeleteLLMProviderForm } from "@/components/lighthouse-v1/forms/delete-llm-provider-form"; import { WorkflowConnectLLM } from "@/components/lighthouse-v1/workflow"; -import { Button } from "@/components/shadcn"; -import { NavigationHeader } from "@/components/shadcn"; +import { Button, NavigationHeader } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes"; import type { LighthouseProvider } from "@/types/lighthouse-v1"; diff --git a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/select-model/page.tsx b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/select-model/page.tsx index 7b61e92c62..f3e72b3063 100644 --- a/ui/app/(prowler)/lighthouse/settings/(connect-llm)/select-model/page.tsx +++ b/ui/app/(prowler)/lighthouse/settings/(connect-llm)/select-model/page.tsx @@ -5,6 +5,7 @@ import { Suspense } from "react"; import { SelectModel } from "@/components/lighthouse-v1/select-model"; import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes"; +import { isCloud } from "@/lib/shared/env"; import type { LighthouseProvider } from "@/types/lighthouse-v1"; function SelectModelContent() { @@ -27,7 +28,7 @@ function SelectModelContent() { } export default function SelectModelPage() { - if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { + if (isCloud()) { redirect(LIGHTHOUSE_ROUTE.SETTINGS); } diff --git a/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx b/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx index 5d30308ee3..b7a6885b55 100644 --- a/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx +++ b/ui/app/(prowler)/mutelist/_components/advanced-mutelist-form.tsx @@ -15,8 +15,8 @@ import { FieldError, Skeleton, Textarea, + useToast, } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { Modal } from "@/components/shadcn/modal"; import { fontMono } from "@/config/fonts"; diff --git a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx index 73af798640..40bcf5d3ff 100644 --- a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx +++ b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-enabled-toggle.tsx @@ -4,8 +4,7 @@ import { useState } from "react"; import { toggleMuteRule } from "@/actions/mute-rules"; import { MuteRuleData } from "@/actions/mute-rules/types"; -import { Switch } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Switch, useToast } from "@/components/shadcn"; interface MuteRuleEnabledToggleProps { muteRule: MuteRuleData; diff --git a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-targets-modal.test.tsx b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-targets-modal.test.tsx index eb8db616e2..fd45dc2e91 100644 --- a/ui/app/(prowler)/mutelist/_components/simple/mute-rule-targets-modal.test.tsx +++ b/ui/app/(prowler)/mutelist/_components/simple/mute-rule-targets-modal.test.tsx @@ -3,6 +3,7 @@ import { type ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import { type MuteRuleTableData } from "./mute-rule-target-previews"; +import { MuteRuleTargetsModal } from "./mute-rule-targets-modal"; vi.mock("@/components/shadcn/modal", () => ({ Modal: ({ @@ -21,8 +22,6 @@ vi.mock("@/components/shadcn/modal", () => ({ ) : null, })); -import { MuteRuleTargetsModal } from "./mute-rule-targets-modal"; - const longMuteRule: MuteRuleTableData = { type: "mute-rules", id: "mute-rule-1", diff --git a/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx index e9086cd366..83cfe429bb 100644 --- a/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx +++ b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx @@ -17,8 +17,8 @@ import { FieldLabel, Input, Textarea, + useToast, } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { Modal } from "@/components/shadcn/modal"; import { DOCS_URLS } from "@/lib/external-urls"; diff --git a/ui/app/(prowler)/scans/config/_components/scan-configurations-manager.tsx b/ui/app/(prowler)/scans/config/_components/scan-configurations-manager.tsx index 2599b7291b..618ee1b8d0 100644 --- a/ui/app/(prowler)/scans/config/_components/scan-configurations-manager.tsx +++ b/ui/app/(prowler)/scans/config/_components/scan-configurations-manager.tsx @@ -10,8 +10,7 @@ import { import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout"; import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; -import { Button, Card } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, Card, useToast } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { Modal } from "@/components/shadcn/modal"; import { DataTable } from "@/components/shadcn/table"; diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index f307920e52..217b94b4c6 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -11,6 +11,8 @@ import { import { getSchedules, getSchedulesPage } from "@/actions/schedules"; import { auth } from "@/auth.config"; import { PageReady } from "@/components/onboarding"; +import { ScansPageShell } from "@/components/scans/scans-page-shell"; +import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state"; import { appendPendingScheduleRowsToPage, buildScheduledTabRows, @@ -20,8 +22,6 @@ import { getScanJobsUserFilters, pickScheduleProviderFilters, } from "@/components/scans/scans.utils"; -import { ScansPageShell } from "@/components/scans/scans-page-shell"; -import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state"; import { SkeletonTableScans } from "@/components/scans/table"; import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table"; import { ContentLayout } from "@/components/shadcn/content-layout"; diff --git a/ui/changelog.d/aws-org-one-step-stackset-deploy.changed.md b/ui/changelog.d/aws-org-one-step-stackset-deploy.changed.md deleted file mode 100644 index caffe6c120..0000000000 --- a/ui/changelog.d/aws-org-one-step-stackset-deploy.changed.md +++ /dev/null @@ -1 +0,0 @@ -AWS Organizations onboarding now deploys the management account role and the member-account StackSet from a single CloudFormation stack, replacing the manual StackSet console step diff --git a/ui/changelog.d/aws-organizations-ou-hint-error-color.fixed.md b/ui/changelog.d/aws-organizations-ou-hint-error-color.fixed.md new file mode 100644 index 0000000000..d6601faf8c --- /dev/null +++ b/ui/changelog.d/aws-organizations-ou-hint-error-color.fixed.md @@ -0,0 +1 @@ +AWS Organizations setup modal now shows the "Enter a valid Organizational Unit or Root ID" hint in the error color, clarifying why the deployment button is disabled diff --git a/ui/changelog.d/dynamic-provider-alias-delete.changed.md b/ui/changelog.d/dynamic-provider-alias-delete.changed.md deleted file mode 100644 index 75adeeeffb..0000000000 --- a/ui/changelog.d/dynamic-provider-alias-delete.changed.md +++ /dev/null @@ -1 +0,0 @@ -Dynamic providers can now be renamed and deleted from the Providers table diff --git a/ui/changelog.d/enterprise-billing-navigation.fixed.md b/ui/changelog.d/enterprise-billing-navigation.fixed.md new file mode 100644 index 0000000000..0ea2b4d31e --- /dev/null +++ b/ui/changelog.d/enterprise-billing-navigation.fixed.md @@ -0,0 +1 @@ +Billing navigation is hidden when Cloud billing is disabled, including Enterprise deployments diff --git a/ui/changelog.d/findings-scan-filter-selection.fixed.md b/ui/changelog.d/findings-scan-filter-selection.fixed.md deleted file mode 100644 index ff1f70c117..0000000000 --- a/ui/changelog.d/findings-scan-filter-selection.fixed.md +++ /dev/null @@ -1 +0,0 @@ -`Scan ID` filter on the Findings page now shows the active scan when opening findings from a scan's `View Findings` action diff --git a/ui/changelog.d/findings-timeline-y-axis.fixed.md b/ui/changelog.d/findings-timeline-y-axis.fixed.md new file mode 100644 index 0000000000..4dd338070f --- /dev/null +++ b/ui/changelog.d/findings-timeline-y-axis.fixed.md @@ -0,0 +1 @@ +Findings Severity Over Time chart Y-axis labels no longer overflow for large findings counts diff --git a/ui/changelog.d/oci-regionless-provider-e2e.fixed.md b/ui/changelog.d/oci-regionless-provider-e2e.fixed.md new file mode 100644 index 0000000000..2e413a6fa8 --- /dev/null +++ b/ui/changelog.d/oci-regionless-provider-e2e.fixed.md @@ -0,0 +1 @@ +OCI provider E2E tests no longer require or submit a region when adding or updating credentials diff --git a/ui/changelog.d/prowler-2254-cloud-upgrade-modal-flash.fixed.md b/ui/changelog.d/prowler-2254-cloud-upgrade-modal-flash.fixed.md new file mode 100644 index 0000000000..ec0b5fd863 --- /dev/null +++ b/ui/changelog.d/prowler-2254-cloud-upgrade-modal-flash.fixed.md @@ -0,0 +1 @@ +Contextual Cloud upgrade modal content remains stable throughout the closing animation diff --git a/ui/changelog.d/prowler-tools-namespace.changed.md b/ui/changelog.d/prowler-tools-namespace.changed.md deleted file mode 100644 index f1affd1715..0000000000 --- a/ui/changelog.d/prowler-tools-namespace.changed.md +++ /dev/null @@ -1 +0,0 @@ -Core Prowler tools in Lighthouse use the `prowler_*` namespace while preserving legacy `prowler_app_*` compatibility diff --git a/ui/changelog.d/s3-integration-bucket-account-id.fixed.md b/ui/changelog.d/s3-integration-bucket-account-id.fixed.md deleted file mode 100644 index 5b2d6310be..0000000000 --- a/ui/changelog.d/s3-integration-bucket-account-id.fixed.md +++ /dev/null @@ -1 +0,0 @@ -The AWS S3 integration CloudFormation quick-create link now sets the bucket owner account ID, preventing a stack validation error when S3 integration is enabled diff --git a/ui/changelog.d/sidebar-logo-top-padding.fixed.md b/ui/changelog.d/sidebar-logo-top-padding.fixed.md new file mode 100644 index 0000000000..c9a3dc6ef8 --- /dev/null +++ b/ui/changelog.d/sidebar-logo-top-padding.fixed.md @@ -0,0 +1 @@ +Sidebar logo top spacing in the main app sidebar diff --git a/ui/changelog.d/sidebar-redesign.changed.md b/ui/changelog.d/sidebar-redesign.changed.md deleted file mode 100644 index 9d45d8ffdf..0000000000 --- a/ui/changelog.d/sidebar-redesign.changed.md +++ /dev/null @@ -1 +0,0 @@ -Sidebar navigation with grouped sections, clearer active states, and a responsive mobile overlay diff --git a/ui/changelog.d/ui-sentry-actionability.fixed.md b/ui/changelog.d/ui-sentry-actionability.fixed.md new file mode 100644 index 0000000000..7a4b8ce2ca --- /dev/null +++ b/ui/changelog.d/ui-sentry-actionability.fixed.md @@ -0,0 +1 @@ +UI Sentry alerts now suppress non-actionable warnings and expected API/control-flow noise while preserving actionable runtime failures diff --git a/ui/changelog.d/ui-trivy-cve-2026-59873-npm-tar.security.md b/ui/changelog.d/ui-trivy-cve-2026-59873-npm-tar.security.md new file mode 100644 index 0000000000..d446e6c2b5 --- /dev/null +++ b/ui/changelog.d/ui-trivy-cve-2026-59873-npm-tar.security.md @@ -0,0 +1 @@ +Removed the unused `npm` CLI from the UI container image, eliminating the bundled `node-tar` `CVE-2026-59873` (and future bundled-npm CVEs); the image builds with `pnpm` via `corepack` and does not use `npm` diff --git a/ui/changelog.d/ui-vitest-browser-file-access-bypass.security.md b/ui/changelog.d/ui-vitest-browser-file-access-bypass.security.md new file mode 100644 index 0000000000..667a41d2c1 --- /dev/null +++ b/ui/changelog.d/ui-vitest-browser-file-access-bypass.security.md @@ -0,0 +1 @@ +Bumped `vitest` and `@vitest/browser`, `@vitest/browser-playwright`, `@vitest/coverage-v8` from `4.1.8` to `4.1.10`, resolving the critical `@vitest/browser` Browser Mode file-access permission bypass (`GHSA-p63j-vcc4-9vmv`) flagged by `pnpm audit`; dev dependencies only, no runtime impact diff --git a/ui/components/auth/oss/sign-in-form.tsx b/ui/components/auth/oss/sign-in-form.tsx index 8ebd8f3c0e..7c971913be 100644 --- a/ui/components/auth/oss/sign-in-form.tsx +++ b/ui/components/auth/oss/sign-in-form.tsx @@ -17,8 +17,8 @@ import { Tooltip, TooltipContent, TooltipTrigger, + useToast, } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form } from "@/components/shadcn/form"; import { getSafeCallbackPath } from "@/lib/auth-callback-url"; diff --git a/ui/components/auth/oss/sign-up-form.tsx b/ui/components/auth/oss/sign-up-form.tsx index 2127151b73..68fe4e2c6a 100644 --- a/ui/components/auth/oss/sign-up-form.tsx +++ b/ui/components/auth/oss/sign-up-form.tsx @@ -15,8 +15,7 @@ import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link"; import { AuthLayout } from "@/components/auth/oss/auth-layout"; import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator"; import { SocialButtons } from "@/components/auth/oss/social-buttons"; -import { Button, Checkbox } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, Checkbox, useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { diff --git a/ui/components/auth/oss/social-buttons.test.tsx b/ui/components/auth/oss/social-buttons.test.tsx index 80b9ca6e90..7a406f4643 100644 --- a/ui/components/auth/oss/social-buttons.test.tsx +++ b/ui/components/auth/oss/social-buttons.test.tsx @@ -1,8 +1,15 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { SocialButtons } from "./social-buttons"; +// Stub Iconify: the real fetches icon data over the network and its +// retry timers can fire after the jsdom environment is torn down, crashing the +// worker with "window is not defined". +vi.mock("@iconify/react", () => ({ + Icon: ({ icon }: { icon: string }) => , +})); + describe("SocialButtons", () => { it("renders icon-only provider links that keep their accessible names", () => { render( diff --git a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx index e3ab821a55..256431cdfb 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx @@ -2,8 +2,7 @@ import { useRef, useState } from "react"; -import { Button } from "@/components/shadcn"; -import { Accordion, AccordionItemProps } from "@/components/shadcn"; +import { Button, Accordion, AccordionItemProps } from "@/components/shadcn"; import { Card } from "@/components/shadcn/card/card"; export const ClientAccordionWrapper = ({ diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 6168784f2f..37c7892fda 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -19,6 +19,7 @@ import { import { ScanEntity } from "@/types/scans"; import { getComplianceIcon } from "../icons"; + import { ComplianceDownloadContainer } from "./compliance-download-container"; interface ComplianceCardProps { diff --git a/ui/components/findings/table/data-table-row-actions.test.tsx b/ui/components/findings/table/data-table-row-actions.test.tsx index 304460b3c7..ad5d416406 100644 --- a/ui/components/findings/table/data-table-row-actions.test.tsx +++ b/ui/components/findings/table/data-table-row-actions.test.tsx @@ -4,6 +4,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import { + FINDING_TRIAGE_DISABLED_REASON, + FINDING_TRIAGE_STATUS, + type FindingTriageSummary, +} from "@/types/findings-triage"; + +import { + DataTableRowActions, + type FindingRowData, +} from "./data-table-row-actions"; +import { FindingsSelectionContext } from "./findings-selection-context"; const { MuteFindingsModalMock, isGroupedJiraDispatchEnabledMock } = vi.hoisted( () => ({ @@ -97,18 +108,6 @@ vi.mock("./finding-note-modal", () => ({ ) : null, })); -import { - FINDING_TRIAGE_DISABLED_REASON, - FINDING_TRIAGE_STATUS, - type FindingTriageSummary, -} from "@/types/findings-triage"; - -import { - DataTableRowActions, - type FindingRowData, -} from "./data-table-row-actions"; -import { FindingsSelectionContext } from "./findings-selection-context"; - function deferredPromise() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index ad45307fc8..9f7aef3df9 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -22,8 +22,9 @@ import { TableHead, TableHeader, TableRow, + SeverityBadge, + StatusFindingBadge, } from "@/components/shadcn/table"; -import { SeverityBadge, StatusFindingBadge } from "@/components/shadcn/table"; import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state"; import { cn, hasHistoricalFindingFilter } from "@/lib"; import { isGroupedJiraDispatchEnabled } from "@/lib/deployment"; @@ -37,6 +38,7 @@ import { FindingGroupRow } from "@/types"; import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; import { FloatingMuteButton } from "../floating-mute-button"; + import { getColumnFindingResources } from "./column-finding-resources"; import { FindingsSelectionContext } from "./findings-selection-context"; import { ImpactedResourcesCell } from "./impacted-resources-cell"; diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index 76ffdbee1b..649b670657 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -21,6 +21,7 @@ import { FindingGroupRow, MetaDataProps } from "@/types"; import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; import { FloatingMuteButton } from "../floating-mute-button"; + import { getColumnFindingGroups } from "./column-finding-groups"; import { canMuteFindingGroup } from "./finding-group-selection"; import { FindingsSelectionContext } from "./findings-selection-context"; diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index c0d2a37f51..98ca0ea133 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -71,8 +71,7 @@ import { type QueryEditorLanguage, } from "@/components/shared/query-code-editor"; import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel"; -import { getFailingForLabel } from "@/lib/date-utils"; -import { formatDuration } from "@/lib/date-utils"; +import { getFailingForLabel, formatDuration } from "@/lib/date-utils"; import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage"; import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection"; import { buildFindingAnalysisPrompt } from "@/lib/lighthouse/prompts"; @@ -91,6 +90,7 @@ import { FindingTriageStatusCell, } from "../finding-triage-cells"; import { DeltaValues, NotificationIndicator } from "../notification-indicator"; + import { ResourceDetailSkeleton } from "./resource-detail-skeleton"; import type { CheckMeta } from "./use-resource-detail-drawer"; diff --git a/ui/components/graphs/line-chart.test.tsx b/ui/components/graphs/line-chart.test.tsx new file mode 100644 index 0000000000..3febcd46f3 --- /dev/null +++ b/ui/components/graphs/line-chart.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { formatYAxisTick } from "./line-chart.utils"; + +describe("formatYAxisTick", () => { + describe("when findings counts are large", () => { + it("should compact six-digit values so Y-axis labels do not overflow", () => { + // Given + const tickValue = 150000; + + // When + const formattedValue = formatYAxisTick(tickValue); + + // Then + expect(formattedValue).toBe("150K"); + }); + + it("should compact million-scale values", () => { + // Given + const tickValue = 1200000; + + // When + const formattedValue = formatYAxisTick(tickValue); + + // Then + expect(formattedValue).toBe("1.2M"); + }); + }); + + describe("when findings counts are small", () => { + it("should keep values below 1000 readable without compact notation", () => { + // Given + const tickValue = 999; + + // When + const formattedValue = formatYAxisTick(tickValue); + + // Then + expect(formattedValue).toBe("999"); + }); + }); +}); diff --git a/ui/components/graphs/line-chart.tsx b/ui/components/graphs/line-chart.tsx index eab0551bb6..1ffdd19dc3 100644 --- a/ui/components/graphs/line-chart.tsx +++ b/ui/components/graphs/line-chart.tsx @@ -17,6 +17,7 @@ import { ChartTooltip, } from "@/components/shadcn/chart/Chart"; +import { formatYAxisTick } from "./line-chart.utils"; import { AlertPill } from "./shared/alert-pill"; import { ChartLegend } from "./shared/chart-legend"; import { CustomActiveDot, PointClickData } from "./shared/custom-active-dot"; @@ -222,6 +223,8 @@ export function LineChart({ tickLine={false} axisLine={false} tickMargin={8} + tickFormatter={formatYAxisTick} + width={56} padding={{ top: 20 }} tick={{ fill: "var(--color-text-neutral-secondary)", diff --git a/ui/components/graphs/line-chart.utils.ts b/ui/components/graphs/line-chart.utils.ts new file mode 100644 index 0000000000..b219529c24 --- /dev/null +++ b/ui/components/graphs/line-chart.utils.ts @@ -0,0 +1,8 @@ +const Y_AXIS_TICK_FORMATTER = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); + +export function formatYAxisTick(value: number) { + return Y_AXIS_TICK_FORMATTER.format(value); +} diff --git a/ui/components/icons/prowler/ProwlerIcons.test.tsx b/ui/components/icons/prowler/ProwlerIcons.test.tsx index 5fca4420e4..b75c5402af 100644 --- a/ui/components/icons/prowler/ProwlerIcons.test.tsx +++ b/ui/components/icons/prowler/ProwlerIcons.test.tsx @@ -11,7 +11,7 @@ describe("ProwlerBrand", () => { it("should render the Local Server lockups outside Cloud", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); // When render(); @@ -30,7 +30,7 @@ describe("ProwlerBrand", () => { it("should render the Prowler Cloud lockups in Cloud", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); // When render(); diff --git a/ui/components/integrations/jira/jira-integration-card.tsx b/ui/components/integrations/jira/jira-integration-card.tsx index 4629215f5e..b99cf7cdbe 100644 --- a/ui/components/integrations/jira/jira-integration-card.tsx +++ b/ui/components/integrations/jira/jira-integration-card.tsx @@ -4,11 +4,9 @@ import { SettingsIcon } from "lucide-react"; import Link from "next/link"; import { JiraIcon } from "@/components/icons/services/IconServices"; -import { Button } from "@/components/shadcn"; +import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; -import { Card, CardContent, CardHeader } from "../../shadcn"; - export const JiraIntegrationCard = () => { return ( diff --git a/ui/components/integrations/jira/jira-integrations-manager.tsx b/ui/components/integrations/jira/jira-integrations-manager.tsx index c3f3a1412f..e63b17dac3 100644 --- a/ui/components/integrations/jira/jira-integrations-manager.tsx +++ b/ui/components/integrations/jira/jira-integrations-manager.tsx @@ -15,15 +15,19 @@ import { IntegrationCardHeader, IntegrationSkeleton, } from "@/components/integrations/shared"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { + Button, + useToast, + Card, + CardContent, + CardHeader, +} from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; import { MetaDataProps } from "@/types"; import { IntegrationProps } from "@/types/integrations"; -import { Card, CardContent, CardHeader } from "../../shadcn"; import { JiraIntegrationForm } from "./jira-integration-form"; interface JiraIntegrationsManagerProps { diff --git a/ui/components/integrations/s3/s3-integration-card.tsx b/ui/components/integrations/s3/s3-integration-card.tsx index 7e2be1890d..be173c5609 100644 --- a/ui/components/integrations/s3/s3-integration-card.tsx +++ b/ui/components/integrations/s3/s3-integration-card.tsx @@ -4,11 +4,9 @@ import { SettingsIcon } from "lucide-react"; import Link from "next/link"; import { AmazonS3Icon } from "@/components/icons/services/IconServices"; -import { Button } from "@/components/shadcn"; +import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; -import { Card, CardContent, CardHeader } from "../../shadcn"; - export const S3IntegrationCard = () => { return ( diff --git a/ui/components/integrations/s3/s3-integration-form.tsx b/ui/components/integrations/s3/s3-integration-form.tsx index 555e7d0d1f..30587fc3b3 100644 --- a/ui/components/integrations/s3/s3-integration-form.tsx +++ b/ui/components/integrations/s3/s3-integration-form.tsx @@ -13,8 +13,7 @@ import { ProviderTypeIcon, } from "@/components/icons/providers-badge/provider-type-icon"; import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; -import { Separator } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Separator, useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { @@ -26,6 +25,7 @@ import { import { FormButtons } from "@/components/shadcn/form/form-buttons"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { getAWSCredentialsTemplateLinks } from "@/lib"; +import { isCloud } from "@/lib/shared/env"; import type { AWSCredentialsRole } from "@/types"; import type { IntegrationProps } from "@/types/integrations"; import { @@ -78,10 +78,9 @@ export const S3IntegrationForm = ({ const isEditingConfig = editMode === "configuration"; const isEditingCredentials = editMode === "credentials"; - const defaultCredentialsType = - process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" - ? "aws-sdk-default" - : "access-secret-key"; + const defaultCredentialsType = isCloud() + ? "aws-sdk-default" + : "access-secret-key"; const form = useForm({ resolver: zodResolver( diff --git a/ui/components/integrations/s3/s3-integrations-manager.tsx b/ui/components/integrations/s3/s3-integrations-manager.tsx index 1e75d1b43c..03ec525abc 100644 --- a/ui/components/integrations/s3/s3-integrations-manager.tsx +++ b/ui/components/integrations/s3/s3-integrations-manager.tsx @@ -15,8 +15,13 @@ import { IntegrationCardHeader, IntegrationSkeleton, } from "@/components/integrations/shared"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { + Button, + useToast, + Card, + CardContent, + CardHeader, +} from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; @@ -24,7 +29,6 @@ import { MetaDataProps } from "@/types"; import { IntegrationProps } from "@/types/integrations"; import { ProviderProps } from "@/types/providers"; -import { Card, CardContent, CardHeader } from "../../shadcn"; import { S3IntegrationForm } from "./s3-integration-form"; interface S3IntegrationsManagerProps { diff --git a/ui/components/integrations/saml/saml-config-form.tsx b/ui/components/integrations/saml/saml-config-form.tsx index db713252a8..82d5fccfd5 100644 --- a/ui/components/integrations/saml/saml-config-form.tsx +++ b/ui/components/integrations/saml/saml-config-form.tsx @@ -12,8 +12,13 @@ import { z } from "zod"; import { createSamlConfig, updateSamlConfig } from "@/actions/integrations"; import { AddIcon } from "@/components/icons"; -import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { + Button, + Card, + CardContent, + CardHeader, + useToast, +} from "@/components/shadcn"; import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet"; import { CustomServerInput } from "@/components/shadcn/custom"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; diff --git a/ui/components/integrations/security-hub/security-hub-integration-card.tsx b/ui/components/integrations/security-hub/security-hub-integration-card.tsx index 4003f8c286..3861702fe0 100644 --- a/ui/components/integrations/security-hub/security-hub-integration-card.tsx +++ b/ui/components/integrations/security-hub/security-hub-integration-card.tsx @@ -4,11 +4,9 @@ import { SettingsIcon } from "lucide-react"; import Link from "next/link"; import { AWSSecurityHubIcon } from "@/components/icons/services/IconServices"; -import { Button } from "@/components/shadcn"; +import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; -import { Card, CardContent, CardHeader } from "../../shadcn"; - export const SecurityHubIntegrationCard = () => { return ( diff --git a/ui/components/integrations/security-hub/security-hub-integration-form.tsx b/ui/components/integrations/security-hub/security-hub-integration-form.tsx index b7fac10706..24e88b744f 100644 --- a/ui/components/integrations/security-hub/security-hub-integration-form.tsx +++ b/ui/components/integrations/security-hub/security-hub-integration-form.tsx @@ -12,8 +12,7 @@ import { ProviderTypeIcon, } from "@/components/icons/providers-badge/provider-type-icon"; import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; -import { Checkbox, Separator } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Checkbox, Separator, useToast } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { Form, diff --git a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx index 34c2d9e303..c0c5f9f02e 100644 --- a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx +++ b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx @@ -15,8 +15,14 @@ import { IntegrationCardHeader, IntegrationSkeleton, } from "@/components/integrations/shared"; -import { Badge, Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { + Badge, + Button, + useToast, + Card, + CardContent, + CardHeader, +} from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; @@ -24,7 +30,6 @@ import { MetaDataProps } from "@/types"; import { IntegrationProps } from "@/types/integrations"; import { ProviderProps } from "@/types/providers"; -import { Card, CardContent, CardHeader } from "../../shadcn"; import { SecurityHubIntegrationForm } from "./security-hub-integration-form"; interface SecurityHubIntegrationsManagerProps { diff --git a/ui/components/integrations/shared/link-card.tsx b/ui/components/integrations/shared/link-card.tsx index ceb74d6a1b..212c95ed39 100644 --- a/ui/components/integrations/shared/link-card.tsx +++ b/ui/components/integrations/shared/link-card.tsx @@ -3,11 +3,9 @@ import { ExternalLinkIcon, LucideIcon } from "lucide-react"; import Link from "next/link"; -import { Button } from "@/components/shadcn"; +import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; -import { Card, CardContent, CardHeader } from "../../shadcn"; - interface LinkCardProps { icon: LucideIcon; title: string; diff --git a/ui/components/invitations/forms/delete-form.tsx b/ui/components/invitations/forms/delete-form.tsx index 58618b9dc6..e34a5e30bd 100644 --- a/ui/components/invitations/forms/delete-form.tsx +++ b/ui/components/invitations/forms/delete-form.tsx @@ -7,8 +7,7 @@ import * as z from "zod"; import { revokeInvite } from "@/actions/invitations/invitation"; import { DeleteIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Form } from "@/components/shadcn/form"; const formSchema = z.object({ diff --git a/ui/components/invitations/forms/edit-form.tsx b/ui/components/invitations/forms/edit-form.tsx index b8bfaf6f70..bbfc57b2fb 100644 --- a/ui/components/invitations/forms/edit-form.tsx +++ b/ui/components/invitations/forms/edit-form.tsx @@ -5,7 +5,7 @@ import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; import { updateInvite } from "@/actions/invitations/invitation"; -import { useToast } from "@/components/shadcn"; +import { useToast, Card, CardContent } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form, FormButtons } from "@/components/shadcn/form"; import { @@ -17,8 +17,6 @@ import { } from "@/components/shadcn/select/select"; import { editInviteFormSchema } from "@/types"; -import { Card, CardContent } from "../../shadcn"; - export const EditForm = ({ invitationId, invitationEmail, diff --git a/ui/components/invitations/workflow/forms/send-invitation-form.tsx b/ui/components/invitations/workflow/forms/send-invitation-form.tsx index 67e6938d4a..ce98cfdc97 100644 --- a/ui/components/invitations/workflow/forms/send-invitation-form.tsx +++ b/ui/components/invitations/workflow/forms/send-invitation-form.tsx @@ -7,8 +7,7 @@ import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; import { sendInvite } from "@/actions/invitations/invitation"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form } from "@/components/shadcn/form"; import { diff --git a/ui/components/invitations/workflow/vertical-steps.tsx b/ui/components/invitations/workflow/vertical-steps.tsx index 17abec63d2..79fb77678c 100644 --- a/ui/components/invitations/workflow/vertical-steps.tsx +++ b/ui/components/invitations/workflow/vertical-steps.tsx @@ -2,19 +2,18 @@ import { useControlledState } from "@react-stately/utils"; import { domAnimation, LazyMotion, m } from "framer-motion"; -import type { ComponentProps } from "react"; -import React from "react"; +import { forwardRef, useMemo } from "react"; +import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export type VerticalStepProps = { className?: string; - description?: React.ReactNode; - title?: React.ReactNode; + description?: ReactNode; + title?: ReactNode; }; -export interface VerticalStepsProps - extends React.HTMLAttributes { +export interface VerticalStepsProps extends HTMLAttributes { /** * An array of steps. * @@ -89,10 +88,7 @@ function CheckIcon(props: ComponentProps<"svg">) { ); } -export const VerticalSteps = React.forwardRef< - HTMLButtonElement, - VerticalStepsProps ->( +export const VerticalSteps = forwardRef( ( { color = "primary", @@ -113,7 +109,7 @@ export const VerticalSteps = React.forwardRef< onStepChange, ); - const colors = React.useMemo(() => { + const colors = useMemo(() => { let userColor; let fgColor; diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx index 54dedad07c..d17432e78c 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx @@ -65,7 +65,7 @@ describe("AppSidebarContent", () => { it("shares the brand, Launch Scan action and Local Server Cloud affordances", async () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", "5.8.0"); const user = userEvent.setup(); @@ -91,7 +91,7 @@ describe("AppSidebarContent", () => { it("keeps the existing Lighthouse chat sidebar in Cloud Chat mode", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT }); // When @@ -109,7 +109,7 @@ describe("AppSidebarContent", () => { it("opens the current scan modal instead of navigating from the scans route", async () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); pathnameValue.current = "/scans"; const user = userEvent.setup(); diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.tsx index c32a7d5048..8d5e869793 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-content.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-content.tsx @@ -24,15 +24,20 @@ interface AppSidebarContentProps { export function AppSidebarContent({ onSelect }: AppSidebarContentProps) { const pathname = usePathname(); const { permissions } = useAuth(); - const { apiDocsUrl } = useRuntimeConfig(); + const { apiDocsUrl, cloudBillingEnabled } = useRuntimeConfig(); const mode = useAppSidebarMode((state) => state.mode); const isCloudEnvironment = isCloud(); - const sections = getNavigationConfig({ pathname, apiDocsUrl, permissions }); + const sections = getNavigationConfig({ + pathname, + apiDocsUrl, + cloudBillingEnabled, + permissions, + }); const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT; return (
-
+
{ it("groups the Local Server navigation without losing available features", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); // When const sections = getNavigationConfig({ @@ -71,7 +71,7 @@ describe("getNavigationConfig", () => { it("models Local Server Cloud features as contextual upgrade actions", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); // When const children = getConfigurationChildren(); @@ -108,7 +108,7 @@ describe("getNavigationConfig", () => { it("uses Cloud destinations and current New badges", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); // When const sections = getNavigationConfig({ @@ -148,7 +148,7 @@ describe("getNavigationConfig", () => { it("keeps the Cloud Billing destination for users with billing permission", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); const permissions = { manage_billing: true, } as RolePermissionAttributes; @@ -157,6 +157,7 @@ describe("getNavigationConfig", () => { const billing = getNavigationConfig({ pathname: "/billing", apiDocsUrl: null, + cloudBillingEnabled: true, permissions, }) .flatMap((section) => section.items) @@ -177,29 +178,40 @@ describe("getNavigationConfig", () => { const permissions = { manage_billing: false, } as RolePermissionAttributes; - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); // When const cloudItems = getNavigationConfig({ pathname: "/", apiDocsUrl: null, + cloudBillingEnabled: true, permissions, }).flatMap((section) => section.items); - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + const enterpriseItems = getNavigationConfig({ + pathname: "/", + apiDocsUrl: null, + cloudBillingEnabled: false, + permissions: { ...permissions, manage_billing: true }, + }).flatMap((section) => section.items); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); const localItems = getNavigationConfig({ pathname: "/", apiDocsUrl: null, + cloudBillingEnabled: true, permissions: { ...permissions, manage_billing: true }, }).flatMap((section) => section.items); // Then expect(cloudItems.find((item) => item.label === "Billing")).toBeUndefined(); + expect( + enterpriseItems.find((item) => item.label === "Billing"), + ).toBeUndefined(); expect(localItems.find((item) => item.label === "Billing")).toBeUndefined(); }); it("keeps environment-specific API documentation destinations", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); // When const localApiReference = getNavigationConfig({ @@ -209,7 +221,7 @@ describe("getNavigationConfig", () => { .flatMap((section) => section.items) .find((item) => item.label === "API Reference"); - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); const cloudApiReference = getNavigationConfig({ pathname: "/", apiDocsUrl: "https://ignored.example/docs", @@ -228,7 +240,7 @@ describe("getNavigationConfig", () => { it("omits the Local Server API reference when no URL is configured", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); // When const items = getNavigationConfig({ @@ -244,7 +256,7 @@ describe("getNavigationConfig", () => { it("filters navigation by required permission after visible copy changes", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); const sections = getNavigationConfig({ pathname: "/integrations", apiDocsUrl: null, @@ -282,7 +294,7 @@ describe("getNavigationConfig", () => { it("keeps navigation when the required permission is granted", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); const permissions = { manage_integrations: true, } as RolePermissionAttributes; @@ -308,7 +320,7 @@ describe("getNavigationConfig", () => { it("matches complete route segments without stealing nested settings routes", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); // When const scanDetails = getNavigationConfig({ diff --git a/ui/components/layout/app-sidebar/navigation-config.ts b/ui/components/layout/app-sidebar/navigation-config.ts index 40e15cb6e7..05ff31abae 100644 --- a/ui/components/layout/app-sidebar/navigation-config.ts +++ b/ui/components/layout/app-sidebar/navigation-config.ts @@ -31,6 +31,7 @@ import { interface NavigationConfigOptions { pathname: string; apiDocsUrl?: string | null; + cloudBillingEnabled?: boolean; permissions?: RolePermissionAttributes; } @@ -106,6 +107,7 @@ export function filterNavigationByPermissions( export function getNavigationConfig({ pathname, apiDocsUrl = null, + cloudBillingEnabled = false, permissions, }: NavigationConfigOptions): NavigationSection[] { const isCloudEnvironment = isCloud(); @@ -265,7 +267,7 @@ export function getNavigationConfig({ }, ], }, - ...(isCloudEnvironment + ...(isCloudEnvironment && cloudBillingEnabled ? [ { kind: NAVIGATION_ITEM_KIND.LINK, diff --git a/ui/components/layout/nav-bar/navbar-client.test.tsx b/ui/components/layout/nav-bar/navbar-client.test.tsx index c93ed3f6e5..5be780cc30 100644 --- a/ui/components/layout/nav-bar/navbar-client.test.tsx +++ b/ui/components/layout/nav-bar/navbar-client.test.tsx @@ -67,7 +67,7 @@ describe("NavbarClient", () => { navigationMocks.searchParams = new URLSearchParams(); window.localStorage.clear(); // Replay icon is Cloud-only. - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); // Default: the current route's content has loaded, so the icon is enabled. usePageReadyStore.setState({ readyPath: "/findings" }); useSidePanelStore.setState({ @@ -223,7 +223,7 @@ describe("NavbarClient", () => { }); it("hides the replay icon entirely in self-hosted (OSS) deployments", () => { - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); render( { it("hides the Lighthouse AI side-panel trigger in self-hosted (OSS) deployments", () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); // When render(); diff --git a/ui/components/lighthouse-v1/chat.tsx b/ui/components/lighthouse-v1/chat.tsx index 851762b8f5..72cf6eac28 100644 --- a/ui/components/lighthouse-v1/chat.tsx +++ b/ui/components/lighthouse-v1/chat.tsx @@ -34,8 +34,8 @@ import { CardHeader, CardTitle, Combobox, + useToast, } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { useMountEffect } from "@/hooks/use-mount-effect"; import type { LighthouseProvider } from "@/types/lighthouse-v1"; diff --git a/ui/components/lighthouse-v1/lighthouse-settings.tsx b/ui/components/lighthouse-v1/lighthouse-settings.tsx index 825786d4d6..14c534a278 100644 --- a/ui/components/lighthouse-v1/lighthouse-settings.tsx +++ b/ui/components/lighthouse-v1/lighthouse-settings.tsx @@ -17,8 +17,8 @@ import { CardContent, CardHeader, CardTitle, + useToast, } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; import { CustomTextarea } from "@/components/shadcn/custom"; import { Form } from "@/components/shadcn/form"; diff --git a/ui/components/manage-groups/forms/add-group-form.tsx b/ui/components/manage-groups/forms/add-group-form.tsx index 886d462e14..251b7b11d7 100644 --- a/ui/components/manage-groups/forms/add-group-form.tsx +++ b/ui/components/manage-groups/forms/add-group-form.tsx @@ -5,8 +5,7 @@ import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; import { createProviderGroup } from "@/actions/manage-groups"; -import { Button, Separator } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, Separator, useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form } from "@/components/shadcn/form"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; diff --git a/ui/components/manage-groups/forms/delete-group-form.tsx b/ui/components/manage-groups/forms/delete-group-form.tsx index 6a2a035875..c3f04fed94 100644 --- a/ui/components/manage-groups/forms/delete-group-form.tsx +++ b/ui/components/manage-groups/forms/delete-group-form.tsx @@ -8,8 +8,7 @@ import * as z from "zod"; import { deleteProviderGroup } from "@/actions/manage-groups/manage-groups"; import { DeleteIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Form } from "@/components/shadcn/form"; const formSchema = z.object({ diff --git a/ui/components/manage-groups/forms/edit-group-form.tsx b/ui/components/manage-groups/forms/edit-group-form.tsx index 7db75aef2b..997c92e370 100644 --- a/ui/components/manage-groups/forms/edit-group-form.tsx +++ b/ui/components/manage-groups/forms/edit-group-form.tsx @@ -7,8 +7,7 @@ import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; import { updateProviderGroup } from "@/actions/manage-groups/manage-groups"; -import { Button, Separator } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, Separator, useToast } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form } from "@/components/shadcn/form"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; diff --git a/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx b/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx index ca9f2cd106..e0f040f832 100644 --- a/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx +++ b/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx @@ -84,7 +84,7 @@ describe("OnboardingTrigger", () => { useDriverTourMock.mockClear(); capturedOnClosed = undefined; // Trigger only resolves in cloud. - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + vi.stubEnv("UI_CLOUD_ENABLED", "true"); searchParamsValue = new URLSearchParams(); sliceState = { active: false, @@ -196,7 +196,7 @@ describe("OnboardingTrigger", () => { describe("in self-hosted (OSS) deployments", () => { it("renders null and never starts the tour, even with a matching param", async () => { - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); searchParamsValue = new URLSearchParams("onboarding=add-provider"); const { container } = render( @@ -208,7 +208,7 @@ describe("OnboardingTrigger", () => { }); it("ignores an active sequence slice in OSS", async () => { - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); setSlice({ active: true, currentFlowId: "add-provider", diff --git a/ui/components/providers/forms/delete-form.tsx b/ui/components/providers/forms/delete-form.tsx index 1547e9f83d..45e8abde73 100644 --- a/ui/components/providers/forms/delete-form.tsx +++ b/ui/components/providers/forms/delete-form.tsx @@ -7,8 +7,7 @@ import * as z from "zod"; import { deleteProvider } from "@/actions/providers"; import { DeleteIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Form } from "@/components/shadcn/form"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; diff --git a/ui/components/providers/forms/delete-organization-form.tsx b/ui/components/providers/forms/delete-organization-form.tsx index 9d17e4251c..b7c90570ef 100644 --- a/ui/components/providers/forms/delete-organization-form.tsx +++ b/ui/components/providers/forms/delete-organization-form.tsx @@ -7,8 +7,7 @@ import { deleteOrganizationalUnit, } from "@/actions/organizations/organizations"; import { DeleteIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { PROVIDERS_GROUP_KIND, ProvidersGroupKind, diff --git a/ui/components/providers/forms/edit-name-form.tsx b/ui/components/providers/forms/edit-name-form.tsx index b6d937baf6..511744527c 100644 --- a/ui/components/providers/forms/edit-name-form.tsx +++ b/ui/components/providers/forms/edit-name-form.tsx @@ -4,8 +4,7 @@ import type { Dispatch, FormEvent, SetStateAction } from "react"; import { useState } from "react"; import { SaveIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Input } from "@/components/shadcn/input/input"; interface EditNameFormProps { diff --git a/ui/components/providers/organizations/aws-method-selector.test.tsx b/ui/components/providers/organizations/aws-method-selector.test.tsx index 9279e8dfb2..7ea1d9a95e 100644 --- a/ui/components/providers/organizations/aws-method-selector.test.tsx +++ b/ui/components/providers/organizations/aws-method-selector.test.tsx @@ -15,7 +15,7 @@ describe("AwsMethodSelector", () => { it("opens the AWS Organizations upgrade in Local Server", async () => { // Given - vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("UI_CLOUD_ENABLED", "false"); const user = userEvent.setup(); const onSelectOrganizations = vi.fn(); diff --git a/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts b/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts index a5332fdfd5..ffe3b0d089 100644 --- a/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts +++ b/ui/components/providers/organizations/hooks/use-org-account-selection-flow.ts @@ -26,6 +26,7 @@ import { pollConnectionTask, runWithConcurrencyLimit, } from "../org-account-selection.utils"; + import { extractErrorMessage } from "./error-utils"; interface SelectionState { diff --git a/ui/components/providers/organizations/org-setup-form.test.tsx b/ui/components/providers/organizations/org-setup-form.test.tsx index 329d6fe05c..151abfec33 100644 --- a/ui/components/providers/organizations/org-setup-form.test.tsx +++ b/ui/components/providers/organizations/org-setup-form.test.tsx @@ -115,7 +115,7 @@ describe("OrgSetupForm", () => { expect(params.get("param_DeployFromDelegatedAdmin")).toBe("true"); expect( - screen.getByLabelText("Delegated Administrator Account Role ARN"), + screen.getByLabelText("Delegated Administrator Account IAM Role ARN"), ).toBeInTheDocument(); }); }); diff --git a/ui/components/providers/organizations/org-setup-form.tsx b/ui/components/providers/organizations/org-setup-form.tsx index 35fb13c938..85903b76d2 100644 --- a/ui/components/providers/organizations/org-setup-form.tsx +++ b/ui/components/providers/organizations/org-setup-form.tsx @@ -41,7 +41,7 @@ const orgSetupSchema = z.object({ roleArn: z .string() .trim() - .min(1, "Role ARN is required") + .min(1, "IAM Role ARN is required") .regex( /^arn:aws:iam::\d{12}:role\//, "Must be a valid IAM Role ARN (e.g., arn:aws:iam::123456789012:role/ProwlerScan)", @@ -400,7 +400,7 @@ export function OrgSetupForm({

1) Choose the AWS Organizational Unit (or root) - to deploy to. Prowler creates the role in your deployment + to deploy to. Prowler creates the IAM Role in your deployment account and rolls it out to every member account under this target.

@@ -422,7 +422,7 @@ export function OrgSetupForm({ to the whole organization, or an OU ID (starts with ou-) to target a specific unit.

-
+
field.onChange(Boolean(checked)) @@ -438,10 +438,10 @@ export function OrgSetupForm({ /> )} @@ -454,12 +454,12 @@ export function OrgSetupForm({

2) Create the CloudFormation Stack in your{" "} {deploymentAccountName}. It deploys the - ProwlerScan role and a service-managed StackSet that rolls the - role out to your member accounts in one step. + ProwlerScan IAM Role and a service-managed StackSet that rolls + the IAM Role out to your member accounts in one step.

{orgQuickLink ? (