diff --git a/.env b/.env index c0364e29ae..a7a90108a6 100644 --- a/.env +++ b/.env @@ -6,14 +6,20 @@ PROWLER_UI_VERSION="stable" AUTH_URL=http://localhost:3000 API_BASE_URL=http://prowler-api:8080/api/v1 +# deprecated, use UI_API_BASE_URL NEXT_PUBLIC_API_BASE_URL=${API_BASE_URL} +UI_API_BASE_URL=${API_BASE_URL} +# deprecated, use UI_API_DOCS_URL NEXT_PUBLIC_API_DOCS_URL=http://prowler-api:8080/api/v1/docs +UI_API_DOCS_URL=http://prowler-api:8080/api/v1/docs AUTH_TRUST_HOST=true UI_PORT=3000 # openssl rand -base64 32 AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8=" -# Google Tag Manager ID +# Google Tag Manager ID (empty/unset ⇒ GTM not loaded, zero egress) +# deprecated, use UI_GOOGLE_TAG_MANAGER_ID NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID="" +UI_GOOGLE_TAG_MANAGER_ID="" #### MCP Server #### PROWLER_MCP_VERSION=stable @@ -139,13 +145,19 @@ DJANGO_BROKER_VISIBILITY_TIMEOUT=86400 DJANGO_SENTRY_DSN= DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute -# Sentry settings -SENTRY_ENVIRONMENT=local +# Sentry for the web app (server + browser). Empty/unset UI_SENTRY_DSN ⇒ +# Sentry disabled, zero egress. SENTRY_RELEASE (unprefixed) feeds the web app's +# server/edge SDKs. +UI_SENTRY_DSN= +UI_SENTRY_ENVIRONMENT=local SENTRY_RELEASE=local -NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} +# Reserved runtime public config (registered now; no UI consumer yet) +# POSTHOG_KEY= +# POSTHOG_HOST= +# REO_DEV_CLIENT_ID= #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.31.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.32.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3300394d83..ed97ff5a1a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,6 @@ # SDK /* @prowler-cloud/detection-remediation /prowler/ @prowler-cloud/detection-remediation -/prowler/compliance/ @prowler-cloud/compliance /tests/ @prowler-cloud/detection-remediation /dashboard/ @prowler-cloud/detection-remediation /docs/ @prowler-cloud/detection-remediation diff --git a/.github/labeler.yml b/.github/labeler.yml index 1b0dfd0400..b9abb1dfd0 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -77,6 +77,11 @@ provider/okta: - any-glob-to-any-file: "prowler/providers/okta/**" - any-glob-to-any-file: "tests/providers/okta/**" +provider/linode: + - changed-files: + - any-glob-to-any-file: "prowler/providers/linode/**" + - any-glob-to-any-file: "tests/providers/linode/**" + github_actions: - changed-files: - any-glob-to-any-file: ".github/workflows/*" diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 2952ebc2d5..4fc6cab0b7 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -590,6 +590,30 @@ jobs: flags: prowler-py${{ matrix.python-version }}-stackit files: ./stackit_coverage.xml + # Linode Provider + - name: Check if Linode files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-linode + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ./prowler/**/linode/** + ./tests/**/linode/** + ./uv.lock + + - name: Run Linode tests + if: steps.changed-linode.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/linode --cov-report=xml:linode_coverage.xml tests/providers/linode + + - name: Upload Linode coverage to Codecov + if: steps.changed-linode.outputs.any_changed == 'true' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-linode + files: ./linode_coverage.xml + # External Provider (dynamic loading) - name: Check if External Provider files changed if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/ui-container-build-push.yml b/.github/workflows/ui-container-build-push.yml index 984256af7f..3210f6e998 100644 --- a/.github/workflows/ui-container-build-push.yml +++ b/.github/workflows/ui-container-build-push.yml @@ -32,9 +32,6 @@ env: PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud PROWLERCLOUD_DOCKERHUB_IMAGE: prowler-ui - # Build args - NEXT_PUBLIC_API_BASE_URL: http://prowler-api:8080/api/v1 - permissions: {} jobs: @@ -146,7 +143,6 @@ jobs: context: ${{ env.WORKING_DIRECTORY }} build-args: | NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }} - NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }} push: true platforms: ${{ matrix.platform }} tags: | diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index ed407e890d..7165882d62 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -40,7 +40,8 @@ jobs: AUTH_SECRET: 'fallback-ci-secret-for-testing' AUTH_TRUST_HOST: true NEXTAUTH_URL: 'http://localhost:3000' - NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1' + AUTH_URL: 'http://localhost:3000' + UI_API_BASE_URL: 'http://localhost:8080/api/v1' E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }} E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }} @@ -165,7 +166,7 @@ jobs: timeout=150 elapsed=0 while [ $elapsed -lt $timeout ]; do - if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then + if curl -s ${UI_API_BASE_URL}/docs >/dev/null 2>&1; then echo "Prowler API is ready!" exit 0 fi diff --git a/Makefile b/Makefile index 8bdb9bf156..e2623a4bf3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,34 @@ .DEFAULT_GOAL:=help +DEV_LOCAL := ./scripts/development/dev-local.sh + +.PHONY: dev dev-setup dev-attach dev-launch dev-stop dev-clean dev-wipe dev-status + +##@ Local Development +dev: ## Start local API, worker, and database logs + $(DEV_LOCAL) all + +dev-setup: ## Bootstrap local dependencies, migrations, and fixtures + $(DEV_LOCAL) setup + +dev-attach: ## Attach to the local tmux development session + $(DEV_LOCAL) attach + +dev-launch: ## Start the local stack on fixed ports and attach + $(DEV_LOCAL) launch + +dev-stop: ## Stop the local tmux session and containers + $(DEV_LOCAL) kill + +dev-clean: ## Remove stopped local development containers + $(DEV_LOCAL) clean + +dev-wipe: ## Stop everything and delete local development data + $(DEV_LOCAL) wipe + +dev-status: ## Show local development container status + $(DEV_LOCAL) status + ##@ Testing test: ## Test with pytest rm -rf .coverage && \ diff --git a/README.md b/README.md index 74e91021ca..ef5ab910d4 100644 --- a/README.md +++ b/README.md @@ -104,23 +104,24 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Interface | |---|---|---|---|---|---|---| -| AWS | 600 | 84 | 44 | 18 | Official | UI, API, CLI | -| Azure | 167 | 22 | 19 | 16 | Official | UI, API, CLI | -| GCP | 102 | 18 | 17 | 12 | Official | UI, API, CLI | -| Kubernetes | 83 | 7 | 7 | 11 | Official | UI, API, CLI | +| AWS | 613 | 86 | 46 | 19 | Official | UI, API, CLI | +| Azure | 190 | 22 | 20 | 16 | Official | UI, API, CLI | +| GCP | 109 | 20 | 18 | 12 | Official | UI, API, CLI | +| Kubernetes | 90 | 7 | 7 | 11 | Official | UI, API, CLI | | GitHub | 24 | 3 | 1 | 5 | Official | UI, API, CLI | -| M365 | 102 | 10 | 4 | 10 | Official | UI, API, CLI | -| OCI | 51 | 14 | 4 | 10 | Official | UI, API, CLI | -| Alibaba Cloud | 63 | 9 | 4 | 9 | Official | UI, API, CLI | -| Cloudflare | 29 | 3 | 0 | 5 | Official | UI, API, CLI | +| M365 | 107 | 10 | 4 | 10 | Official | UI, API, CLI | +| OCI | 52 | 14 | 4 | 10 | Official | UI, API, CLI | +| Alibaba Cloud | 63 | 9 | 5 | 9 | Official | UI, API, CLI | +| Cloudflare | 29 | 3 | 1 | 5 | Official | UI, API, CLI | | IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI | | MongoDB Atlas | 10 | 3 | 0 | 8 | Official | UI, API, CLI | | LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI | | Image | N/A | N/A | N/A | N/A | Official | CLI, API | -| Google Workspace | 39 | 5 | 2 | 5 | Official | UI, API, CLI | +| Google Workspace | 65 | 11 | 2 | 6 | Official | UI, API, CLI | | OpenStack | 34 | 5 | 0 | 9 | Official | UI, API, CLI | | Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI | -| Okta | 1 | 1 | 0 | 1 | Official | CLI | +| Okta | 29 | 8 | 1 | 2 | Official | UI, API, CLI | +| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 0 | 4 | Unofficial | CLI | | Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI | | StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 0 | 3 | Unofficial | CLI | | NHN | 6 | 2 | 1 | 0 | Unofficial | CLI | diff --git a/api/.env.example b/api/.env.example index 0be5388790..f97d868890 100644 --- a/api/.env.example +++ b/api/.env.example @@ -24,6 +24,9 @@ DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute # Decide whether to allow Django manage database table partitions DJANGO_MANAGE_DB_PARTITIONS=[True|False] DJANGO_CELERY_DEADLOCK_ATTEMPTS=5 +# Optional: bound Celery's prefork pool size. Unset → Celery uses os.cpu_count(). +# Useful on Kubernetes nodes with many CPUs where unbounded prefork balloons memory. +# DJANGO_CELERY_WORKER_CONCURRENCY=4 DJANGO_BROKER_VISIBILITY_TIMEOUT=86400 DJANGO_SENTRY_DSN= diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index db5cc69b03..aebc333b2f 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,18 +2,25 @@ All notable changes to the **Prowler API** are documented in this file. -## [1.32.0] (Prowler UNRELEASED) +## [1.32.0] (Prowler v5.31.0) ### 🚀 Added - Provider group filters for API endpoints that support cloud provider filtering, including exact and `__in` variants [(#11573)](https://github.com/prowler-cloud/prowler/pull/11573) - Provider filters for `GET /api/v1/compliance-overviews`, `/metadata`, and `/requirements`, using latest completed scans per matching provider [(#11587)](https://github.com/prowler-cloud/prowler/pull/11587) - Server-Sent Events (SSE) infrastructure for the API: a base viewset, a tenant-aware channel manager, and channel-name helpers backed by `django-eventstream` over Valkey Pub/Sub and served through the Gunicorn ASGI worker, so feature endpoints can stream events to clients over a single long-lived connection [(#11556)](https://github.com/prowler-cloud/prowler/pull/11556) +- `DJANGO_CELERY_WORKER_CONCURRENCY` to configure Celery workers concurrency. Unset for default behaviour [(#11075)](https://github.com/prowler-cloud/prowler/pull/11075) ### 🔄 Changed - Gunicorn worker timeout raised from the 30s default to 120s, so long-running requests are no longer killed prematurely [(#11631)](https://github.com/prowler-cloud/prowler/pull/11631) - Sentry now drops ASGI's `RequestAborted` errors from health-check probe disconnects on `/health/live` [(#11632)](https://github.com/prowler-cloud/prowler/pull/11632) +- Gunicorn keep-alive timeout now exceeds the load balancer idle timeout, stopping 502s from reused connections [(#11647)](https://github.com/prowler-cloud/prowler/pull/11647) +- API runs under the Uvicorn worker so keep-alive outlives the load balancer idle timeout, fixing Gunicorn's intermittent 502s [(#11663)](https://github.com/prowler-cloud/prowler/pull/11663) + +### 🐞 Fixed + +- Database connections no longer leak under the ASGI worker, which previously exhausted the read replica's connection slots and caused 500s on read endpoints [(#11640)](https://github.com/prowler-cloud/prowler/pull/11640) ### 🔐 Security @@ -23,6 +30,14 @@ All notable changes to the **Prowler API** are documented in this file. --- +## [1.31.3] (Prowler v5.30.3) + +### 🔐 Security + +- SAML logins now link to an existing account only when the asserted email domain matches the ACS endpoint and the user is already a member of that domain's tenant, fixing a cross-tenant account takeover [(GHSA-h8m9-jgf8-vwvp)](https://github.com/prowler-cloud/prowler/security/advisories/GHSA-h8m9-jgf8-vwvp) + +--- + ## [1.31.2] (Prowler v5.30.2) ### 🔄 Changed diff --git a/api/README.md b/api/README.md index ea9661366f..c510479ab2 100644 --- a/api/README.md +++ b/api/README.md @@ -196,6 +196,42 @@ python -m celery -A config.celery worker -l info -E The Celery worker does not detect and reload changes in the code, so you need to restart it manually when you make changes. +### Makefile-Assisted Local Deployment + +This method is an additional local development workflow. It does not replace the manual local deployment or the Docker deployment described in this guide. + +PostgreSQL, Valkey, and Neo4j run with Docker Compose, while Django and the Celery worker run natively through `uv`. Additionally, this workflow creates a `tmux` session with panes for the API, worker, and PostgreSQL logs. + +Before using this method, ensure `docker compose`, `tmux`, and `uv` are installed. + +This workflow is designed for macOS and should also work on Linux when Docker, `tmux`, and `uv` are available. Windows requires script changes before it can be supported. + +From the repository root, run: + +```console +make dev +``` + +The API will be available at: + +```console +http://localhost:8080/api/v1 +``` + +Use these commands to manage the local stack: + +```console +make dev-setup # Bootstrap dependencies, migrations, and fixtures +make dev-attach # Attach to the tmux session +make dev-launch # Start the stack on fixed ports and attach +make dev-stop # Stop the tmux session and containers +make dev-clean # Remove stopped development containers +make dev-wipe # Stop everything and delete local development data +make dev-status # Show development container status +``` + +This workflow does not start the UI. Start it separately from the `ui/` directory when needed. + ### Docker deployment This method requires `docker` and `docker compose`. diff --git a/api/docs/orphan-task-recovery.md b/api/docs/orphan-task-recovery.md index a47b4f36a9..af4eefc1ac 100644 --- a/api/docs/orphan-task-recovery.md +++ b/api/docs/orphan-task-recovery.md @@ -65,6 +65,7 @@ All settings have safe defaults; override via environment variables. | Env var | Default | Purpose | | --- | --- | --- | | `DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER` | `1` | Tasks reserved per worker process. | +| `DJANGO_CELERY_WORKER_CONCURRENCY` | unset | Optional Celery prefork pool size. When unset, Celery uses its CPU-based default. Set this on worker containers to bound idle memory on hosts with many CPUs. | | `DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT` | `60` | Seconds the worker drains/re-queues on `SIGTERM` before force-kill. | | `DJANGO_CELERY_TASK_TIME_LIMIT` | `21600` (6h) | Hard limit for most tasks; connection checks are capped at 120s. | | `DJANGO_CELERY_TASK_SOFT_TIME_LIMIT` | hard - 600 | Soft limit; raises `SoftTimeLimitExceeded` for cleanup. | diff --git a/api/pyproject.toml b/api/pyproject.toml index 41014d3382..26f70ee3b9 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -62,7 +62,8 @@ dependencies = [ "gevent (==25.9.1)", "werkzeug (==3.1.7)", "sqlparse (==0.5.5)", - "fonttools (==4.62.1)" + "fonttools (==4.62.1)", + "uvicorn-worker (==0.4.0)", ] description = "Prowler's API (Django/DRF)" license = "Apache-2.0" @@ -70,7 +71,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.32.0" +version = "1.33.0" [tool.uv] # Transitive pins matching master to avoid silent drift; bump deliberately. @@ -422,6 +423,7 @@ constraint-dependencies = [ "uritemplate==4.2.0", "urllib3==2.7.0", "uuid6==2024.7.10", + "uvicorn==0.49.0", "uvloop==0.22.1", "vine==5.1.0", "vulture==2.14", diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py index e09dc972b4..1d0d2ace00 100644 --- a/api/src/backend/api/adapters.py +++ b/api/src/backend/api/adapters.py @@ -3,7 +3,14 @@ from django.db import transaction from api.db_router import MainRouter from api.db_utils import rls_transaction -from api.models import Membership, Role, Tenant, User, UserRoleRelationship +from api.models import ( + Membership, + Role, + SAMLConfiguration, + Tenant, + User, + UserRoleRelationship, +) class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): @@ -18,7 +25,42 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): # Link existing accounts with the same email address email = sociallogin.account.extra_data.get("email") if sociallogin.provider.id == "saml": + # For SAML, the asserted NameID email cannot be trusted on its own: + # any tenant can claim any email domain in its SAML configuration. To + # prevent cross-tenant account takeover (GHSA-h8m9-jgf8-vwvp), only link + # the incoming SAML session to an existing account when (1) the email + # domain matches the tenant whose ACS endpoint is being used and (2) the + # existing user is already a member of that tenant. email = sociallogin.user.email + if not email: + return + + domain = email.rsplit("@", 1)[-1].lower() + resolver_match = getattr(request, "resolver_match", None) + organization_slug = ( + (resolver_match.kwargs or {}).get("organization_slug", "") + if resolver_match + else "" + ).lower() + # The ACS endpoint is scoped per email domain; reject mismatches so an + # attacker cannot replay an assertion through another tenant's endpoint. + if organization_slug != domain: + return + + try: + saml_config = SAMLConfiguration.objects.using(MainRouter.admin_db).get( + email_domain=domain + ) + except SAMLConfiguration.DoesNotExist: + return + + existing_user = self.get_user_by_email(email) + if existing_user and existing_user.is_member_of_tenant( + str(saml_config.tenant_id) + ): + sociallogin.connect(request, existing_user) + return + if email: existing_user = self.get_user_by_email(email) if existing_user: diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 202825185b..77c45cbffd 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -112,14 +112,14 @@ def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[s """List compliance framework identifiers available for `provider_type`. Includes both per-provider frameworks and universal top-level frameworks - (e.g. ``dora``, ``csa_ccm_4.0``). + (e.g. ``dora_2022_2554``, ``csa_ccm_4.0``). Args: provider_type (Provider.ProviderChoices): The cloud provider type (e.g., "aws", "azure", "gcp", "m365"). Returns: - list[str]: Framework identifiers (e.g., "cis_1.4_aws", "dora"). + list[str]: Framework identifiers (e.g., "cis_1.4_aws", "dora_2022_2554"). """ global AVAILABLE_COMPLIANCE_FRAMEWORKS if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS: diff --git a/api/src/backend/api/middleware.py b/api/src/backend/api/middleware.py index 63f2fc630b..2b0a2340c4 100644 --- a/api/src/backend/api/middleware.py +++ b/api/src/backend/api/middleware.py @@ -1,9 +1,35 @@ import logging import time +from django.core.handlers.asgi import ASGIRequest +from django.db import connections + from config.custom_logging import BackendLogger +class CloseDBConnectionsMiddleware: + """ + Close request-scoped DB connections at the end of each ASGI request. + + Under the ASGI worker, connections opened by sync views are not released + by Django's normal request-boundary cleanup, so they accumulate idle until + Postgres runs out of slots. Only ASGI requests are handled; the sync WSGI + test client manages its own connections and must be left alone. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + try: + return self.get_response(request) + finally: + if isinstance(request, ASGIRequest): + for conn in connections.all(initialized_only=True): + if not conn.in_atomic_block: + conn.close_if_unusable_or_obsolete() + + def extract_auth_info(request) -> dict: if getattr(request, "auth", None) is not None: tenant_id = request.auth.get("tenant_id", "N/A") diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 18566c5c71..767a3aaf78 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.32.0 + version: 1.33.0 description: |- Prowler API specification. diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py index 22b44b3506..182e4ddc25 100644 --- a/api/src/backend/api/tests/test_adapters.py +++ b/api/src/backend/api/tests/test_adapters.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -5,9 +6,48 @@ from allauth.socialaccount.models import SocialLogin from django.contrib.auth import get_user_model from api.adapters import ProwlerSocialAccountAdapter +from api.db_router import MainRouter +from api.models import SAMLConfiguration User = get_user_model() +# Minimal, well-formed IdP metadata accepted by SAMLConfiguration._parse_metadata. +VALID_METADATA = """ + + + + + + FAKECERTDATA + + + + + + +""" + + +def _saml_request(rf, organization_slug): + """Build an ACS request whose resolver_match carries the organization slug, + mirroring how Django populates it after routing the SAML ACS URL.""" + request = rf.post(f"/api/v1/accounts/saml/{organization_slug}/acs/finish/") + request.resolver_match = SimpleNamespace( + kwargs={"organization_slug": organization_slug} + ) + return request + + +def _saml_sociallogin(user): + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.account = MagicMock() + sociallogin.provider = MagicMock() + sociallogin.provider.id = "saml" + sociallogin.account.extra_data = {} + sociallogin.user = user + sociallogin.connect = MagicMock() + return sociallogin + @pytest.mark.django_db class TestProwlerSocialAccountAdapter: @@ -20,26 +60,99 @@ class TestProwlerSocialAccountAdapter: adapter = ProwlerSocialAccountAdapter() assert adapter.get_user_by_email("notfound@example.com") is None - def test_pre_social_login_links_existing_user(self, create_test_user, rf): + def test_pre_social_login_links_member_of_saml_tenant( + self, create_test_user, tenants_fixture, rf + ): + """A SAML login links to an existing account only when that user is + already a member of the tenant that owns the asserted email domain.""" adapter = ProwlerSocialAccountAdapter() + # create_test_user (dev@prowler.com) is a member of tenant1. + domain = create_test_user.email.rsplit("@", 1)[-1] + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=domain, + metadata_xml=VALID_METADATA, + tenant=tenants_fixture[0], + ) - sociallogin = MagicMock(spec=SocialLogin) - sociallogin.account = MagicMock() - sociallogin.provider = MagicMock() - sociallogin.provider.id = "saml" - sociallogin.account.extra_data = {} - sociallogin.user = create_test_user - sociallogin.connect = MagicMock() - - adapter.pre_social_login(rf.get("/"), sociallogin) + sociallogin = _saml_sociallogin(create_test_user) + adapter.pre_social_login(_saml_request(rf, domain), sociallogin) call_args = sociallogin.connect.call_args assert call_args is not None - - called_request, called_user = call_args[0] - assert called_request.path == "/" + _, called_user = call_args[0] assert called_user.email == create_test_user.email + def test_pre_social_login_blocks_cross_tenant_takeover( + self, create_test_user, tenants_fixture, rf + ): + """GHSA-h8m9-jgf8-vwvp: an attacker tenant that claims the victim's + email domain must NOT be able to link to the victim's account, because + the victim is not a member of the attacker's tenant.""" + adapter = ProwlerSocialAccountAdapter() + domain = create_test_user.email.rsplit("@", 1)[-1] + # tenant3 is the attacker tenant; create_test_user is NOT a member of it. + attacker_tenant = tenants_fixture[2] + assert not create_test_user.is_member_of_tenant(str(attacker_tenant.id)) + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=domain, + metadata_xml=VALID_METADATA, + tenant=attacker_tenant, + ) + + sociallogin = _saml_sociallogin(create_test_user) + adapter.pre_social_login(_saml_request(rf, domain), sociallogin) + + sociallogin.connect.assert_not_called() + + def test_pre_social_login_blocks_domain_slug_mismatch( + self, create_test_user, tenants_fixture, rf + ): + """The asserted email domain must match the ACS endpoint's slug, so an + assertion cannot be replayed through a different tenant's endpoint.""" + adapter = ProwlerSocialAccountAdapter() + domain = create_test_user.email.rsplit("@", 1)[-1] + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=domain, + metadata_xml=VALID_METADATA, + tenant=tenants_fixture[0], + ) + + sociallogin = _saml_sociallogin(create_test_user) + # Slug points at a different domain than the asserted email. + adapter.pre_social_login(_saml_request(rf, "attacker.com"), sociallogin) + + sociallogin.connect.assert_not_called() + + def test_pre_social_login_blocks_when_no_saml_config( + self, create_test_user, tenants_fixture, rf + ): + """No SAML configuration for the domain means nothing to link against.""" + adapter = ProwlerSocialAccountAdapter() + domain = create_test_user.email.rsplit("@", 1)[-1] + + sociallogin = _saml_sociallogin(create_test_user) + adapter.pre_social_login(_saml_request(rf, domain), sociallogin) + + sociallogin.connect.assert_not_called() + + def test_pre_social_login_blocks_without_resolver_match( + self, create_test_user, tenants_fixture, rf + ): + """Fail closed: if the request has no resolver_match we cannot bind the + assertion to a tenant, so no linking happens.""" + adapter = ProwlerSocialAccountAdapter() + domain = create_test_user.email.rsplit("@", 1)[-1] + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=domain, + metadata_xml=VALID_METADATA, + tenant=tenants_fixture[0], + ) + + sociallogin = _saml_sociallogin(create_test_user) + adapter.pre_social_login(rf.post("/"), sociallogin) + + sociallogin.connect.assert_not_called() + def test_pre_social_login_no_link_if_email_missing(self, rf): adapter = ProwlerSocialAccountAdapter() @@ -47,14 +160,35 @@ class TestProwlerSocialAccountAdapter: sociallogin.account = MagicMock() sociallogin.provider = MagicMock() sociallogin.user = MagicMock() + sociallogin.user.email = "" sociallogin.provider.id = "saml" sociallogin.account.extra_data = {} sociallogin.connect = MagicMock() - adapter.pre_social_login(rf.get("/"), sociallogin) + adapter.pre_social_login(_saml_request(rf, "prowler.com"), sociallogin) sociallogin.connect.assert_not_called() + def test_pre_social_login_non_saml_links_by_email(self, create_test_user, rf): + """Non-SAML providers (e.g. Google/GitHub) still link to an existing + local account by email; the tenant binding only applies to SAML.""" + adapter = ProwlerSocialAccountAdapter() + + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.account = MagicMock() + sociallogin.provider = MagicMock() + sociallogin.provider.id = "google" + sociallogin.account.extra_data = {"email": create_test_user.email} + sociallogin.user = create_test_user + sociallogin.connect = MagicMock() + + adapter.pre_social_login(rf.get("/"), sociallogin) + + call_args = sociallogin.connect.call_args + assert call_args is not None + _, called_user = call_args[0] + assert called_user.email == create_test_user.email + def test_save_user_saml_sets_session_flag(self, rf): adapter = ProwlerSocialAccountAdapter() request = rf.get("/") diff --git a/api/src/backend/api/tests/test_celery_settings.py b/api/src/backend/api/tests/test_celery_settings.py index d4010796ee..dcd8930edc 100644 --- a/api/src/backend/api/tests/test_celery_settings.py +++ b/api/src/backend/api/tests/test_celery_settings.py @@ -41,3 +41,30 @@ class TestBuildCeleryBrokerUrl: def test_invalid_scheme_raises_error(self): with pytest.raises(ValueError, match="Invalid VALKEY_SCHEME 'http'"): _build_celery_broker_url("http", "", "", "valkey", "6379", "0") + + +class TestCeleryWorkerConcurrency: + def _reimport_settings(self): + """Fresh import — importlib.reload() doesn't clear the module namespace, + so an attribute set by a prior test would leak into the unset case.""" + import sys + + sys.modules.pop("config.settings.celery", None) + import config.settings.celery as celery_settings + + return celery_settings + + def test_unset_leaves_setting_absent(self, monkeypatch): + monkeypatch.delenv("DJANGO_CELERY_WORKER_CONCURRENCY", raising=False) + mod = self._reimport_settings() + assert not hasattr(mod, "CELERY_WORKER_CONCURRENCY") + + def test_explicit_value_applied(self, monkeypatch): + monkeypatch.setenv("DJANGO_CELERY_WORKER_CONCURRENCY", "8") + mod = self._reimport_settings() + assert mod.CELERY_WORKER_CONCURRENCY == 8 + + def test_invalid_value_raises(self, monkeypatch): + monkeypatch.setenv("DJANGO_CELERY_WORKER_CONCURRENCY", "not-a-number") + with pytest.raises(ValueError): + self._reimport_settings() diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 35098c1907..613cef4755 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -13586,7 +13586,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -13606,18 +13608,23 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace( tenant_id=tenants_fixture[0].id ) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenants_fixture[0] + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -13665,6 +13672,81 @@ class TestTenantFinishACSView: user.company_name = original_company user.save() + def test_dispatch_rejects_assertion_email_domain_that_differs_from_slug( + self, tenants_fixture, saml_setup, monkeypatch + ): + monkeypatch.setenv("AUTH_URL", "http://localhost") + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + victim_tenant = tenants_fixture[0] + attacker_tenant = tenants_fixture[1] + attacker_domain = "attacker.com" + + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=attacker_domain, + metadata_xml=""" + + + + + + TEST + + + + + + + """, + tenant=attacker_tenant, + ) + user = User.objects.using(MainRouter.admin_db).create( + email=f"intruder@{saml_setup['domain']}", name="Intruder" + ) + social_account = SocialAccount( + user=user, + provider="ATTACKER", + extra_data={ + "firstName": ["Mallory"], + "lastName": ["Example"], + }, + ) + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": attacker_domain}) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", + provider_id="ATTACKER", + client_id=attacker_domain, + name="Attacker App", + settings={}, + ) + mock_sa_get.return_value = social_account + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug=attacker_domain) + + assert response.status_code == 302 + assert "sso_saml_failed=true" in response.url + assert not ( + Membership.objects.using(MainRouter.admin_db) + .filter(user=user, tenant=victim_tenant) + .exists() + ) + assert ( + not SAMLToken.objects.using(MainRouter.admin_db).filter(user=user).exists() + ) + def test_rollback_saml_user_when_error_occurs(self, users_fixture, monkeypatch): """Test that a user is properly deleted when created during SAML flow and an error occurs""" monkeypatch.setenv("AUTH_URL", "http://localhost") @@ -13734,7 +13816,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -13754,16 +13838,21 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -13802,7 +13891,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -13822,16 +13913,21 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -13881,7 +13977,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -13901,16 +13999,21 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -13959,7 +14062,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -13979,16 +14084,21 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -14043,7 +14153,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -14063,16 +14175,21 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -14126,7 +14243,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = non_admin_user request.session = {} @@ -14146,16 +14265,21 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = non_admin_user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 7e9aae5b94..6b2616b289 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -767,7 +767,10 @@ class TenantFinishACSView(FinishACSView): try: check = SAMLDomainIndex.objects.get(email_domain=organization_slug) with rls_transaction(str(check.tenant_id)): - SAMLConfiguration.objects.get(tenant_id=str(check.tenant_id)) + saml_config = SAMLConfiguration.objects.select_related("tenant").get( + tenant_id=str(check.tenant_id) + ) + tenant = saml_config.tenant social_app = SocialApp.objects.get( provider="saml", client_id=organization_slug ) @@ -787,6 +790,15 @@ class TenantFinishACSView(FinishACSView): callback_url = env.str("AUTH_URL") return redirect(f"{callback_url}?sso_saml_failed=true") + requested_domain = organization_slug.lower() + configured_domain = saml_config.email_domain.lower() + email_domain = user.email.rsplit("@", 1)[-1].lower() + if configured_domain != requested_domain or email_domain != configured_domain: + logger.error("SAML email domain does not match requested organization") + self._rollback_saml_user(request) + callback_url = env.str("AUTH_URL") + return redirect(f"{callback_url}?sso_saml_failed=true") + extra = social_account.extra_data user.first_name = ( extra.get("firstName", [""])[0] if extra.get("firstName") else "" @@ -800,13 +812,6 @@ class TenantFinishACSView(FinishACSView): user.name = "N/A" user.save() - email_domain = user.email.split("@")[-1] - tenant = ( - SAMLConfiguration.objects.using(MainRouter.admin_db) - .get(email_domain=email_domain) - .tenant - ) - # Only remap roles when the IdP provides a userType attribute. # Without it, the user's current roles are left untouched. role_name = ( @@ -1884,7 +1889,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): description=( "Download a specific compliance report as an OCSF JSON file. " "Only universal frameworks that declare an output configuration " - "produce this artifact (currently 'dora' and 'csa_ccm_4.0'); any " + "produce this artifact (currently 'dora_2022_2554' and 'csa_ccm_4.0'); any " "other framework returns 404." ), parameters=[ @@ -1893,7 +1898,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): type=str, location=OpenApiParameter.PATH, required=True, - description="The compliance report name, like 'dora'", + description="The compliance report name, like 'dora_2022_2554'", ), ], responses={ diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 3308561b48..31a9537b5f 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -49,6 +49,7 @@ INSTALLED_APPS = [ ] MIDDLEWARE = [ + "api.middleware.CloseDBConnectionsMiddleware", "django_guid.middleware.guid_middleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", diff --git a/api/src/backend/config/guniconf.py b/api/src/backend/config/guniconf.py index 63472feec8..6f85a9e1bc 100644 --- a/api/src/backend/config/guniconf.py +++ b/api/src/backend/config/guniconf.py @@ -3,6 +3,8 @@ import multiprocessing import os import threading +from uvicorn_worker import UvicornWorker + from config.env import env # Ensure the environment variable for Django settings is set @@ -12,6 +14,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.production") import django # noqa: E402 django.setup() + from api.compliance import warm_compliance_caches # noqa: E402 from config.django.production import LOGGING as DJANGO_LOGGERS, DEBUG # noqa: E402 from config.custom_logging import BackendLogger # noqa: E402 @@ -19,34 +22,28 @@ from config.custom_logging import BackendLogger # noqa: E402 BIND_ADDRESS = env("DJANGO_BIND_ADDRESS", default="127.0.0.1") PORT = env("DJANGO_PORT", default=8080) + +class ProwlerUvicornWorker(UvicornWorker): + CONFIG_KWARGS = { + # Keep-alive idle timeout. Must exceed the load balancer idle timeout. + "timeout_keep_alive": env.int("GUNICORN_KEEPALIVE", default=75), + "loop": "uvloop", + "lifespan": "off", # Django ASGIHandler doesn't handle lifespan scopes + } + + +# Required so SSE endpoints can keep the event loop alive while waiting for events +worker_class = env( + "DJANGO_WORKER_CLASS", + default="config.guniconf.ProwlerUvicornWorker", +) + # Server settings bind = f"{BIND_ADDRESS}:{PORT}" workers = env.int("DJANGO_WORKERS", default=multiprocessing.cpu_count() * 2 + 1) reload = DEBUG -# Native ASGI worker (gunicorn 24+). Required so SSE endpoints can keep the -# event loop alive while waiting for events. -worker_class = env("DJANGO_WORKER_CLASS", default="asgi") - -# Lifespan protocol. Django's ASGIHandler (config.asgi:application) serves only -# HTTP scopes and raises "Django can only handle ASGI/HTTP connections, not -# lifespan." gunicorn's default ("auto") probes the app with a lifespan scope -# to detect support, which triggers that error. We use no lifespan startup or -# shutdown hooks, so disable the protocol entirely. -asgi_lifespan = env("DJANGO_ASGI_LIFESPAN", default="off") - -# Event loop for the ASGI worker. "auto" uses uvloop when it is installed and -# falls back to the stdlib asyncio loop otherwise; uvloop gives the SSE event -# loop more headroom under many concurrent open streams. -asgi_loop = env("DJANGO_ASGI_LOOP", default="uvloop") - -# Max concurrent connections per ASGI worker. Each open SSE stream holds one -# connection for its whole lifetime, so this caps simultaneous SSE clients per -# worker (gunicorn's default is 1000). The sync-only `threads` option has no -# effect on ASGI workers. -worker_connections = env.int("DJANGO_WORKER_CONNECTIONS", default=1000) - # Preload the application before forking workers in production: the app is # imported once in the master and workers fork from it. In development, disable # preload so the server restarts on code changes. diff --git a/api/src/backend/config/settings/celery.py b/api/src/backend/config/settings/celery.py index b7030ebea4..b7105a548c 100644 --- a/api/src/backend/config/settings/celery.py +++ b/api/src/backend/config/settings/celery.py @@ -53,3 +53,8 @@ CELERY_TASK_TRACK_STARTED = True CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True CELERY_DEADLOCK_ATTEMPTS = env.int("DJANGO_CELERY_DEADLOCK_ATTEMPTS", default=5) + +# Opt-in override for Celery's prefork pool size. When unset, Celery falls back +# to its default (os.cpu_count()). +if "DJANGO_CELERY_WORKER_CONCURRENCY" in env.ENVIRON: + CELERY_WORKER_CONCURRENCY = env.int("DJANGO_CELERY_WORKER_CONCURRENCY") diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index e617339973..e1e7100ae6 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -560,7 +560,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): # Per-framework exporters in `COMPLIANCE_CLASS_MAP` consume the legacy bulk. frameworks_bulk = Compliance.get_bulk(provider_type) - # Universal-only frameworks (top-level JSONs like `dora.json`) are emitted + # Universal-only frameworks (top-level JSONs like `dora_2022_2554.json`) are emitted # via `process_universal_compliance_frameworks` below. universal_bulk = get_prowler_provider_compliance(provider_type) universal_only_names = { @@ -650,7 +650,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): writer.batch_write_data_to_file(**extra) writer._data.clear() - # Universal-only frameworks (e.g. `dora.json`). + # Universal-only frameworks (e.g. `dora_2022_2554.json`). if universal_only_names: process_universal_compliance_frameworks( input_compliance_frameworks=universal_only_names, diff --git a/api/uv.lock b/api/uv.lock index f96318c1f4..38c3b4de74 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -357,6 +357,7 @@ constraints = [ { name = "uritemplate", specifier = "==4.2.0" }, { name = "urllib3", specifier = "==2.7.0" }, { name = "uuid6", specifier = "==2024.7.10" }, + { name = "uvicorn", specifier = "==0.49.0" }, { name = "uvloop", specifier = "==0.22.1" }, { name = "vine", specifier = "==5.1.0" }, { name = "vulture", specifier = "==2.14" }, @@ -4535,7 +4536,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.32.0" +version = "1.33.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, @@ -4575,6 +4576,7 @@ dependencies = [ { name = "sentry-sdk", extra = ["django"] }, { name = "sqlparse" }, { name = "uuid6" }, + { name = "uvicorn-worker" }, { name = "uvloop" }, { name = "werkzeug" }, { name = "xmlsec" }, @@ -4641,6 +4643,7 @@ requires-dist = [ { name = "sentry-sdk", extras = ["django"], specifier = "==2.56.0" }, { name = "sqlparse", specifier = "==0.5.5" }, { name = "uuid6", specifier = "==2024.7.10" }, + { name = "uvicorn-worker", specifier = "==0.4.0" }, { name = "uvloop", specifier = "==0.22.1" }, { name = "werkzeug", specifier = "==3.1.7" }, { name = "xmlsec", specifier = "==1.3.17" }, @@ -5846,6 +5849,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/3e/4ae6af487ce5781ed71d5fe10aca72e7cbc4d4f45afc31b120287082a8dd/uuid6-2024.7.10-py3-none-any.whl", hash = "sha256:93432c00ba403751f722829ad21759ff9db051dea140bf81493271e8e4dd18b7", size = 6376, upload-time = "2024-07-10T16:39:36.148Z" }, ] +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[[package]] +name = "uvicorn-worker" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gunicorn" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/25/09cd7a90c8bb7fb693be0d6704fccd5f9778d5513214b7a01cc4a94ff314/uvicorn_worker-0.4.0-py3-none-any.whl", hash = "sha256:e2ed952cef976f5e9e429d7269640bbcafbd36c80aa80f1003c8c77a6797abde", size = 5364, upload-time = "2025-09-20T10:46:59.776Z" }, +] + [[package]] name = "uvloop" version = "0.22.1" diff --git a/contrib/k8s/helm/prowler-api/values.yaml b/contrib/k8s/helm/prowler-api/values.yaml index 0395c6f704..a6074c7852 100644 --- a/contrib/k8s/helm/prowler-api/values.yaml +++ b/contrib/k8s/helm/prowler-api/values.yaml @@ -438,6 +438,34 @@ mainConfig: # Minimum number of Availability Zones that an ELBv2 must be in elbv2_min_azs: 2 + # AWS Post-Quantum TLS Configuration + # aws.acmpca_certificate_authority_pqc_key_algorithm + acmpca_pqc_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" + # aws.cloudfront_distributions_pqc_tls_enabled + cloudfront_pqc_min_protocol_versions: + - "TLSv1.3_2025" + # aws.apigateway_domain_name_pqc_tls_enabled + apigateway_pqc_tls_allowed_policies: + - "SecurityPolicy_TLS13_1_2_FIPS_PFS_PQ_2025_09" + - "SecurityPolicy_TLS13_1_2_PFS_PQ_2025_09" + - "SecurityPolicy_TLS13_1_2_PQ_2025_09" + + # AWS Post-Quantum SSH Key Exchange Configuration + # aws.transfer_server_pqc_ssh_kex_enabled + transfer_pqc_ssh_allowed_policies: + - "TransferSecurityPolicy-2025-03" + - "TransferSecurityPolicy-FIPS-2025-03" + - "TransferSecurityPolicy-AS2Restricted-2025-07" + + + # aws.rolesanywhere_trust_anchor_pqc_pki + rolesanywhere_pqc_pca_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" # AWS Secrets Configuration # Patterns to ignore in the secrets checks diff --git a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml index 38d6e65ee3..856770722a 100644 --- a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml +++ b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml @@ -11,8 +11,7 @@ data: {{- else }} AUTH_URL: {{ .Values.ui.authUrl | quote }} {{- end }} - API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" - NEXT_PUBLIC_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" - NEXT_PUBLIC_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs" + UI_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" + UI_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs" AUTH_TRUST_HOST: "true" UI_PORT: {{ .Values.ui.service.port | quote }} diff --git a/contrib/k8s/helm/prowler-ui/values.yaml b/contrib/k8s/helm/prowler-ui/values.yaml index d4f2dbe137..aca0178b3a 100644 --- a/contrib/k8s/helm/prowler-ui/values.yaml +++ b/contrib/k8s/helm/prowler-ui/values.yaml @@ -21,8 +21,8 @@ fullnameOverride: "" secrets: SITE_URL: http://localhost:3000 - API_BASE_URL: http://prowler-api:8080/api/v1 - NEXT_PUBLIC_API_DOCS_URL: http://prowler-api:8080/api/v1/docs + UI_API_BASE_URL: http://prowler-api:8080/api/v1 + UI_API_DOCS_URL: http://prowler-api:8080/api/v1/docs AUTH_TRUST_HOST: True UI_PORT: 3000 # openssl rand -base64 32 diff --git a/docs/developer-guide/configurable-checks.mdx b/docs/developer-guide/configurable-checks.mdx index 76b339601d..760dc80f58 100644 --- a/docs/developer-guide/configurable-checks.mdx +++ b/docs/developer-guide/configurable-checks.mdx @@ -40,9 +40,181 @@ When adding a new configurable check to Prowler, update the following files: # aws.awslambda_function_vpc_multi_az lambda_min_azs: 2 ``` +- **Provider Schema:** Add the typed field to the provider's Pydantic schema in `prowler/config/schema/.py`. This is required: the loader validates user configs against these schemas and the shipped `config.yaml` must round-trip with zero warnings. See [Adding a Parameter to the Provider Schema](#adding-a-parameter-to-the-provider-schema) below. - **Test Fixtures:** If tests depend on this configuration, add the variable to `tests/config/fixtures/config.yaml`. - **Documentation:** Document the new variable in the list of configurable checks in `docs/tutorials/configuration_file.md`. For a complete list of checks that already support configuration, see the [Configuration File Tutorial](/user-guide/cli/tutorials/configuration_file). +## Adding a Parameter to the Provider Schema + +Most providers have a typed Pydantic schema in `prowler/config/schema/`, registered in `prowler/config/schema/registry.py`. When a config is loaded and the provider has a registered schema, `validate_provider_config` checks each user-supplied key against it, logs a warning, and drops any field that fails validation. The consumer's `.get(key, default)` then falls back to the built-in default. Providers without a registered schema are passed through unchanged. + +This catches typos in a value (for example, `0.2` typed as `20`, or `"medium"` for an enum that expects `"MEDIUM"`). It does NOT catch typos in a key name: `disalowed_regions` (one `l` missing) is treated as an unknown key and passes through untouched, because third-party check plugins legitimately rely on unknown keys being preserved. Reviewers should still check that any new key the YAML adds is named exactly the same as the field on the schema. + +### Where to Add the Field + +1. Open `prowler/config/schema/.py` (for example, `aws.py`). +2. Add a field on the provider's schema class. Always make it `Optional[...] = None` so the absence of the key is valid. +3. Apply the tightest type the value allows. Examples below. + +If you are introducing an entirely new provider rather than a new parameter, also add an entry mapping the provider name to its schema class in `prowler/config/schema/registry.py`. The loader uses that registry to find the schema for the provider it is loading. + +### Choosing the Right Type + +| Value kind | Field declaration | +|---|---| +| Boolean toggle | `Optional[bool] = None` | +| Strictly positive integer (days, counts) | `Optional[int] = Field(default=None, gt=0)` | +| Fraction in 0..1 (threshold) | `Optional[float] = Field(default=None, ge=0.0, le=1.0)` | +| Closed set of strings | `Optional[Literal["A", "B", "C"]] = None` | +| Free-form string | `Optional[str] = None` | +| List of strings or ints | `Optional[list[str]] = None` | + +Prefer `Literal[...]` over `str` whenever the value is one of a known set. Prefer `Field(gt=0)` over `int` whenever zero or negative would be nonsensical. The point of the schema is to catch real-world mistakes that previously passed silently. + +### Custom Validators (Only When Needed) + +If the value has structural rules beyond type and range, add a `field_validator`. Examples already in `aws.py`: + +- `_validate_port_range` rejects ports outside `0..65535`. +- `_validate_account_ids` rejects anything that isn't a 12-digit AWS account ID. +- `_validate_trusted_ips` rejects entries that aren't a valid IP or CIDR. + +Raise `ValueError` from the validator. The framework converts the error into a warning and drops the offending key. + +### Example: Adding a New Parameter + +Say a new check needs `max_iam_role_session_hours`, a strictly positive integer that defaults to 12 in code. + +1. **Schema** (`prowler/config/schema/aws.py`): + ```python + # IAM + max_iam_role_session_hours: Optional[int] = Field(default=None, gt=0) + ``` +2. **Shipped config** (`prowler/config/config.yaml`): + ```yaml + # aws.iam_role_session_duration_within_limit + max_iam_role_session_hours: 12 + ``` +3. **Consumer** (the check): + ```python + max_hours = iam_client.audit_config.get("max_iam_role_session_hours", 12) + ``` +4. **Tests** in `tests/config/schema/aws_schema_test.py`: + - one test for a valid value that round-trips, + - one test for an invalid value (zero, negative, wrong type) that is dropped. + +### What the Loader Guarantees + +- **Unknown keys pass through.** Third-party check plugins can introduce arbitrary keys without schema edits; they will not be filtered. +- **Invalid values never crash the run.** They produce a single warning per field and the key is dropped. +- **Coerced values are normalized.** A YAML-quoted `"180"` for an `int` field arrives downstream as the integer `180`. +- **The shipped `config.yaml` must round-trip cleanly.** The integration test `test_shipped_default_config_loads_without_warnings` will fail if a key is added to the YAML without a matching schema field, so the two stay in sync. + +## Configuration Value Limits + +Configurable thresholds enforce hard limits. A value outside the documented range is **dropped with a warning** and the check falls back to its built-in default (the same as if the key were absent). These bounds are intentionally conservative: they are not the absolute service maxima but the range that still produces a meaningful security check. + +Use this section as the reference when upgrading an existing config: if a value you set is being rejected, it is outside the range below. + +Only fields with a numeric range, a fixed value set, or a length cap are listed. Fields typed as free-form strings or lists (for example `disallowed_regions`, `secrets_ignore_patterns`, `trusted_account_ids`) have no range limit — they are validated for shape only (a 12-digit account ID, a valid IP/CIDR, a dotted version string), not for magnitude. + +### AWS + +| Key | Allowed range | Notes | +|---|---|---| +| `max_unused_access_keys_days` | `30..180` days | CIS AWS 1.13 recommends 45; NIST IA-5 ≤90 | +| `max_console_access_days` | `30..180` days | CIS AWS 1.12 recommends 45 | +| `max_unused_sagemaker_access_days` | `7..180` days | | +| `max_security_group_rules` | `1..1000` | AWS hard limit is 1000 rules per security group | +| `max_ec2_instance_age_in_days` | `1..1095` days | 3 years | +| `ec2_high_risk_ports` | each port `1..65535` | port 0 is reserved | +| `max_idle_disconnect_timeout_in_seconds` | `60..1800` s | NIST AC-12: cap at 30 min | +| `max_disconnect_timeout_in_seconds` | `60..3600` s | | +| `max_session_duration_seconds` | `600..86400` s | 10 min .. 24 h (AppStream per-session hard limit) | +| `lambda_min_azs` | `1..6` | | +| `recommended_cdk_bootstrap_version` | `1..100` | | +| `log_group_retention_days` | one of `1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653` | only the CloudWatch Logs API-accepted retention values | +| `threat_detection_privilege_escalation_threshold` | `0.0..1.0` | fraction of suspicious actions | +| `threat_detection_privilege_escalation_minutes` | `5..43200` min | under 5 min the signal is mostly false positives | +| `threat_detection_enumeration_threshold` | `0.0..1.0` | | +| `threat_detection_enumeration_minutes` | `5..43200` min | | +| `threat_detection_llm_jacking_threshold` | `0.0..1.0` | | +| `threat_detection_llm_jacking_minutes` | `5..43200` min | | +| `days_to_expire_threshold` (ACM) | `7..365` days | PCI-DSS 4.2.1.1: alert ≥30 days before expiry | +| `elb_min_azs` | `1..6` | | +| `elbv2_min_azs` | `1..6` | | +| `minimum_snapshot_retention_period` | `1..35` days | ElastiCache service hard limit | +| `max_days_secret_unused` | `7..365` days | | +| `max_days_secret_unrotated` | `1..180` days | NIST IA-5: rotate quarterly; CIS ≤90 | +| `min_kinesis_stream_retention_hours` | `24..8760` h | 1 day .. 1 year | +| `detect_secrets_plugins[].limit` | `0.0..10.0` | Shannon entropy threshold | +| `shodan_api_key` | ≤512 chars | | + +### Azure + +| Key | Allowed range | Notes | +|---|---|---| +| `vm_backup_min_daily_retention_days` | `7..9999` days | Azure Backup hard limit; under 7 days defeats DR/ransomware recovery | +| `apim_threat_detection_llm_jacking_threshold` | `0.0..1.0` | fraction of suspicious actions | +| `apim_threat_detection_llm_jacking_minutes` | `5..43200` min | under 5 min the signal is mostly false positives | +| `shodan_api_key` | ≤512 chars | | + +### GCP + +| Key | Allowed range | Notes | +|---|---|---| +| `mig_min_zones` | `1..5` | | +| `max_snapshot_age_days` | `1..1095` days | 3 years | +| `max_unused_account_days` | `30..365` days | | +| `storage_min_retention_days` | `1..3650` days | | +| `shodan_api_key` | ≤512 chars | | + +### Kubernetes + +| Key | Allowed range | Notes | +|---|---|---| +| `audit_log_maxbackup` | `2..1000` | CIS Kubernetes 1.2.18 recommends ≥10 | +| `audit_log_maxsize` | `10..10000` MB | CIS Kubernetes 1.2.19 recommends ≥100 MB | +| `audit_log_maxage` | `7..3650` days | CIS Kubernetes 1.2.17 recommends ≥30 days | + +### M365 + +| Key | Allowed range | Notes | +|---|---|---| +| `sign_in_frequency` | `1..168` h | 1 h .. 7 days; Conditional Access baseline for admins ≤24 h | +| `recommended_mailtips_large_audience_threshold` | `5..10000` | Microsoft default 25 | +| `audit_log_age` | `30..3650` days | M365 E3 default 90 days; SEC/FINRA require ≥7 years | + +### GitHub + +| Key | Allowed range | Notes | +|---|---|---| +| `inactive_not_archived_days_threshold` | `30..3650` days | CIS GitHub recommends 180 | + +### Cloudflare + +| Key | Allowed range | Notes | +|---|---|---| +| `max_retries` | `0..10` | 0 disables retries | + +### MongoDB Atlas + +| Key | Allowed range | Notes | +|---|---|---| +| `max_service_account_secret_validity_hours` | `1..720` h | 1 h .. 30 days | + +### Vercel + +| Key | Allowed range | Notes | +|---|---|---| +| `days_to_expire_threshold` | `7..365` days | PCI-DSS 4.2.1.1: alert ≥30 days before expiry | +| `stale_token_threshold_days` | `30..3650` days | NIST AC-2(3) typical window 30..90 days | +| `stale_invitation_threshold_days` | `7..365` days | | +| `max_owner_percentage` | `1..50` % | guidance recommends ≤25% | +| `max_owners` | `1..1000` | absolute cap, overrides percentage for large teams | + +These bounds live in the provider schemas under `prowler/config/schema/`; each field's `Field(ge=..., le=...)` (or `field_validator`) is the source of truth and the descriptions there carry the full rationale. + This approach ensures that checks are easily configurable, making Prowler highly adaptable to different environments and requirements. diff --git a/docs/developer-guide/end2end-testing.mdx b/docs/developer-guide/end2end-testing.mdx index 0a62251531..9013a32245 100644 --- a/docs/developer-guide/end2end-testing.mdx +++ b/docs/developer-guide/end2end-testing.mdx @@ -221,9 +221,9 @@ Before running E2E tests: ``` - **Ensure Prowler API is available** - - By default, Playwright uses `NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`). + - By default, Playwright uses `UI_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`). - Start Prowler API so it is reachable on that URL (for example, via `docker-compose-dev.yml` or the development orchestration used locally). - - If a different API URL is required, set `NEXT_PUBLIC_API_BASE_URL` accordingly before running the tests. + - If a different API URL is required, set `UI_API_BASE_URL` accordingly before running the tests. - **Ensure Prowler App UI is available** - Playwright automatically starts the Next.js server through the `webServer` block in `playwright.config.ts` (`pnpm run dev` by default). diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx new file mode 100644 index 0000000000..1f4d17ee2e --- /dev/null +++ b/docs/developer-guide/environment-variables.mdx @@ -0,0 +1,53 @@ +--- +title: 'Environment Variable Naming Convention' +--- + +Prowler is a monorepo composed of several runtime components — Prowler App (the web user interface), Prowler API (the backend), Prowler SDK, and Prowler MCP Server (Model Context Protocol) — that frequently share a single `.env` file. To keep that shared configuration unambiguous, each component namespaces its environment variables with a component-specific prefix. + +## Component Prefixes + +Each component owns a dedicated prefix for the environment variables it reads: + +| Component | Prefix | Status | +|-----------|--------|--------| +| Prowler App (web UI) | `UI_` | Adopted | +| Prowler API (backend) | `API_` | Planned | +| Prowler SDK | `SDK_` | Planned | +| Prowler MCP Server | `MCP_` | Planned | + +## Why Component Prefixes Matter + +Component prefixes solve three concrete problems in a shared configuration file: + +- **Collisions in a shared `.env`:** Several components historically read identically named variables. The API base URL, for example, is consumed by more than one component, so a single unprefixed name is ambiguous. A component prefix removes that ambiguity. +- **Explicit ownership:** A prefix states, at a glance, which component consumes a variable. +- **Reduced accidental exposure:** For Prowler App, scoping browser-facing configuration under one intentional prefix prevents server-only values from leaking into the client bundle. + +## Prowler App + +Prowler App has adopted the `UI_` prefix. Its public configuration is resolved from the container environment at runtime rather than inlined at build time, so a single pre-built image serves any deployment. For the operational details on changing these values without rebuilding the image, see [Troubleshooting](/troubleshooting). + +The former build-time variables map to the new runtime variables as follows: + +| Former variable | New variable | +|-----------------|--------------| +| `NEXT_PUBLIC_API_BASE_URL` | `UI_API_BASE_URL` | +| `NEXT_PUBLIC_API_DOCS_URL` | `UI_API_DOCS_URL` | +| `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` | + +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 the App's runtime configuration. + +## Upcoming Breaking Change + + +Adopting the `API_`, `SDK_`, and `MCP_` prefixes for Prowler API, Prowler SDK, and Prowler MCP Server is a planned breaking change in a future release. Migrate environment configuration to the new names when upgrading. + + +Prowler API, Prowler SDK, and Prowler MCP Server have not yet adopted the convention. In a future release, the variables each of these components reads will be namespaced under `API_`, `SDK_`, and `MCP_` respectively. The per-component mapping from current to prefixed names will be documented when each change is released. + +## Deprecated Names + +- **Prowler App:** The bare server-side `SENTRY_DSN` and `SENTRY_ENVIRONMENT` are no longer read; the server and edge runtimes now read `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT`. The former `NEXT_PUBLIC_*` build-time variables are deprecated but still read at runtime as a fallback when the matching `UI_*` variable is unset. This fallback will be removed in a future release, so set the `UI_*` runtime variables on the running container. +- **Prowler API, Prowler SDK, and Prowler MCP Server:** The current, unprefixed variable names are deprecated. They continue to work today and will be removed once the prefixed convention is adopted for each component, as described in [Upcoming Breaking Change](#upcoming-breaking-change). diff --git a/docs/developer-guide/introduction.mdx b/docs/developer-guide/introduction.mdx index 117640c669..1d434912ef 100644 --- a/docs/developer-guide/introduction.mdx +++ b/docs/developer-guide/introduction.mdx @@ -108,6 +108,39 @@ uv sync source .venv/bin/activate ``` +### Running the Local API Development Stack + +For API development, Prowler provides a Makefile-based local stack in addition to the manual and Docker Compose workflows documented in the API README. PostgreSQL, Valkey, and Neo4j run with Docker Compose, while Django and the Celery worker run natively through `uv`. + +Before using this method, ensure `docker compose`, `tmux`, and `uv` are installed. + +This workflow is designed for macOS and should also work on Linux when Docker, `tmux`, and `uv` are available. Windows requires script changes before it can be supported. + +To start the local API stack, run: + +```shell +make dev +``` + +This command starts the required services, creates a `tmux` session with panes for the API, worker, and PostgreSQL logs, waits until the API responds, and prints the API URL and log file paths. The API is available at: + +```text +http://localhost:8080/api/v1 +``` + +Use these commands to manage the stack: + +```shell +make dev-setup # Bootstrap dependencies, migrations, and fixtures +make dev-attach # Attach to the tmux session +make dev-launch # Start the stack on fixed ports and attach +make dev-stop # Stop the tmux session and containers +make dev-clean # Remove stopped development containers +make dev-wipe # Stop everything and delete local development data +make dev-status # Show development container status +``` + +The UI is not started by this workflow. Start it separately by following the UI development instructions in the `ui/` directory. ### Pre-Commit Hooks diff --git a/docs/developer-guide/security-compliance-framework.mdx b/docs/developer-guide/security-compliance-framework.mdx index 030d876aab..cf756c4da0 100644 --- a/docs/developer-guide/security-compliance-framework.mdx +++ b/docs/developer-guide/security-compliance-framework.mdx @@ -38,7 +38,7 @@ Before adding a new framework, complete the following checks: - **Verify the framework is not already supported.** Inspect `prowler/compliance/` and every `prowler/compliance//` for an existing JSON file matching the name and version. - **Confirm the required checks exist.** Every requirement that can be automated must point to one or more existing Prowler checks. For each missing check, implement it first by following the [Prowler Checks](/developer-guide/checks) guide. - **Review a reference framework.** Use an existing framework with a similar structure as your template: - - Universal: `prowler/compliance/dora.json`, `prowler/compliance/csa_ccm_4.0.json`. + - Universal: `prowler/compliance/dora_2022_2554.json`, `prowler/compliance/csa_ccm_4.0.json`. - Legacy: `prowler/compliance/aws/cis_2.0_aws.json` (canonical CIS shape), `prowler/compliance/aws/ccc_aws.json`, `prowler/compliance/aws/ens_rd2022_aws.json`, `prowler/compliance/aws/nist_800_53_revision_5_aws.json`. ## Universal Compliance Framework @@ -51,9 +51,9 @@ Place the file at the top level of the compliance directory: prowler/compliance/.json ``` -Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/compliance/dora.json`. +Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/compliance/dora_2022_2554.json`. -The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora.json` → `dora`). +The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora_2022_2554.json` → `dora_2022_2554`). ### Top-level structure @@ -70,7 +70,7 @@ The file is auto-discovered — there is **no** need to register it in any `__in } ``` -A `provider` field at the top level is **optional**. The framework's effective provider list is derived by `ComplianceFramework.get_providers()` (`compliance_models.py:739`) from the union of all keys appearing in `requirement.checks` across all requirements; the explicit `provider` field is used **only as a fallback** when no requirement carries any `checks` key. This is what enables a single file (e.g. `dora.json`) to cover AWS today and add Azure / GCP / etc. tomorrow without restructuring. +A `provider` field at the top level is **optional**. The framework's effective provider list is derived by `ComplianceFramework.get_providers()` (`compliance_models.py:739`) from the union of all keys appearing in `requirement.checks` across all requirements; the explicit `provider` field is used **only as a fallback** when no requirement carries any `checks` key. This is what enables a single file (e.g. `dora_2022_2554.json`) to cover AWS today and add Azure / GCP / etc. tomorrow without restructuring. Provider keys inside `requirement.checks` must match the directory names under `prowler/providers/`. The valid keys at present are: `aws`, `azure`, `gcp`, `m365`, `kubernetes`, `iac`, `github`, `googleworkspace`, `alibabacloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `oraclecloud`, `llm`. Comparison in `supports_provider()` is case-insensitive, but lowercase is the convention used everywhere in the repository. @@ -493,7 +493,7 @@ Before opening a PR, validate the JSON loads cleanly against the model and that ### 1. Schema validation -For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora.json` that key is `dora`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`. +For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora_2022_2554.json` that key is `dora_2022_2554`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`. ```python from prowler.lib.check.compliance_models import ( @@ -619,7 +619,7 @@ The following issues are the most common when contributing a compliance framewor Use the following files as templates when modeling a new contribution. -- `prowler/compliance/dora.json` — universal schema, single-provider populated (AWS), ready to extend with more providers. +- `prowler/compliance/dora_2022_2554.json` — universal schema, single-provider populated (AWS), ready to extend with more providers. - `prowler/compliance/csa_ccm_4.0.json` — universal schema, multi-provider populated (AWS, Azure, GCP, AlibabaCloud, OracleCloud). - `prowler/compliance/aws/cis_2.0_aws.json` — legacy CIS attribute shape. - `prowler/compliance/aws/nist_800_53_revision_5_aws.json` — legacy generic attribute shape. diff --git a/docs/docs.json b/docs/docs.json index 51c7e739b0..5b88b5d2da 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -359,6 +359,13 @@ "user-guide/providers/okta/getting-started-okta", "user-guide/providers/okta/authentication" ] + }, + { + "group": "Linode", + "pages": [ + "user-guide/providers/linode/getting-started-linode", + "user-guide/providers/linode/authentication" + ] } ] }, @@ -417,6 +424,7 @@ "group": "Miscellaneous", "pages": [ "developer-guide/documentation", + "developer-guide/environment-variables", { "group": "Testing", "pages": [ diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index eea9075cda..598b2ac44a 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.30.0" -PROWLER_API_VERSION="5.30.0" +PROWLER_UI_VERSION="5.31.0" +PROWLER_API_VERSION="5.31.0" ``` diff --git a/docs/introduction.mdx b/docs/introduction.mdx index fe530e4a4f..51a27eb555 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -32,6 +32,7 @@ Prowler supports a wide range of providers organized by category: | [Azure](/user-guide/providers/azure/getting-started-azure) | Official | Subscriptions | UI, API, CLI | | [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | UI, API, CLI | | [Google Cloud](/user-guide/providers/gcp/getting-started-gcp) | Official | Projects | UI, API, CLI | +| [Linode](/user-guide/providers/linode/getting-started-linode) | [Contact us](https://prowler.com/contact) | Accounts | CLI | | **NHN** | [Contact us](https://prowler.com/contact) | Tenants | CLI | | [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI | | [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI | diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 9d60c57321..3125d74ef5 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -2,6 +2,8 @@ title: 'Troubleshooting' --- +import { VersionBadge } from "/snippets/version-badge.mdx" + ## Running `prowler` I get `[File: utils.py:15] [Module: utils] CRITICAL: path/redacted: OSError[13]` That is an error related to file descriptors or opened files allowed by your operating system. @@ -81,6 +83,39 @@ docker compose down docker compose up -d ``` +### Worker Uses Too Much Memory on Hosts with Many CPUs + + + +When Prowler App runs self-hosted on a machine or Kubernetes node with many CPUs, +the Celery worker may create one prefork process per detected CPU if concurrency +is not configured explicitly. Each process loads the SDK runtime and cloud +provider clients, so idle memory can be high and worker containers can be +terminated by their memory limit. + +Set `DJANGO_CELERY_WORKER_CONCURRENCY` in the worker runtime environment to cap +the number of prefork processes: + +```yaml +services: + worker: + environment: + DJANGO_CELERY_WORKER_CONCURRENCY: "4" +``` + +For Kubernetes deployments, set the same variable on the worker Deployment: + +```yaml +env: + - name: DJANGO_CELERY_WORKER_CONCURRENCY + value: "4" +``` + +Lower values reduce idle memory and the number of tasks a worker can run in +parallel. Increase the value only when the worker has enough memory for the +expected scan workload. Leaving the variable unset preserves Celery's default +CPU-based concurrency. + ### API Container Fails to Start with JWT Key Permission Error See [GitHub Issue #8897](https://github.com/prowler-cloud/prowler/issues/8897) for more details. @@ -201,35 +236,29 @@ When running Prowler behind a reverse proxy (nginx, Traefik, etc.) or load balan **Root Cause:** -Next.js environment variables prefixed with `NEXT_PUBLIC_` are **bundled at build time**, not runtime. The pre-built Docker images from Docker Hub (`prowlercloud/prowler-ui:stable`) are built with default internal URLs. Simply setting `NEXT_PUBLIC_API_BASE_URL` in your `.env` file or environment variables and restarting the container will **NOT** work because these values are already compiled into the JavaScript bundle. +The API base and docs URLs are resolved from the container environment **at runtime**. A single pre-built Docker image (`prowlercloud/prowler-ui:stable`) therefore serves any environment: point the URLs at your external domain and restart the container — no rebuild is required. **Solution:** -You must **rebuild** the UI Docker image with your external URL: - -```bash -# Clone the repository (if you haven't already) -git clone https://github.com/prowler-cloud/prowler.git -cd prowler/ui - -# Build with your external URL as a build argument -docker build \ - --build-arg NEXT_PUBLIC_API_BASE_URL=https://prowler.example.com/api/v1 \ - --build-arg NEXT_PUBLIC_API_DOCS_URL=https://prowler.example.com/api/v1/docs \ - -t prowler-ui-custom:latest \ - --target prod \ - . -``` - -Then update your `docker-compose.yml` to use your custom image instead of the pre-built one: +Set the runtime environment variables to your external URL and restart the UI container: ```yaml services: ui: - image: prowler-ui-custom:latest # Use your custom-built image + image: prowlercloud/prowler-ui:stable + environment: + UI_API_BASE_URL: https://prowler.example.com/api/v1 + UI_API_DOCS_URL: https://prowler.example.com/api/v1/docs # ... rest of configuration ``` +The same values can be supplied through your `.env` file: + +```bash +UI_API_BASE_URL=https://prowler.example.com/api/v1 +UI_API_DOCS_URL=https://prowler.example.com/api/v1/docs +``` + -The `NEXT_PUBLIC_` prefix is a Next.js convention that exposes environment variables to the browser. Since the browser bundle is compiled during `docker build`, these variables must be provided as build arguments, not runtime environment variables. +Earlier releases inlined these values into the JavaScript bundle at build time (via the `NEXT_PUBLIC_` prefix) and required a rebuild with `--build-arg`. That is no longer necessary: `UI_API_BASE_URL` and `UI_API_DOCS_URL` are read at container start, so updating them and restarting is sufficient. diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index caf8853a1b..657549f8b0 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -10,14 +10,20 @@ prowler/config/config.yaml Additionally, you can input a custom configuration file using the `--config-file` argument. + +Numeric thresholds enforce hard limits. A value outside the accepted range is dropped with a warning and the check falls back to its built-in default. See [Configuration Value Limits](/developer-guide/configurable-checks#configuration-value-limits) for the exact range of every bounded option (max-days caps, percentages, counts, etc.). + + ## AWS ### Configurable Checks + The following list includes all the AWS checks with configurable variables that can be changed in the configuration yaml file: | Check Name | Value | Type | |---------------------------------------------------------------|--------------------------------------------------|-----------------| | `acm_certificates_expiration_check` | `days_to_expire_threshold` | Integer | +| `acmpca_certificate_authority_pqc_key_algorithm` | `acmpca_pqc_key_algorithms` | List of Strings | | `appstream_fleet_maximum_session_duration` | `max_session_duration_seconds` | Integer | | `appstream_fleet_session_disconnect_timeout` | `max_disconnect_timeout_in_seconds` | Integer | | `appstream_fleet_session_idle_disconnect_timeout` | `max_idle_disconnect_timeout_in_seconds` | Integer | @@ -55,6 +61,9 @@ The following list includes all the AWS checks with configurable variables that | `elasticache_redis_cluster_backup_enabled` | `minimum_snapshot_retention_period` | Integer | | `elb_is_in_multiple_az` | `elb_min_azs` | Integer | | `elbv2_is_in_multiple_az` | `elbv2_min_azs` | Integer | +| `rolesanywhere_trust_anchor_pqc_pki` | `rolesanywhere_pqc_pca_key_algorithms` | List of Strings | +| `cloudfront_distributions_pqc_tls_enabled` | `cloudfront_pqc_min_protocol_versions` | List of Strings | +| `apigateway_domain_name_pqc_tls_enabled` | `apigateway_pqc_tls_allowed_policies` | List of Strings | | `guardduty_is_enabled` | `mute_non_default_regions` | Boolean | | `iam_user_access_not_stale_to_sagemaker` | `max_unused_sagemaker_access_days` | Integer | | `iam_user_accesskey_unused` | `max_unused_access_keys_days` | Integer | @@ -67,6 +76,7 @@ The following list includes all the AWS checks with configurable variables that | `secretsmanager_secret_rotated_periodically` | `max_days_secret_unrotated` | Integer | | `ssm_document_secrets` | `secrets_ignore_patterns` | List of Strings | | `trustedadvisor_premium_support_plan_subscribed` | `verify_premium_support_plans` | Boolean | +| `transfer_server_pqc_ssh_kex_enabled` | `transfer_pqc_ssh_allowed_policies` | List of Strings | | `dynamodb_table_cross_account_access` | `trusted_account_ids` | List of Strings | | `eventbridge_bus_cross_account_access` | `trusted_account_ids` | List of Strings | | `eventbridge_schema_registry_cross_account_access` | `trusted_account_ids` | List of Strings | diff --git a/docs/user-guide/providers/linode/authentication.mdx b/docs/user-guide/providers/linode/authentication.mdx new file mode 100644 index 0000000000..feac121f40 --- /dev/null +++ b/docs/user-guide/providers/linode/authentication.mdx @@ -0,0 +1,97 @@ +--- +title: "Linode Authentication in Prowler" +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler for Linode uses a **Personal Access Token** (PAT) for authentication. Prowler reads the token **exclusively** from the `LINODE_TOKEN` environment variable, so the secret is never exposed in shell history or process listings. There are no credential CLI flags. + +## Required Permissions + +Prowler requires read-only access to your Linode account. The following OAuth scopes are needed on the Personal Access Token: + +| Scope | Access | Description | +|-------|--------|-------------| +| `account` | `Read Only` | Required to list users and verify account identity | +| `linodes` | `Read Only` | Required to list instances and their configurations | +| `firewall` | `Read Only` | Required to list firewalls and their rules | + + +Ensure the token has all required scopes. Missing permissions will cause some checks to fail or return incomplete results. + + +--- + +## Personal Access Token + +### Step 1: Create a Personal Access Token + +1. Log into the [Linode Cloud Manager](https://cloud.linode.com). +2. Click on your username in the top-right corner, then select **API Tokens** under the "My Profile" section. +3. Click **Create a Personal Access Token**. +4. Configure the token: + - **Label:** A descriptive name (e.g., "Prowler Security Scanner") + - **Expiry:** Set an appropriate expiration (e.g., 6 months) + - **Permissions:** Set the following scopes to **Read Only**: + - Account + - Linodes + - Firewall + - All other scopes can be set to **No Access** +5. Click **Create Token**. +6. Copy the token immediately — it will not be shown again. + +### Step 2: Configure Authentication + +Set the `LINODE_TOKEN` environment variable: + +```bash +export LINODE_TOKEN="your-personal-access-token" +``` + +Then run Prowler: + +```bash +prowler linode +``` + +--- + +## Verifying Authentication + +To verify that Prowler can connect to your Linode account, run: + +```bash +prowler linode --list-checks +``` + +If authentication succeeds, you will see a list of available checks. If it fails, Prowler will display an error message indicating the credentials issue. + +--- + +## CI/CD Integration + +For automated pipelines, set the token as a secret environment variable: + +**GitHub Actions:** + +```yaml +env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + +steps: + - name: Run Prowler + run: prowler linode +``` + +**GitLab CI:** + +```yaml +variables: + LINODE_TOKEN: $LINODE_TOKEN + +prowler_scan: + script: + - prowler linode +``` diff --git a/docs/user-guide/providers/linode/getting-started-linode.mdx b/docs/user-guide/providers/linode/getting-started-linode.mdx new file mode 100644 index 0000000000..128fdf1c90 --- /dev/null +++ b/docs/user-guide/providers/linode/getting-started-linode.mdx @@ -0,0 +1,61 @@ +--- +title: 'Getting Started With Linode on Prowler' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler for Linode scans your Linode infrastructure for security misconfigurations, including compute settings, networking rules, user account security, and more. + + +Linode support in Prowler is community-maintained. For commercial support or to request additional service coverage, [contact us](https://prowler.com/contact). + + +## Prerequisites + +Set up authentication for Linode with the [Linode Authentication](/user-guide/providers/linode/authentication) guide before starting: + +- Create a Linode Personal Access Token with read-only permissions +- The token requires at minimum: `account:read_only`, `linodes:read_only`, and `firewall:read_only` scopes + +## Prowler CLI + +### Run Prowler for Linode + +Once authenticated with a Personal Access Token, set the `LINODE_TOKEN` environment variable and run Prowler for Linode. Prowler reads the token exclusively from the environment variable, so the secret is never exposed in shell history or process listings: + +```bash +export LINODE_TOKEN="your-personal-access-token" +prowler linode +``` + +### Run Specific Checks + +```bash +prowler linode --checks compute_instance_backups_enabled compute_instance_watchdog_enabled +``` + +### Run a Specific Service + +```bash +prowler linode --services networking +``` + +### Scan Specific Regions + +Use `--region` (alias `--filter-region` / `-f`) to limit the scan to one or more Linode regions. Region-less resources (account administration and Cloud Firewalls) are always scanned; only regional resources such as instances are filtered. When the flag is omitted, all regions are scanned. + +```bash +prowler linode --region eu-central us-east +``` + +## Available Services + +Prowler for Linode currently supports the following services: + +| Service | Description | +|---------|-------------| +| `administration` | Account administration includes users and access controls such as two-factor authentication | +| `compute` | Compute includes Linode instances and their workload configuration | +| `networking` | Networking includes Cloud Firewalls and their stateful network rules | diff --git a/permissions/prowler-additions-policy.json b/permissions/prowler-additions-policy.json index 906f06fbfb..c0da045603 100644 --- a/permissions/prowler-additions-policy.json +++ b/permissions/prowler-additions-policy.json @@ -36,6 +36,8 @@ "lightsail:GetRelationalDatabases", "macie2:GetMacieSession", "macie2:GetAutomatedDiscoveryConfiguration", + "rolesanywhere:ListTagsForResource", + "rolesanywhere:ListTrustAnchors", "s3:GetAccountPublicAccessBlock", "shield:DescribeProtection", "shield:GetSubscriptionState", @@ -61,7 +63,9 @@ ], "Resource": [ "arn:*:apigateway:*::/restapis/*", - "arn:*:apigateway:*::/apis/*" + "arn:*:apigateway:*::/apis/*", + "arn:*:apigateway:*::/domainnames", + "arn:*:apigateway:*::/domainnames/*" ], "Sid": "AllowAPIGatewayReadOnly" } diff --git a/permissions/templates/cloudformation/prowler-scan-role.yml b/permissions/templates/cloudformation/prowler-scan-role.yml index 395fed6424..a7d91b0071 100644 --- a/permissions/templates/cloudformation/prowler-scan-role.yml +++ b/permissions/templates/cloudformation/prowler-scan-role.yml @@ -129,6 +129,8 @@ Resources: - "lightsail:GetRelationalDatabases" - "macie2:GetMacieSession" - "macie2:GetAutomatedDiscoveryConfiguration" + - "rolesanywhere:ListTagsForResource" + - "rolesanywhere:ListTrustAnchors" - "s3:GetAccountPublicAccessBlock" - "shield:DescribeProtection" - "shield:GetSubscriptionState" @@ -150,6 +152,8 @@ Resources: Resource: - "arn:*:apigateway:*::/restapis/*" - "arn:*:apigateway:*::/apis/*" + - "arn:*:apigateway:*::/domainnames" + - "arn:*:apigateway:*::/domainnames/*" - !If - OrganizationsEnabled - PolicyName: ProwlerOrganizations diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index ada7536290..b040b32a78 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.31.0] (Prowler UNRELEASED) +## [5.31.0] (Prowler v5.31.0) ### 🚀 Added @@ -12,23 +12,59 @@ All notable changes to the **Prowler SDK** are documented in this file. - `sagemaker_clarify_exists` check for AWS provider [(#11211)](https://github.com/prowler-cloud/prowler/pull/11211) - `cloudsql_instance_high_availability_enabled` check for GCP provider, verifying Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover [(#11024)](https://github.com/prowler-cloud/prowler/pull/11024) - `cloudfunction_function_inside_vpc` check for GCP provider, verifying Cloud Functions have a Serverless VPC Access connector for private egress [(#11021)](https://github.com/prowler-cloud/prowler/pull/11021) +- `cloudfunction_function_not_publicly_accessible` check for GCP provider, detecting Cloud Functions with `allUsers` or `allAuthenticatedUsers` IAM invocation bindings [(#11022)](https://github.com/prowler-cloud/prowler/pull/11022) +- `secretmanager_secret_not_publicly_accessible` check for GCP provider, detecting Secret Manager secrets with public IAM bindings [(#11025)](https://github.com/prowler-cloud/prowler/pull/11025) +- `secretmanager_secret_rotation_enabled` check for GCP provider, verifying Secret Manager secrets have automatic rotation configured within 90 days [(#11026)](https://github.com/prowler-cloud/prowler/pull/11026) - `identity_storage_service_level_admins_scoped` check for OCI provider CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions [(#11523)](https://github.com/prowler-cloud/prowler/pull/11523) - `cosmosdb_account_automatic_failover_enabled` check for Azure provider [(#11031)](https://github.com/prowler-cloud/prowler/pull/11031) - `cosmosdb_account_backup_policy_continuous` check for Azure provider [(#11032)](https://github.com/prowler-cloud/prowler/pull/11032) - `cosmosdb_account_minimum_tls_version` check for Azure provider, verifying Cosmos DB accounts enforce TLS 1.2 or higher for client connections [(#11033)](https://github.com/prowler-cloud/prowler/pull/11033) - `cosmosdb_account_public_network_access_disabled` check for Azure provider, verifying Cosmos DB accounts have public network access disabled so connectivity is restricted to private endpoints or VNet service endpoints [(#11034)](https://github.com/prowler-cloud/prowler/pull/11034) - `databricks_workspace_public_network_access_disabled` check for Azure provider, verifying Databricks workspaces have public network access disabled so connectivity is restricted to Azure Private Link private endpoints [(#11035)](https://github.com/prowler-cloud/prowler/pull/11035) +- `databricks_workspace_no_public_ip_enabled` check for Azure provider, verifying Databricks workspaces use secure cluster connectivity (no public IP) so compute nodes are not assigned public IP addresses [(#11036)](https://github.com/prowler-cloud/prowler/pull/11036) +- `defender_ensure_defender_cspm_is_on` check for Azure provider, verifying Microsoft Defender Cloud Security Posture Management (CSPM) is enabled on the Standard tier [(#11037)](https://github.com/prowler-cloud/prowler/pull/11037) +- `mysql_flexible_server_geo_redundant_backup_enabled` check for Azure provider, verifying MySQL Flexible Servers have geo-redundant backup enabled so backups are replicated to the paired region [(#11041)](https://github.com/prowler-cloud/prowler/pull/11041) +- `mysql_flexible_server_high_availability_enabled` check for Azure provider, verifying MySQL Flexible Servers have high availability enabled for automatic failover to a standby replica [(#11042)](https://github.com/prowler-cloud/prowler/pull/11042) +- `postgresql_flexible_server_geo_redundant_backup_enabled` check for Azure provider, verifying PostgreSQL Flexible Servers have geo-redundant backup enabled so backups are replicated to the paired region [(#11045)](https://github.com/prowler-cloud/prowler/pull/11045) +- `postgresql_flexible_server_high_availability_enabled` check for Azure provider, verifying PostgreSQL Flexible Servers have high availability enabled for automatic failover to a standby replica [(#11046)](https://github.com/prowler-cloud/prowler/pull/11046) +- `aks_cluster_azure_monitor_enabled` check for Azure provider, verifying AKS clusters have Azure Monitor (Container Insights) enabled for metrics, logs, and alerting [(#11029)](https://github.com/prowler-cloud/prowler/pull/11029) +- `aks_cluster_local_accounts_disabled` check for Azure provider, verifying AKS clusters have local accounts disabled so authentication is forced through Microsoft Entra ID [(#11030)](https://github.com/prowler-cloud/prowler/pull/11030) +- `network_subnet_nsg_associated` check for Azure provider, verifying virtual network subnets have a network security group associated to enforce traffic filtering [(#11043)](https://github.com/prowler-cloud/prowler/pull/11043) +- `network_vnet_ddos_protection_enabled` check for Azure provider, verifying virtual networks have Azure DDoS Network Protection enabled [(#11044)](https://github.com/prowler-cloud/prowler/pull/11044) +- `entra_app_registration_credential_not_expired` check for Azure provider, verifying Entra ID app registration secrets and certificates are not expired, expiring within 30 days, or without an expiration date [(#11038)](https://github.com/prowler-cloud/prowler/pull/11038) +- `entra_authentication_methods_policy_strong_auth_enforced` check for Azure provider, verifying the Entra ID authentication methods policy enforces MFA registration and enables at least one strong method (Microsoft Authenticator, FIDO2, or X.509 certificate) [(#11039)](https://github.com/prowler-cloud/prowler/pull/11039) +- `entra_user_with_recent_sign_in` check for Azure provider, detecting stale enabled accounts that have not signed in within the last 90 days (requires Entra ID P1/P2 licensing for sign-in activity) [(#11040)](https://github.com/prowler-cloud/prowler/pull/11040) - `aks_cluster_auto_upgrade_enabled` check for Azure provider [(#11027)](https://github.com/prowler-cloud/prowler/pull/11027) - Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398) - Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602) - TLS certificate verification in the `codepipeline_project_repo_private` check, which previously used an unverified SSL context, leaving the repository-visibility probe open to MITM tampering [(#11603)](https://github.com/prowler-cloud/prowler/pull/11603) +- Support for Linode cloud provider, with compute, networking and administration services [(#11633)](https://github.com/prowler-cloud/prowler/pull/11633) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the Azure provider, mapping existing Azure checks across the five DORA pillars [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) +- Rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) - `entra_directory_sync_object_takeover_blocked` check for the M365 provider, verifying that hybrid Entra tenants block cloud object takeover through both soft-match and hard-match directory synchronization [(#11098)](https://github.com/prowler-cloud/prowler/pull/11098) - `entra_conditional_access_policy_no_deleted_object_references` check for M365 provider [(#11236)](https://github.com/prowler-cloud/prowler/pull/11236) +- `aks_cluster_defender_enabled` check for Azure provider, verifying that AKS clusters have Microsoft Defender security monitoring enabled [(#11028)](https://github.com/prowler-cloud/prowler/pull/11028) +- `recovery_vault_has_protected_items` check for Azure provider, verifying that Recovery Services vaults have at least one protected backup item [(#11048)](https://github.com/prowler-cloud/prowler/pull/11048) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the GCP provider, mapping existing GCP checks across the five DORA pillars [(#11642)](https://github.com/prowler-cloud/prowler/pull/11642) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the Cloudflare provider, mapping existing Cloudflare edge/network checks across the applicable DORA pillars [(#11645)](https://github.com/prowler-cloud/prowler/pull/11645) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the AlibabaCloud provider, mapping existing AlibabaCloud checks across the applicable DORA pillars [(#11646)](https://github.com/prowler-cloud/prowler/pull/11646) +- `cloudfront_distributions_pqc_tls_enabled` check for AWS provider to verify CloudFront distributions enforce a post-quantum TLS 1.3 security policy [(#11317)](https://github.com/prowler-cloud/prowler/pull/11317) +- `apigateway_domain_name_pqc_tls_enabled` check for AWS provider to verify API Gateway custom domain names use a post-quantum TLS security policy [(#11316)](https://github.com/prowler-cloud/prowler/pull/11316) +- `transfer_server_pqc_ssh_kex_enabled` check for AWS provider to verify Transfer Family servers use a post-quantum hybrid SSH key exchange security policy [(#11315)](https://github.com/prowler-cloud/prowler/pull/11315) +- `acmpca_certificate_authority_pqc_key_algorithm` check and new `acmpca` service for AWS provider to verify AWS Private CA certificate authorities use a post-quantum (ML-DSA) key algorithm [(#11318)](https://github.com/prowler-cloud/prowler/pull/11318) +- `rolesanywhere_trust_anchor_pqc_pki` check and new `rolesanywhere` service for AWS provider to verify IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI [(#11319)](https://github.com/prowler-cloud/prowler/pull/11319) +- Kubernetes core checks for container CPU limits, CPU requests, memory limits, memory requests, fixed image tags, liveness probes, and readiness probes [(#11373)](https://github.com/prowler-cloud/prowler/pull/11373) +- `recovery_vault_backup_policy_retention_adequate` check for Azure provider, verifying Recovery Services backup policies retain daily backups for at least 30 days [(#11047)](https://github.com/prowler-cloud/prowler/pull/11047) ### 🔄 Changed - Replaced the unmaintained `awsipranges` dependency with a small standard-library helper for the `route53_dangling_ip_subdomain_takeover` check [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293) +### 🐞 Fixed + +- Azure PostgreSQL flexible server inventory no longer aborts the whole subscription when the `connection_throttle.enable` parameter is missing (e.g. PostgreSQL v18), and logs the expected "Entra ID authentication not enabled" case as a warning instead of an error, so servers are still scanned [(#11045)](https://github.com/prowler-cloud/prowler/pull/11045) +- `iam_policy_allows_privilege_escalation` now includes the `privilege-escalation` category [(#11648)](https://github.com/prowler-cloud/prowler/pull/11648) + ### 🔐 Security - `pytest` from 8.3.5 to 9.0.3, patching a known vulnerability in the SDK test dependency [(#11291)](https://github.com/prowler-cloud/prowler/pull/11291) @@ -38,6 +74,14 @@ All notable changes to the **Prowler SDK** are documented in this file. --- +## [5.30.3] (Prowler v5.30.3) + +### 🐞 Fixed + +- CLI compliance summary tables no longer undercount findings mapped to multiple sections nor double-count a single finding mapped to several requirements within the same group/split, and the Provider column no longer leaks a value from another framework [(#11567)](https://github.com/prowler-cloud/prowler/pull/11567) + +--- + ## [5.30.2] (Prowler v5.30.2) ### 🐞 Fixed @@ -179,6 +223,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `entra_service_principal_no_secrets_for_permanent_tier0_roles` check for M365 provider [(#10788)](https://github.com/prowler-cloud/prowler/pull/10788) - `iam_user_access_not_stale_to_sagemaker` check for AWS provider with configurable `max_unused_sagemaker_access_days` (default 90) [(#11000)](https://github.com/prowler-cloud/prowler/pull/11000) - `cloudtrail_bedrock_logging_enabled` check for AWS provider [(#10858)](https://github.com/prowler-cloud/prowler/pull/10858) +- Per-provider scan configuration schema with bounds validation that drops out-of-range values with a warning on config load [(#11518)](https://github.com/prowler-cloud/prowler/pull/11518) - Okta provider with OAuth 2.0 authentication and `signon_global_session_idle_timeout_15min` check [(#11079)](https://github.com/prowler-cloud/prowler/pull/11079) - `sagemaker_domain_sso_configured` check for AWS provider [(#11094)](https://github.com/prowler-cloud/prowler/pull/11094) - Scaleway provider with `iam_api_keys_no_root_owned` check [(#11166)](https://github.com/prowler-cloud/prowler/pull/11166) diff --git a/prowler/__main__.py b/prowler/__main__.py index 533a704359..d4c925f74f 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -147,6 +147,7 @@ from prowler.providers.iac.models import IACOutputOptions from prowler.providers.image.exceptions.exceptions import ImageBaseException from prowler.providers.image.models import ImageOutputOptions from prowler.providers.kubernetes.models import KubernetesOutputOptions +from prowler.providers.linode.models import LinodeOutputOptions from prowler.providers.llm.models import LLMOutputOptions from prowler.providers.m365.models import M365OutputOptions from prowler.providers.mongodbatlas.models import MongoDBAtlasOutputOptions @@ -439,6 +440,10 @@ def prowler(): output_options = ScalewayOutputOptions( args, bulk_checks_metadata, global_provider.identity ) + elif provider == "linode": + output_options = LinodeOutputOptions( + args, bulk_checks_metadata, global_provider.identity + ) else: # Dynamic fallback: any external/custom provider try: diff --git a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json index 94c50eb68a..4c4eaee252 100644 --- a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json +++ b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json @@ -1151,6 +1151,9 @@ "elb_insecure_ssl_ciphers", "elb_ssl_listeners", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_ssl_listeners", "s3_bucket_secure_transport_policy" ] diff --git a/prowler/compliance/aws/ccc_aws.json b/prowler/compliance/aws/ccc_aws.json index d1a20ee3d0..003e9e17a0 100644 --- a/prowler/compliance/aws/ccc_aws.json +++ b/prowler/compliance/aws/ccc_aws.json @@ -49,6 +49,9 @@ "elb_insecure_ssl_ciphers", "elb_ssl_listeners", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_ssl_listeners", "elbv2_nlb_tls_termination_enabled", "s3_bucket_secure_transport_policy", diff --git a/prowler/compliance/aws/ens_rd2022_aws.json b/prowler/compliance/aws/ens_rd2022_aws.json index f6c574daef..8fcd3263e9 100644 --- a/prowler/compliance/aws/ens_rd2022_aws.json +++ b/prowler/compliance/aws/ens_rd2022_aws.json @@ -2366,7 +2366,10 @@ } ], "Checks": [ - "elbv2_insecure_ssl_ciphers" + "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled" ] }, { @@ -2389,7 +2392,10 @@ } ], "Checks": [ - "elbv2_insecure_ssl_ciphers" + "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled" ] }, { diff --git a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json index c914a58b2c..11f66ada6b 100644 --- a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json +++ b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json @@ -1145,6 +1145,9 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "s3_bucket_secure_transport_policy" @@ -1164,6 +1167,9 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "s3_bucket_secure_transport_policy" diff --git a/prowler/compliance/aws/ffiec_aws.json b/prowler/compliance/aws/ffiec_aws.json index 23ab8953b6..53f7275a85 100644 --- a/prowler/compliance/aws/ffiec_aws.json +++ b/prowler/compliance/aws/ffiec_aws.json @@ -487,6 +487,9 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "s3_bucket_secure_transport_policy" ] diff --git a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json index 39534c4fdc..bcf8f19c3b 100644 --- a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json +++ b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json @@ -266,6 +266,9 @@ "ec2_ebs_default_encryption", "efs_encryption_at_rest_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "opensearch_service_domains_encryption_at_rest_enabled", "opensearch_service_domains_node_to_node_encryption_enabled", diff --git a/prowler/compliance/aws/iso27001_2013_aws.json b/prowler/compliance/aws/iso27001_2013_aws.json index ad31ea0af4..190693d121 100644 --- a/prowler/compliance/aws/iso27001_2013_aws.json +++ b/prowler/compliance/aws/iso27001_2013_aws.json @@ -35,7 +35,10 @@ ], "Checks": [ "elb_insecure_ssl_ciphers", - "elbv2_insecure_ssl_ciphers" + "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled" ] }, { diff --git a/prowler/compliance/aws/kisa_isms_p_2023_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_aws.json index d172e615dd..c24b5a23e5 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_aws.json @@ -2040,6 +2040,9 @@ "elb_ssl_listeners", "elb_ssl_listeners_use_acm_certificate", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_nlb_tls_termination_enabled", "elbv2_ssl_listeners", "glue_data_catalogs_connection_passwords_encryption_enabled", @@ -3090,6 +3093,9 @@ "elb_ssl_listeners_use_acm_certificate", "elbv2_desync_mitigation_mode", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_internet_facing", "elbv2_listeners_underneath", "elbv2_logging_enabled", diff --git a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json index 1748d96442..50e2a149a5 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json @@ -2042,6 +2042,9 @@ "elb_ssl_listeners", "elb_ssl_listeners_use_acm_certificate", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_nlb_tls_termination_enabled", "elbv2_ssl_listeners", "glue_data_catalogs_connection_passwords_encryption_enabled", @@ -3093,6 +3096,9 @@ "elb_ssl_listeners_use_acm_certificate", "elbv2_desync_mitigation_mode", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_internet_facing", "elbv2_listeners_underneath", "elbv2_logging_enabled", diff --git a/prowler/compliance/aws/nist_800_171_revision_2_aws.json b/prowler/compliance/aws/nist_800_171_revision_2_aws.json index 8e28ee383d..e5a456bbb3 100644 --- a/prowler/compliance/aws/nist_800_171_revision_2_aws.json +++ b/prowler/compliance/aws/nist_800_171_revision_2_aws.json @@ -653,6 +653,9 @@ "apigateway_restapi_client_certificate_enabled", "ec2_ebs_volume_encryption", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "opensearch_service_domains_node_to_node_encryption_enabled", "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy" diff --git a/prowler/compliance/aws/nist_800_53_revision_5_aws.json b/prowler/compliance/aws/nist_800_53_revision_5_aws.json index 858df01fd9..c9eb755e49 100644 --- a/prowler/compliance/aws/nist_800_53_revision_5_aws.json +++ b/prowler/compliance/aws/nist_800_53_revision_5_aws.json @@ -5262,6 +5262,9 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "s3_bucket_secure_transport_policy" @@ -5549,6 +5552,9 @@ ], "Checks": [ "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners" ] }, diff --git a/prowler/compliance/aws/rbi_cyber_security_framework_aws.json b/prowler/compliance/aws/rbi_cyber_security_framework_aws.json index b4566a8929..f4e8d1d70e 100644 --- a/prowler/compliance/aws/rbi_cyber_security_framework_aws.json +++ b/prowler/compliance/aws/rbi_cyber_security_framework_aws.json @@ -40,6 +40,9 @@ "ec2_instance_public_ip", "efs_encryption_at_rest_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "ec2_ebs_default_encryption", "emr_cluster_master_nodes_no_public_ip", diff --git a/prowler/compliance/aws/secnumcloud_3.2_aws.json b/prowler/compliance/aws/secnumcloud_3.2_aws.json index 1e157b47c7..d8736c2d6b 100644 --- a/prowler/compliance/aws/secnumcloud_3.2_aws.json +++ b/prowler/compliance/aws/secnumcloud_3.2_aws.json @@ -474,6 +474,9 @@ "elbv2_ssl_listeners", "elb_insecure_ssl_ciphers", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "redshift_cluster_in_transit_encryption_enabled", "elasticache_redis_cluster_in_transit_encryption_enabled", "dynamodb_accelerator_cluster_in_transit_encryption_enabled", diff --git a/prowler/compliance/azure/cis_2.0_azure.json b/prowler/compliance/azure/cis_2.0_azure.json index 73f4dc3611..59bd153352 100644 --- a/prowler/compliance/azure/cis_2.0_azure.json +++ b/prowler/compliance/azure/cis_2.0_azure.json @@ -2714,7 +2714,8 @@ "Id": "6.6", "Description": "Ensure that Network Watcher is 'Enabled'", "Checks": [ - "network_watcher_enabled" + "network_watcher_enabled", + "network_vnet_ddos_protection_enabled" ], "Attributes": [ { diff --git a/prowler/compliance/azure/cis_5.0_azure.json b/prowler/compliance/azure/cis_5.0_azure.json index 21785d92b6..8ea6f50d7c 100644 --- a/prowler/compliance/azure/cis_5.0_azure.json +++ b/prowler/compliance/azure/cis_5.0_azure.json @@ -1974,7 +1974,9 @@ { "Id": "7.11", "Description": "Ensure subnets are associated with network security groups", - "Checks": [], + "Checks": [ + "network_subnet_nsg_associated" + ], "Attributes": [ { "Section": "7 Networking Services", @@ -2094,7 +2096,9 @@ { "Id": "8.1.1.1", "Description": "Ensure Microsoft Defender CSPM is set to 'On'", - "Checks": [], + "Checks": [ + "defender_ensure_defender_cspm_is_on" + ], "Attributes": [ { "Section": "8 Security Services", @@ -2931,7 +2935,9 @@ { "Id": "8.5", "Description": "Ensure Azure DDoS Network Protection is enabled on virtual networks", - "Checks": [], + "Checks": [ + "network_vnet_ddos_protection_enabled" + ], "Attributes": [ { "Section": "8 Security Services", diff --git a/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json b/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json index c845f75f60..655f649abe 100644 --- a/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json +++ b/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json @@ -328,6 +328,7 @@ "Checks": [ "entra_non_privileged_user_has_mfa", "entra_privileged_user_has_mfa", + "entra_user_with_recent_sign_in", "entra_user_with_vm_access_has_mfa", "iam_custom_role_has_permissions_to_administer_resource_locks", "iam_role_user_access_admin_restricted", diff --git a/prowler/compliance/azure/hipaa_azure.json b/prowler/compliance/azure/hipaa_azure.json index 3b1184e0e5..3672218f3b 100644 --- a/prowler/compliance/azure/hipaa_azure.json +++ b/prowler/compliance/azure/hipaa_azure.json @@ -182,6 +182,7 @@ } ], "Checks": [ + "entra_user_with_recent_sign_in", "storage_key_rotation_90_days", "keyvault_key_rotation_enabled", "keyvault_rbac_key_expiration_set", @@ -430,6 +431,7 @@ } ], "Checks": [ + "recovery_vault_backup_policy_retention_adequate", "vm_backup_enabled", "vm_sufficient_daily_backup_retention_period", "storage_blob_versioning_is_enabled", @@ -439,7 +441,9 @@ "keyvault_recoverable", "sqlserver_auditing_retention_90_days", "postgresql_flexible_server_log_retention_days_greater_3", - "cosmosdb_account_backup_policy_continuous" + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled" ] }, { diff --git a/prowler/compliance/azure/iso27001_2022_azure.json b/prowler/compliance/azure/iso27001_2022_azure.json index 1eb0f75792..23392533de 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -271,6 +271,7 @@ } ], "Checks": [ + "aks_cluster_local_accounts_disabled", "defender_ensure_defender_for_containers_is_on", "defender_ensure_defender_for_cosmosdb_is_on", "defender_ensure_defender_for_databases_is_on", @@ -284,6 +285,7 @@ "defender_ensure_notify_alerts_severity_is_high", "entra_policy_guest_users_access_restrictions", "entra_policy_restricts_user_consent_for_apps", + "entra_user_with_recent_sign_in", "entra_users_cannot_create_microsoft_365_groups", "iam_custom_role_has_permissions_to_administer_resource_locks", "monitor_alert_create_update_security_solution", @@ -332,7 +334,8 @@ "entra_policy_guest_invite_only_for_admin_roles", "entra_policy_guest_users_access_restrictions", "entra_policy_restricts_user_consent_for_apps", - "entra_policy_user_consent_for_verified_apps" + "entra_policy_user_consent_for_verified_apps", + "entra_user_with_recent_sign_in" ] }, { @@ -1102,6 +1105,7 @@ } ], "Checks": [ + "entra_authentication_methods_policy_strong_auth_enforced", "entra_conditional_access_policy_require_mfa_for_management_app", "entra_non_privileged_user_has_mfa entra_privileged_user_has_mfa", "entra_user_with_vm_access_has_mfa", @@ -1267,7 +1271,10 @@ } ], "Checks": [ - "cosmosdb_account_backup_policy_continuous" + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled", + "recovery_vault_backup_policy_retention_adequate" ] }, { @@ -1296,7 +1303,11 @@ "storage_secure_transfer_required_is_enabled", "vm_ensure_using_managed_disks", "vm_trusted_launch_enabled", - "cosmosdb_account_automatic_failover_enabled" + "cosmosdb_account_automatic_failover_enabled", + "mysql_flexible_server_geo_redundant_backup_enabled", + "mysql_flexible_server_high_availability_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_high_availability_enabled" ] }, { @@ -1340,6 +1351,8 @@ } ], "Checks": [ + "aks_cluster_azure_monitor_enabled", + "defender_ensure_defender_cspm_is_on", "monitor_alert_create_policy_assignment", "monitor_alert_create_update_nsg", "monitor_alert_create_update_public_ip_address_rule", @@ -1411,6 +1424,7 @@ "containerregistry_not_publicly_accessible", "cosmosdb_account_firewall_use_selected_networks", "cosmosdb_account_public_network_access_disabled", + "databricks_workspace_no_public_ip_enabled", "databricks_workspace_public_network_access_disabled", "network_bastion_host_exists", "network_flow_log_captured_sent", @@ -1419,7 +1433,9 @@ "network_public_ip_shodan", "network_rdp_internet_access_restricted", "network_ssh_internet_access_restricted", + "network_subnet_nsg_associated", "network_udp_internet_access_restricted", + "network_vnet_ddos_protection_enabled", "network_watcher_enabled" ] }, @@ -1473,6 +1489,7 @@ "network_public_ip_shodan", "network_rdp_internet_access_restricted", "network_ssh_internet_access_restricted", + "network_subnet_nsg_associated", "network_udp_internet_access_restricted", "network_watcher_enabled" ] @@ -1506,6 +1523,7 @@ "Checks": [ "app_minimum_tls_version_12", "cosmosdb_account_minimum_tls_version", + "entra_app_registration_credential_not_expired", "monitor_storage_account_with_activity_logs_cmk_encrypted", "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled", diff --git a/prowler/compliance/azure/mitre_attack_azure.json b/prowler/compliance/azure/mitre_attack_azure.json index 92ddd72713..8b338da534 100644 --- a/prowler/compliance/azure/mitre_attack_azure.json +++ b/prowler/compliance/azure/mitre_attack_azure.json @@ -212,6 +212,7 @@ "Description": "Adversaries may obtain and abuse credentials of existing accounts as a means of gaining Initial Access, Persistence, Privilege Escalation, or Defense Evasion. Compromised credentials may be used to bypass access controls placed on various resources on systems within the network and may even be used for persistent access to remote systems and externally available services, such as VPNs, Outlook Web Access, network devices, and remote desktop.[1] Compromised credentials may also grant an adversary increased privilege to specific systems or access to restricted areas of the network. Adversaries may choose not to use malware or tools in conjunction with the legitimate access those credentials provide to make it harder to detect their presence.", "TechniqueURL": "https://attack.mitre.org/techniques/T1078/", "Checks": [ + "entra_app_registration_credential_not_expired", "entra_conditional_access_policy_require_mfa_for_management_api", "entra_global_admin_in_less_than_five_users", "entra_non_privileged_user_has_mfa", diff --git a/prowler/compliance/azure/secnumcloud_3.2_azure.json b/prowler/compliance/azure/secnumcloud_3.2_azure.json index ef8c94113e..aedf2133d4 100644 --- a/prowler/compliance/azure/secnumcloud_3.2_azure.json +++ b/prowler/compliance/azure/secnumcloud_3.2_azure.json @@ -339,6 +339,7 @@ } ], "Checks": [ + "entra_authentication_methods_policy_strong_auth_enforced", "entra_non_privileged_user_has_mfa", "entra_privileged_user_has_mfa", "entra_security_defaults_enabled" @@ -493,7 +494,8 @@ "keyvault_non_rbac_secret_expiration_set", "keyvault_logging_enabled", "keyvault_private_endpoints", - "keyvault_access_only_through_private_endpoints" + "keyvault_access_only_through_private_endpoints", + "entra_app_registration_credential_not_expired" ] }, { diff --git a/prowler/compliance/azure/soc2_azure.json b/prowler/compliance/azure/soc2_azure.json index d8936f5485..e0839cedaa 100644 --- a/prowler/compliance/azure/soc2_azure.json +++ b/prowler/compliance/azure/soc2_azure.json @@ -241,7 +241,8 @@ "app_function_not_publicly_accessible", "containerregistry_not_publicly_accessible", "network_public_ip_shodan", - "storage_blob_public_access_level_is_disabled" + "storage_blob_public_access_level_is_disabled", + "entra_app_registration_credential_not_expired" ] }, { @@ -307,7 +308,8 @@ "app_minimum_tls_version_12", "mysql_flexible_server_minimum_tls_version_12", "sqlserver_recommended_minimal_tls_version", - "storage_ensure_minimum_tls_version_12" + "storage_ensure_minimum_tls_version_12", + "network_subnet_nsg_associated" ] }, { diff --git a/prowler/compliance/csa_ccm_4.0.json b/prowler/compliance/csa_ccm_4.0.json index 57f1a67758..b6bb382cda 100644 --- a/prowler/compliance/csa_ccm_4.0.json +++ b/prowler/compliance/csa_ccm_4.0.json @@ -1602,6 +1602,7 @@ "cloudfront_distributions_origin_traffic_encrypted", "cloudfront_distributions_custom_ssl_certificate", "transfer_server_in_transit_encryption_enabled", + "transfer_server_pqc_ssh_kex_enabled", "sagemaker_notebook_instance_encryption_enabled", "sagemaker_training_jobs_volume_and_output_encryption_enabled", "workspaces_volume_encryption_enabled", @@ -1785,6 +1786,7 @@ "acm_certificates_with_secure_key_algorithms", "elb_insecure_ssl_ciphers", "elbv2_insecure_ssl_ciphers", + "transfer_server_pqc_ssh_kex_enabled", "cloudfront_distributions_using_deprecated_ssl_protocols" ], "azure": [ diff --git a/prowler/compliance/dora.json b/prowler/compliance/dora.json deleted file mode 100644 index 2378a307c6..0000000000 --- a/prowler/compliance/dora.json +++ /dev/null @@ -1,597 +0,0 @@ -{ - "framework": "DORA", - "name": "Digital Operational Resilience Act (Regulation (EU) 2022/2554)", - "version": "2022/2554", - "description": "The Digital Operational Resilience Act (DORA) is a European Union regulation (Regulation (EU) 2022/2554) that sets a uniform framework for the digital operational resilience of the EU financial sector. Mandatory since 17 January 2025, it applies to financial entities (banks, insurers, investment firms, payment institutions, etc.) and to ICT third-party service providers. DORA is structured around five pillars: ICT risk management, ICT-related incident reporting, digital operational resilience testing, ICT third-party risk management, and information sharing. This Prowler mapping covers the technical controls auditable from cloud configuration; the organisational, contractual and supervisory obligations defined in DORA must be addressed outside of Prowler.", - "icon": "dora", - "attributes_metadata": [ - { - "key": "Pillar", - "label": "Pillar", - "type": "str", - "required": true, - "enum": [ - "ICT Risk Management", - "ICT-Related Incident Reporting", - "Digital Operational Resilience Testing", - "ICT Third-Party Risk Management", - "Information Sharing" - ], - "output_formats": { - "csv": true, - "ocsf": true - } - }, - { - "key": "Article", - "label": "Article", - "type": "str", - "required": true, - "output_formats": { - "csv": true, - "ocsf": true - } - }, - { - "key": "ArticleTitle", - "label": "Article Title", - "type": "str", - "required": true, - "output_formats": { - "csv": true, - "ocsf": true - } - } - ], - "outputs": { - "table_config": { - "group_by": "Pillar" - }, - "pdf_config": { - "language": "en", - "primary_color": "#003399", - "secondary_color": "#0055A5", - "bg_color": "#F0F4FA", - "group_by_field": "Pillar", - "sections": [ - "ICT Risk Management", - "ICT-Related Incident Reporting", - "Digital Operational Resilience Testing", - "ICT Third-Party Risk Management", - "Information Sharing" - ], - "section_short_names": { - "ICT Risk Management": "ICT Risk Mgmt", - "ICT-Related Incident Reporting": "Incident Reporting", - "Digital Operational Resilience Testing": "Resilience Testing", - "ICT Third-Party Risk Management": "Third-Party Risk", - "Information Sharing": "Info Sharing" - }, - "charts": [ - { - "id": "pillar_compliance", - "type": "horizontal_bar", - "group_by": "Pillar", - "title": "Compliance Score by DORA Pillar", - "y_label": "Pillar", - "x_label": "Compliance %", - "value_source": "compliance_percent", - "color_mode": "by_value" - } - ], - "filter": { - "only_failed": true, - "include_manual": false - } - } - }, - "requirements": [ - { - "id": "DORA-Art5", - "name": "Governance and organisation", - "description": "Financial entities shall have a sound, comprehensive and well-documented ICT internal governance and control framework. Senior management is accountable for ICT risk and shall enforce strong identity, authentication and least-privilege policies for privileged identities, including the root account.", - "attributes": { - "Pillar": "ICT Risk Management", - "Article": "Article 5", - "ArticleTitle": "Governance and organisation" - }, - "checks": { - "aws": [ - "iam_avoid_root_usage", - "iam_no_root_access_key", - "iam_root_mfa_enabled", - "iam_root_hardware_mfa_enabled", - "iam_root_credentials_management_enabled", - "iam_password_policy_minimum_length_14", - "iam_password_policy_lowercase", - "iam_password_policy_uppercase", - "iam_password_policy_number", - "iam_password_policy_symbol", - "iam_password_policy_reuse_24", - "iam_password_policy_expires_passwords_within_90_days_or_less", - "iam_securityaudit_role_created", - "iam_support_role_created", - "organizations_account_part_of_organizations", - "iam_user_mfa_enabled_console_access", - "iam_user_hardware_mfa_enabled" - ] - } - }, - { - "id": "DORA-Art6", - "name": "ICT risk management framework", - "description": "Financial entities shall have an ICT risk management framework that is sound, comprehensive and well-documented, enabling them to address ICT risk quickly, efficiently and comprehensively and to ensure a high level of digital operational resilience. This includes continuous configuration recording, security findings aggregation and an enterprise-wide visibility plane.", - "attributes": { - "Pillar": "ICT Risk Management", - "Article": "Article 6", - "ArticleTitle": "ICT risk management framework" - }, - "checks": { - "aws": [ - "config_recorder_all_regions_enabled", - "config_recorder_using_aws_service_role", - "securityhub_enabled", - "accessanalyzer_enabled", - "accessanalyzer_enabled_without_findings", - "organizations_delegated_administrators", - "guardduty_centrally_managed", - "guardduty_delegated_admin_enabled_all_regions" - ] - } - }, - { - "id": "DORA-Art7", - "name": "ICT systems, protocols and tools", - "description": "Financial entities shall use and maintain updated ICT systems, protocols and tools that are appropriate to the magnitude of operations supporting ICT functions, technologically resilient, and adequately equipped to securely process data. Cryptographic primitives, certificate hygiene and network segmentation are core to this requirement.", - "attributes": { - "Pillar": "ICT Risk Management", - "Article": "Article 7", - "ArticleTitle": "ICT systems, protocols and tools" - }, - "checks": { - "aws": [ - "acm_certificates_with_secure_key_algorithms", - "acm_certificates_transparency_logs_enabled", - "acm_certificates_expiration_check", - "ec2_ebs_default_encryption", - "kms_cmk_rotation_enabled", - "s3_bucket_secure_transport_policy", - "s3_bucket_default_encryption", - "s3_bucket_kms_encryption", - "vpc_subnet_separate_private_public", - "vpc_subnet_no_public_ip_by_default", - "elb_insecure_ssl_ciphers", - "elbv2_insecure_ssl_ciphers", - "elb_ssl_listeners", - "elbv2_ssl_listeners", - "cloudfront_distributions_using_deprecated_ssl_protocols", - "cloudfront_distributions_https_enabled", - "rds_instance_transport_encrypted" - ] - } - }, - { - "id": "DORA-Art8", - "name": "Identification", - "description": "Financial entities shall identify, classify and adequately document all ICT supported business functions, roles and responsibilities, the information assets and ICT assets supporting them, and their interdependencies. They shall on a continuous basis identify all sources of ICT risk, in particular the risk exposure to and from other financial entities.", - "attributes": { - "Pillar": "ICT Risk Management", - "Article": "Article 8", - "ArticleTitle": "Identification" - }, - "checks": { - "aws": [ - "accessanalyzer_enabled", - "accessanalyzer_enabled_without_findings", - "macie_is_enabled", - "macie_automated_sensitive_data_discovery_enabled", - "ec2_securitygroup_not_used", - "ec2_elastic_ip_unassigned", - "ec2_networkacl_unused", - "secretsmanager_secret_unused" - ] - } - }, - { - "id": "DORA-Art9", - "name": "Protection and prevention", - "description": "Financial entities shall continuously monitor and control the security and functioning of ICT systems and tools and minimise the impact of ICT risk by deploying appropriate ICT security tools, policies and procedures. Encryption at rest and in transit, blocking of public exposure, network access controls, secret management and instance hardening are central to this article.", - "attributes": { - "Pillar": "ICT Risk Management", - "Article": "Article 9", - "ArticleTitle": "Protection and prevention" - }, - "checks": { - "aws": [ - "kms_key_not_publicly_accessible", - "ec2_ebs_volume_encryption", - "ec2_ebs_snapshots_encrypted", - "ec2_ebs_public_snapshot", - "ec2_ebs_snapshot_account_block_public_access", - "s3_account_level_public_access_blocks", - "s3_bucket_level_public_access_block", - "s3_bucket_public_access", - "s3_bucket_policy_public_write_access", - "s3_bucket_public_write_acl", - "s3_bucket_public_list_acl", - "s3_bucket_acl_prohibited", - "s3_access_point_public_access_block", - "ec2_securitygroup_default_restrict_traffic", - "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", - "ec2_securitygroup_allow_ingress_from_internet_to_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", - "rds_instance_storage_encrypted", - "rds_cluster_storage_encrypted", - "rds_instance_no_public_access", - "rds_snapshots_public_access", - "secretsmanager_not_publicly_accessible", - "secretsmanager_has_restrictive_resource_policy", - "secretsmanager_automatic_rotation_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", - "sns_topics_kms_encryption_at_rest_enabled", - "sns_topics_not_publicly_accessible", - "ec2_instance_imdsv2_enabled", - "ec2_instance_account_imdsv2_enabled", - "efs_encryption_at_rest_enabled", - "awslambda_function_not_publicly_accessible" - ] - } - }, - { - "id": "DORA-Art10", - "name": "Detection", - "description": "Financial entities shall have in place mechanisms to promptly detect anomalous activities, including ICT network performance issues and ICT-related incidents, and to identify potential single points of failure. Threat detection across compute, identity, storage and the API control plane is required for timely detection.", - "attributes": { - "Pillar": "ICT Risk Management", - "Article": "Article 10", - "ArticleTitle": "Detection" - }, - "checks": { - "aws": [ - "guardduty_is_enabled", - "guardduty_no_high_severity_findings", - "guardduty_ec2_malware_protection_enabled", - "guardduty_lambda_protection_enabled", - "guardduty_rds_protection_enabled", - "guardduty_s3_protection_enabled", - "guardduty_eks_audit_log_enabled", - "guardduty_eks_runtime_monitoring_enabled", - "securityhub_enabled", - "cloudtrail_threat_detection_enumeration", - "cloudtrail_threat_detection_llm_jacking", - "cloudtrail_threat_detection_privilege_escalation", - "cloudtrail_insights_exist", - "inspector2_is_enabled", - "inspector2_active_findings_exist", - "ec2_elastic_ip_shodan" - ] - } - }, - { - "id": "DORA-Art11", - "name": "Response and recovery", - "description": "Financial entities shall put in place a comprehensive ICT business continuity policy, including ICT response and recovery plans, that ensures the continuity of ICT-supported critical or important functions. Operational alarming, automated event routing and tested recovery actions are essential.", - "attributes": { - "Pillar": "ICT Risk Management", - "Article": "Article 11", - "ArticleTitle": "Response and recovery" - }, - "checks": { - "aws": [ - "cloudwatch_alarm_actions_enabled", - "cloudwatch_alarm_actions_alarm_state_configured", - "eventbridge_global_endpoint_event_replication_enabled", - "sns_subscription_not_using_http_endpoints", - "backup_plans_exist", - "backup_vaults_exist", - "rds_instance_critical_event_subscription", - "rds_cluster_critical_event_subscription" - ] - } - }, - { - "id": "DORA-Art12", - "name": "Backup policies and procedures, restoration and recovery procedures and methods", - "description": "Financial entities shall develop and document backup policies and procedures specifying the scope of data subject to backup and the minimum frequency of the backup, as well as restoration and recovery procedures and methods. Backups must be encrypted, retained, and resources must be designed for recoverability across availability zones and regions.", - "attributes": { - "Pillar": "ICT Risk Management", - "Article": "Article 12", - "ArticleTitle": "Backup policies and procedures, restoration and recovery procedures and methods" - }, - "checks": { - "aws": [ - "backup_plans_exist", - "backup_vaults_exist", - "backup_vaults_encrypted", - "backup_recovery_point_encrypted", - "backup_reportplans_exist", - "rds_instance_backup_enabled", - "rds_cluster_protected_by_backup_plan", - "rds_instance_protected_by_backup_plan", - "rds_instance_multi_az", - "rds_cluster_multi_az", - "rds_cluster_backtrack_enabled", - "rds_instance_deletion_protection", - "rds_cluster_deletion_protection", - "rds_snapshots_encrypted", - "s3_bucket_object_versioning", - "s3_bucket_object_lock", - "s3_bucket_cross_region_replication", - "s3_bucket_no_mfa_delete", - "dynamodb_tables_pitr_enabled", - "dynamodb_table_deletion_protection_enabled", - "ec2_ebs_volume_protected_by_backup_plan", - "ec2_ebs_volume_snapshots_exists", - "autoscaling_group_multiple_az", - "elb_is_in_multiple_az", - "elbv2_is_in_multiple_az", - "cloudfront_distributions_multiple_origin_failover_configured", - "dynamodb_table_protected_by_backup_plan" - ] - } - }, - { - "id": "DORA-Art13", - "name": "Learning and evolving", - "description": "Financial entities shall have in place capabilities and staff to gather information on vulnerabilities and cyber threats, perform post ICT-related incident reviews, and continuously feed lessons learnt back into the ICT risk assessment process. Findings aggregation and continuous insights drive this cycle.", - "attributes": { - "Pillar": "ICT Risk Management", - "Article": "Article 13", - "ArticleTitle": "Learning and evolving" - }, - "checks": { - "aws": [ - "securityhub_enabled", - "guardduty_no_high_severity_findings", - "inspector2_active_findings_exist", - "accessanalyzer_enabled_without_findings", - "cloudtrail_insights_exist" - ] - } - }, - { - "id": "DORA-Art14", - "name": "Communication", - "description": "As part of the ICT risk management framework, financial entities shall have in place crisis communication plans enabling a responsible disclosure of ICT-related incidents or major vulnerabilities to clients, counterparts and the public. Reliable, encrypted and access-controlled notification channels are required.", - "attributes": { - "Pillar": "ICT Risk Management", - "Article": "Article 14", - "ArticleTitle": "Communication" - }, - "checks": { - "aws": [ - "sns_topics_kms_encryption_at_rest_enabled", - "sns_topics_not_publicly_accessible", - "sns_subscription_not_using_http_endpoints", - "eventbridge_bus_exposed", - "eventbridge_bus_cross_account_access", - "eventbridge_schema_registry_cross_account_access", - "cloudwatch_alarm_actions_enabled", - "cloudwatch_alarm_actions_alarm_state_configured" - ] - } - }, - { - "id": "DORA-Art17", - "name": "ICT-related incident management process", - "description": "Financial entities shall define, establish and implement an ICT-related incident management process to detect, manage and notify ICT-related incidents. Comprehensive trail logging, log integrity protection, retention and centralisation of ICT events are foundational requirements.", - "attributes": { - "Pillar": "ICT-Related Incident Reporting", - "Article": "Article 17", - "ArticleTitle": "ICT-related incident management process" - }, - "checks": { - "aws": [ - "cloudtrail_multi_region_enabled", - "cloudtrail_multi_region_enabled_logging_management_events", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_log_file_validation_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_bucket_requires_mfa_delete", - "cloudtrail_bedrock_logging_enabled", - "cloudwatch_log_group_retention_policy_specific_days_enabled", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_no_secrets_in_logs", - "cloudwatch_log_group_not_publicly_accessible", - "vpc_flow_logs_enabled", - "ec2_client_vpn_endpoint_connection_logging_enabled", - "route53_public_hosted_zones_cloudwatch_logging_enabled", - "elb_logging_enabled", - "elbv2_logging_enabled", - "cloudfront_distributions_logging_enabled", - "s3_bucket_server_access_logging_enabled" - ] - } - }, - { - "id": "DORA-Art18", - "name": "Classification of ICT-related incidents and cyber threats", - "description": "Financial entities shall classify ICT-related incidents and shall determine their impact based on criteria such as the number of clients affected, duration, geographical spread, data losses, and criticality of the services affected. Severity-aware threat detection across the estate underpins this classification.", - "attributes": { - "Pillar": "ICT-Related Incident Reporting", - "Article": "Article 18", - "ArticleTitle": "Classification of ICT-related incidents and cyber threats" - }, - "checks": { - "aws": [ - "guardduty_no_high_severity_findings", - "guardduty_centrally_managed", - "guardduty_delegated_admin_enabled_all_regions", - "securityhub_enabled", - "inspector2_active_findings_exist", - "accessanalyzer_enabled_without_findings", - "cloudtrail_threat_detection_enumeration", - "cloudtrail_threat_detection_llm_jacking", - "cloudtrail_threat_detection_privilege_escalation" - ] - } - }, - { - "id": "DORA-Art19", - "name": "Reporting of major ICT-related incidents and voluntary notification of significant cyber threats", - "description": "Financial entities shall report major ICT-related incidents to the relevant competent authority and may, on a voluntary basis, notify significant cyber threats. Detective metric filters, change-tracking alarms and reliable notification topics are needed to surface and route reportable events.", - "attributes": { - "Pillar": "ICT-Related Incident Reporting", - "Article": "Article 19", - "ArticleTitle": "Reporting of major ICT-related incidents and voluntary notification of significant cyber threats" - }, - "checks": { - "aws": [ - "cloudwatch_log_metric_filter_authentication_failures", - "cloudwatch_log_metric_filter_unauthorized_api_calls", - "cloudwatch_log_metric_filter_root_usage", - "cloudwatch_log_metric_filter_sign_in_without_mfa", - "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", - "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", - "cloudwatch_log_metric_filter_policy_changes", - "cloudwatch_log_metric_filter_security_group_changes", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_changes_to_vpcs_alarm_configured", - "sns_subscription_not_using_http_endpoints" - ] - } - }, - { - "id": "DORA-Art24", - "name": "General requirements for the performance of digital operational resilience testing", - "description": "Financial entities shall establish, maintain and review a sound and comprehensive digital operational resilience testing programme, as an integral part of the ICT risk management framework. Continuous vulnerability discovery, configuration assessment and instance manageability are foundational.", - "attributes": { - "Pillar": "Digital Operational Resilience Testing", - "Article": "Article 24", - "ArticleTitle": "General requirements for the performance of digital operational resilience testing" - }, - "checks": { - "aws": [ - "inspector2_is_enabled", - "inspector2_active_findings_exist", - "securityhub_enabled", - "ec2_instance_managed_by_ssm", - "ec2_instance_with_outdated_ami", - "ssm_managed_compliant_patching" - ] - } - }, - { - "id": "DORA-Art25", - "name": "Testing of ICT tools and systems", - "description": "Financial entities shall ensure that tests are undertaken on ICT tools and systems, on critical ICT systems supporting all critical or important functions, at least yearly. Vulnerability assessments, deprecated component detection and certificate hygiene must be tracked.", - "attributes": { - "Pillar": "Digital Operational Resilience Testing", - "Article": "Article 25", - "ArticleTitle": "Testing of ICT tools and systems" - }, - "checks": { - "aws": [ - "inspector2_is_enabled", - "inspector2_active_findings_exist", - "guardduty_is_enabled", - "guardduty_no_high_severity_findings", - "config_recorder_all_regions_enabled", - "ec2_instance_with_outdated_ami", - "ec2_instance_managed_by_ssm", - "ec2_instance_paravirtual_type", - "rds_instance_deprecated_engine_version", - "acm_certificates_expiration_check", - "rds_instance_certificate_expiration", - "iam_no_expired_server_certificates_stored", - "ssm_managed_compliant_patching" - ] - } - }, - { - "id": "DORA-Art28", - "name": "General principles (ICT third-party risk)", - "description": "Financial entities shall manage ICT third-party risk as an integral component of ICT risk within their ICT risk management framework. Cross-account access, trust boundaries, organization-level controls and dependency visibility are critical to monitor third-party exposure on AWS.", - "attributes": { - "Pillar": "ICT Third-Party Risk Management", - "Article": "Article 28", - "ArticleTitle": "General principles (ICT third-party risk)" - }, - "checks": { - "aws": [ - "iam_role_cross_service_confused_deputy_prevention", - "iam_role_cross_account_readonlyaccess_policy", - "iam_no_custom_policy_permissive_role_assumption", - "accessanalyzer_enabled", - "accessanalyzer_enabled_without_findings", - "s3_bucket_cross_account_access", - "dynamodb_table_cross_account_access", - "eventbridge_bus_cross_account_access", - "eventbridge_schema_registry_cross_account_access", - "cloudwatch_cross_account_sharing_disabled", - "organizations_delegated_administrators", - "organizations_account_part_of_organizations", - "organizations_scp_check_deny_regions", - "vpc_endpoint_connections_trust_boundaries", - "vpc_endpoint_services_allowed_principals_trust_boundaries", - "vpc_peering_routing_tables_with_least_privilege", - "awslambda_function_using_cross_account_layers" - ] - } - }, - { - "id": "DORA-Art30", - "name": "Key contractual provisions", - "description": "Contractual arrangements with ICT third-party service providers shall be set out in writing and include, at minimum, agreed service levels and clear allocation of rights and obligations. Privilege boundaries, least-privilege policies and absence of administrative wildcards are the technical guardrails that enforce these contractual constraints inside AWS.", - "attributes": { - "Pillar": "ICT Third-Party Risk Management", - "Article": "Article 30", - "ArticleTitle": "Key contractual provisions" - }, - "checks": { - "aws": [ - "iam_aws_attached_policy_no_administrative_privileges", - "iam_customer_attached_policy_no_administrative_privileges", - "iam_customer_unattached_policy_no_administrative_privileges", - "iam_inline_policy_no_administrative_privileges", - "iam_inline_policy_allows_privilege_escalation", - "iam_policy_allows_privilege_escalation", - "iam_inline_policy_no_full_access_to_cloudtrail", - "iam_inline_policy_no_full_access_to_kms", - "iam_policy_no_full_access_to_cloudtrail", - "iam_policy_no_full_access_to_kms", - "iam_role_administratoraccess_policy", - "iam_user_administrator_access_policy", - "iam_group_administrator_access_policy", - "iam_administrator_access_with_mfa", - "iam_policy_attached_only_to_group_or_roles", - "accessanalyzer_enabled" - ] - } - }, - { - "id": "DORA-Art45", - "name": "Information-sharing arrangements on cyber threat information and intelligence", - "description": "Financial entities may exchange amongst themselves cyber threat information and intelligence, including indicators of compromise, tactics, techniques and procedures, cyber security alerts and configuration tools. Centralised threat detection, sensitive data discovery and trail-based intelligence enable participation in such information-sharing arrangements.", - "attributes": { - "Pillar": "Information Sharing", - "Article": "Article 45", - "ArticleTitle": "Information-sharing arrangements on cyber threat information and intelligence" - }, - "checks": { - "aws": [ - "guardduty_is_enabled", - "guardduty_centrally_managed", - "securityhub_enabled", - "macie_is_enabled", - "macie_automated_sensitive_data_discovery_enabled", - "cloudtrail_threat_detection_enumeration", - "cloudtrail_threat_detection_llm_jacking", - "cloudtrail_threat_detection_privilege_escalation", - "accessanalyzer_enabled_without_findings" - ] - } - } - ] -} diff --git a/prowler/compliance/dora_2022_2554.json b/prowler/compliance/dora_2022_2554.json new file mode 100644 index 0000000000..e4b5fd69df --- /dev/null +++ b/prowler/compliance/dora_2022_2554.json @@ -0,0 +1,1251 @@ +{ + "framework": "DORA", + "name": "Digital Operational Resilience Act (Regulation (EU) 2022/2554)", + "version": "2022/2554", + "description": "The Digital Operational Resilience Act (DORA) is a European Union regulation (Regulation (EU) 2022/2554) that sets a uniform framework for the digital operational resilience of the EU financial sector. Mandatory since 17 January 2025, it applies to financial entities (banks, insurers, investment firms, payment institutions, etc.) and to ICT third-party service providers. DORA is structured around five pillars: ICT risk management, ICT-related incident reporting, digital operational resilience testing, ICT third-party risk management, and information sharing. This Prowler mapping covers the technical controls auditable from cloud configuration; the organisational, contractual and supervisory obligations defined in DORA must be addressed outside of Prowler.", + "icon": "dora", + "attributes_metadata": [ + { + "key": "Pillar", + "label": "Pillar", + "type": "str", + "required": true, + "enum": [ + "ICT Risk Management", + "ICT-Related Incident Reporting", + "Digital Operational Resilience Testing", + "ICT Third-Party Risk Management", + "Information Sharing" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "Article", + "label": "Article", + "type": "str", + "required": true, + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "ArticleTitle", + "label": "Article Title", + "type": "str", + "required": true, + "output_formats": { + "csv": true, + "ocsf": true + } + } + ], + "outputs": { + "table_config": { + "group_by": "Pillar" + }, + "pdf_config": { + "language": "en", + "primary_color": "#003399", + "secondary_color": "#0055A5", + "bg_color": "#F0F4FA", + "group_by_field": "Pillar", + "sections": [ + "ICT Risk Management", + "ICT-Related Incident Reporting", + "Digital Operational Resilience Testing", + "ICT Third-Party Risk Management", + "Information Sharing" + ], + "section_short_names": { + "ICT Risk Management": "ICT Risk Mgmt", + "ICT-Related Incident Reporting": "Incident Reporting", + "Digital Operational Resilience Testing": "Resilience Testing", + "ICT Third-Party Risk Management": "Third-Party Risk", + "Information Sharing": "Info Sharing" + }, + "charts": [ + { + "id": "pillar_compliance", + "type": "horizontal_bar", + "group_by": "Pillar", + "title": "Compliance Score by DORA Pillar", + "y_label": "Pillar", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { + "only_failed": true, + "include_manual": false + } + } + }, + "requirements": [ + { + "id": "DORA-Art5", + "name": "Governance and organisation", + "description": "Financial entities shall have a sound, comprehensive and well-documented ICT internal governance and control framework. Senior management is accountable for ICT risk and shall enforce strong identity, authentication and least-privilege policies for privileged identities, including the root account.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 5", + "ArticleTitle": "Governance and organisation" + }, + "checks": { + "aws": [ + "iam_avoid_root_usage", + "iam_no_root_access_key", + "iam_root_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_root_credentials_management_enabled", + "iam_password_policy_minimum_length_14", + "iam_password_policy_lowercase", + "iam_password_policy_uppercase", + "iam_password_policy_number", + "iam_password_policy_symbol", + "iam_password_policy_reuse_24", + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_securityaudit_role_created", + "iam_support_role_created", + "organizations_account_part_of_organizations", + "iam_user_mfa_enabled_console_access", + "iam_user_hardware_mfa_enabled" + ], + "azure": [ + "entra_global_admin_in_less_than_five_users", + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa", + "entra_security_defaults_enabled", + "entra_conditional_access_policy_require_mfa_for_admin_portals", + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_users_cannot_create_microsoft_365_groups", + "iam_subscription_roles_owner_custom_not_created", + "iam_role_user_access_admin_restricted", + "iam_custom_role_has_permissions_to_administer_resource_locks" + ], + "gcp": [ + "compute_project_os_login_enabled", + "compute_project_os_login_2fa_enabled", + "iam_no_service_roles_at_project_level", + "iam_sa_no_administrative_privileges", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_organization_essential_contacts_configured", + "iam_account_access_approval_enabled", + "iam_service_account_unused", + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access", + "gke_cluster_no_default_service_account" + ], + "alibabacloud": [ + "ram_no_root_access_key", + "ram_user_mfa_enabled_console_access", + "ram_user_console_access_unused", + "ram_rotate_access_key_90_days", + "ram_password_policy_minimum_length", + "ram_password_policy_lowercase", + "ram_password_policy_uppercase", + "ram_password_policy_number", + "ram_password_policy_symbol", + "ram_password_policy_password_reuse_prevention", + "ram_password_policy_max_password_age", + "ram_password_policy_max_login_attempts", + "ram_policy_attached_only_to_group_or_roles", + "ram_policy_no_administrative_privileges" + ] + } + }, + { + "id": "DORA-Art6", + "name": "ICT risk management framework", + "description": "Financial entities shall have an ICT risk management framework that is sound, comprehensive and well-documented, enabling them to address ICT risk quickly, efficiently and comprehensively and to ensure a high level of digital operational resilience. This includes continuous configuration recording, security findings aggregation and an enterprise-wide visibility plane.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 6", + "ArticleTitle": "ICT risk management framework" + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "config_recorder_using_aws_service_role", + "securityhub_enabled", + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "organizations_delegated_administrators", + "guardduty_centrally_managed", + "guardduty_delegated_admin_enabled_all_regions" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_os_relational_databases_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_cosmosdb_is_on", + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "policy_ensure_asc_enforcement_enabled" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled", + "logging_sink_created", + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "iam_organization_essential_contacts_configured" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "securitycenter_all_assets_agent_installed", + "securitycenter_vulnerability_scan_enabled", + "actiontrail_multi_region_enabled" + ] + } + }, + { + "id": "DORA-Art7", + "name": "ICT systems, protocols and tools", + "description": "Financial entities shall use and maintain updated ICT systems, protocols and tools that are appropriate to the magnitude of operations supporting ICT functions, technologically resilient, and adequately equipped to securely process data. Cryptographic primitives, certificate hygiene and network segmentation are core to this requirement.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 7", + "ArticleTitle": "ICT systems, protocols and tools" + }, + "checks": { + "aws": [ + "acm_certificates_with_secure_key_algorithms", + "acm_certificates_transparency_logs_enabled", + "acm_certificates_expiration_check", + "ec2_ebs_default_encryption", + "kms_cmk_rotation_enabled", + "s3_bucket_secure_transport_policy", + "s3_bucket_default_encryption", + "s3_bucket_kms_encryption", + "vpc_subnet_separate_private_public", + "vpc_subnet_no_public_ip_by_default", + "elb_insecure_ssl_ciphers", + "elbv2_insecure_ssl_ciphers", + "elb_ssl_listeners", + "elbv2_ssl_listeners", + "cloudfront_distributions_using_deprecated_ssl_protocols", + "cloudfront_distributions_https_enabled", + "rds_instance_transport_encrypted" + ], + "azure": [ + "storage_ensure_minimum_tls_version_12", + "storage_secure_transfer_required_is_enabled", + "storage_smb_channel_encryption_with_secure_algorithm", + "storage_smb_protocol_version_is_latest", + "app_minimum_tls_version_12", + "app_ensure_http_is_redirected_to_https", + "app_ensure_using_http20", + "sqlserver_recommended_minimal_tls_version", + "mysql_flexible_server_minimum_tls_version_12", + "mysql_flexible_server_ssl_connection_enabled", + "postgresql_flexible_server_enforce_ssl_enabled", + "keyvault_key_rotation_enabled", + "storage_key_rotation_90_days", + "aks_network_policy_enabled" + ], + "gcp": [ + "cloudsql_instance_ssl_connections", + "compute_instance_encryption_with_csek_enabled", + "compute_instance_confidential_computing_enabled", + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "dataproc_encrypted_with_cmks_disabled", + "kms_key_rotation_enabled", + "kms_key_rotation_max_90_days", + "dns_dnssec_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec", + "compute_network_not_legacy", + "compute_network_default_in_use", + "compute_instance_single_network_interface" + ], + "cloudflare": [ + "zone_universal_ssl_enabled", + "zone_ssl_strict", + "zone_min_tls_version_secure", + "zone_tls_1_3_enabled", + "zone_hsts_enabled", + "zone_https_redirect_enabled", + "zone_automatic_https_rewrites_enabled", + "zone_development_mode_disabled", + "zone_record_caa_exists", + "zone_dnssec_enabled" + ], + "alibabacloud": [ + "oss_bucket_secure_transport_enabled", + "rds_instance_ssl_enabled", + "rds_instance_tde_enabled", + "rds_instance_tde_key_custom", + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted", + "ecs_instance_no_legacy_network" + ] + } + }, + { + "id": "DORA-Art8", + "name": "Identification", + "description": "Financial entities shall identify, classify and adequately document all ICT supported business functions, roles and responsibilities, the information assets and ICT assets supporting them, and their interdependencies. They shall on a continuous basis identify all sources of ICT risk, in particular the risk exposure to and from other financial entities.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 8", + "ArticleTitle": "Identification" + }, + "checks": { + "aws": [ + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "macie_is_enabled", + "macie_automated_sensitive_data_discovery_enabled", + "ec2_securitygroup_not_used", + "ec2_elastic_ip_unassigned", + "ec2_networkacl_unused", + "secretsmanager_secret_unused" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "network_watcher_enabled", + "network_public_ip_shodan", + "vm_scaleset_not_empty" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled", + "iam_service_account_unused", + "iam_sa_user_managed_key_unused", + "apikeys_key_exists", + "compute_instance_suspended_without_persistent_disks", + "compute_public_address_shodan" + ], + "cloudflare": [ + "dns_record_no_wildcard", + "dns_record_no_internal_ip", + "dns_record_cname_target_valid" + ], + "alibabacloud": [ + "securitycenter_all_assets_agent_installed", + "ram_user_console_access_unused" + ] + } + }, + { + "id": "DORA-Art9", + "name": "Protection and prevention", + "description": "Financial entities shall continuously monitor and control the security and functioning of ICT systems and tools and minimise the impact of ICT risk by deploying appropriate ICT security tools, policies and procedures. Encryption at rest and in transit, blocking of public exposure, network access controls, secret management and instance hardening are central to this article.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 9", + "ArticleTitle": "Protection and prevention" + }, + "checks": { + "aws": [ + "kms_key_not_publicly_accessible", + "ec2_ebs_volume_encryption", + "ec2_ebs_snapshots_encrypted", + "ec2_ebs_public_snapshot", + "ec2_ebs_snapshot_account_block_public_access", + "s3_account_level_public_access_blocks", + "s3_bucket_level_public_access_block", + "s3_bucket_public_access", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_write_acl", + "s3_bucket_public_list_acl", + "s3_bucket_acl_prohibited", + "s3_access_point_public_access_block", + "ec2_securitygroup_default_restrict_traffic", + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", + "rds_instance_storage_encrypted", + "rds_cluster_storage_encrypted", + "rds_instance_no_public_access", + "rds_snapshots_public_access", + "secretsmanager_not_publicly_accessible", + "secretsmanager_has_restrictive_resource_policy", + "secretsmanager_automatic_rotation_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", + "sns_topics_kms_encryption_at_rest_enabled", + "sns_topics_not_publicly_accessible", + "ec2_instance_imdsv2_enabled", + "ec2_instance_account_imdsv2_enabled", + "efs_encryption_at_rest_enabled", + "awslambda_function_not_publicly_accessible" + ], + "azure": [ + "storage_account_public_network_access_disabled", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied", + "storage_ensure_private_endpoints_in_storage_accounts", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "storage_account_key_access_disabled", + "storage_default_to_entra_authorization_enabled", + "containerregistry_not_publicly_accessible", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "cosmosdb_account_firewall_use_selected_networks", + "keyvault_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "keyvault_rbac_enabled", + "app_function_not_publicly_accessible", + "aisearch_service_not_publicly_accessible", + "aks_clusters_public_access_disabled", + "aks_clusters_created_with_private_nodes", + "sqlserver_unrestricted_inbound_access", + "postgresql_flexible_server_allow_access_services_disabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk", + "vm_ensure_using_managed_disks", + "vm_trusted_launch_enabled", + "vm_linux_enforce_ssh_authentication", + "vm_jit_access_enabled", + "sqlserver_tde_encryption_enabled", + "sqlserver_tde_encrypted_with_cmk", + "databricks_workspace_cmk_encryption_enabled", + "network_ssh_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_http_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_bastion_host_exists" + ], + "gcp": [ + "kms_key_not_publicly_accessible", + "bigquery_dataset_public_access", + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_uniform_bucket_level_access", + "cloudstorage_uses_vpc_service_controls", + "cloudsql_instance_public_access", + "cloudsql_instance_public_ip", + "cloudsql_instance_private_ip_assignment", + "cloudsql_instance_ssl_connections", + "cloudsql_instance_cmek_encryption_enabled", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_instance_public_ip", + "compute_instance_shielded_vm_enabled", + "compute_instance_confidential_computing_enabled", + "compute_instance_serial_ports_in_use", + "compute_instance_block_project_wide_ssh_keys_disabled", + "compute_instance_ip_forwarding_is_enabled", + "compute_instance_encryption_with_csek_enabled", + "compute_image_not_publicly_shared", + "dataproc_encrypted_with_cmks_disabled", + "kms_key_rotation_enabled", + "gke_cluster_no_default_service_account", + "cloudfunction_function_inside_vpc", + "apikeys_api_restrictions_configured", + "apikeys_api_restricted_with_gemini_api", + "cloudsql_instance_mysql_local_infile_flag", + "cloudsql_instance_mysql_skip_show_database_flag", + "cloudsql_instance_sqlserver_contained_database_authentication_flag", + "cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag", + "cloudsql_instance_sqlserver_external_scripts_enabled_flag", + "cloudsql_instance_sqlserver_remote_access_flag", + "cloudsql_instance_sqlserver_trace_flag", + "cloudsql_instance_sqlserver_user_connections_flag", + "cloudsql_instance_sqlserver_user_options_flag" + ], + "cloudflare": [ + "zone_universal_ssl_enabled", + "zone_ssl_strict", + "zone_min_tls_version_secure", + "zone_tls_1_3_enabled", + "zone_hsts_enabled", + "zone_https_redirect_enabled", + "zone_automatic_https_rewrites_enabled", + "zone_record_caa_exists", + "zone_dnssec_enabled", + "zone_waf_enabled", + "zone_waf_owasp_ruleset_enabled", + "zone_firewall_blocking_rules_configured", + "zone_browser_integrity_check_enabled", + "zone_bot_fight_mode_enabled", + "zone_rate_limiting_enabled", + "zone_challenge_passage_configured", + "zone_ip_geolocation_enabled", + "dns_record_proxied", + "dns_record_no_internal_ip", + "dns_record_no_wildcard", + "dns_record_cname_target_valid", + "zone_record_spf_exists", + "zone_record_dkim_exists", + "zone_record_dmarc_exists" + ], + "alibabacloud": [ + "oss_bucket_not_publicly_accessible", + "oss_bucket_secure_transport_enabled", + "actiontrail_oss_bucket_not_publicly_accessible", + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted", + "ecs_securitygroup_restrict_ssh_internet", + "ecs_securitygroup_restrict_rdp_internet", + "ecs_instance_endpoint_protection_installed", + "rds_instance_no_public_access_whitelist", + "rds_instance_ssl_enabled", + "rds_instance_tde_enabled", + "rds_instance_tde_key_custom", + "cs_kubernetes_network_policy_enabled", + "cs_kubernetes_eni_multiple_ip_enabled", + "cs_kubernetes_private_cluster_enabled", + "cs_kubernetes_rbac_enabled", + "cs_kubernetes_dashboard_disabled" + ] + } + }, + { + "id": "DORA-Art10", + "name": "Detection", + "description": "Financial entities shall have in place mechanisms to promptly detect anomalous activities, including ICT network performance issues and ICT-related incidents, and to identify potential single points of failure. Threat detection across compute, identity, storage and the API control plane is required for timely detection.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 10", + "ArticleTitle": "Detection" + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "guardduty_no_high_severity_findings", + "guardduty_ec2_malware_protection_enabled", + "guardduty_lambda_protection_enabled", + "guardduty_rds_protection_enabled", + "guardduty_s3_protection_enabled", + "guardduty_eks_audit_log_enabled", + "guardduty_eks_runtime_monitoring_enabled", + "securityhub_enabled", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation", + "cloudtrail_insights_exist", + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "ec2_elastic_ip_shodan" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_mcas_is_enabled", + "defender_container_images_scan_enabled", + "defender_container_images_resolved_vulnerabilities", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" + ], + "gcp": [ + "compute_public_address_shodan", + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_subnet_flow_logs_enabled", + "compute_network_dns_logging_enabled", + "cloudstorage_bucket_logging_enabled", + "compute_loadbalancer_logging_enabled", + "logging_sink_created" + ], + "cloudflare": [ + "zone_bot_fight_mode_enabled", + "zone_browser_integrity_check_enabled", + "zone_rate_limiting_enabled" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "securitycenter_notification_enabled_high_risk", + "securitycenter_all_assets_agent_installed", + "ecs_instance_endpoint_protection_installed", + "cs_kubernetes_cloudmonitor_enabled" + ] + } + }, + { + "id": "DORA-Art11", + "name": "Response and recovery", + "description": "Financial entities shall put in place a comprehensive ICT business continuity policy, including ICT response and recovery plans, that ensures the continuity of ICT-supported critical or important functions. Operational alarming, automated event routing and tested recovery actions are essential.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 11", + "ArticleTitle": "Response and recovery" + }, + "checks": { + "aws": [ + "cloudwatch_alarm_actions_enabled", + "cloudwatch_alarm_actions_alarm_state_configured", + "eventbridge_global_endpoint_event_replication_enabled", + "sns_subscription_not_using_http_endpoints", + "backup_plans_exist", + "backup_vaults_exist", + "rds_instance_critical_event_subscription", + "rds_cluster_critical_event_subscription" + ], + "azure": [ + "monitor_alert_service_health_exists", + "monitor_alert_create_update_security_solution", + "monitor_alert_delete_security_solution", + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_attack_path_notifications_properly_configured", + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" + ], + "gcp": [ + "compute_instance_automatic_restart_enabled", + "compute_instance_group_autohealing_enabled", + "compute_instance_on_host_maintenance_migrate", + "cloudsql_instance_high_availability_enabled", + "cloudsql_instance_automated_backups", + "compute_instance_deletion_protection_enabled", + "compute_instance_group_load_balancer_attached", + "compute_instance_preemptible_vm_disabled" + ], + "alibabacloud": [ + "securitycenter_notification_enabled_high_risk", + "cs_kubernetes_cluster_check_recent", + "cs_kubernetes_cluster_check_weekly" + ] + } + }, + { + "id": "DORA-Art12", + "name": "Backup policies and procedures, restoration and recovery procedures and methods", + "description": "Financial entities shall develop and document backup policies and procedures specifying the scope of data subject to backup and the minimum frequency of the backup, as well as restoration and recovery procedures and methods. Backups must be encrypted, retained, and resources must be designed for recoverability across availability zones and regions.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 12", + "ArticleTitle": "Backup policies and procedures, restoration and recovery procedures and methods" + }, + "checks": { + "aws": [ + "backup_plans_exist", + "backup_vaults_exist", + "backup_vaults_encrypted", + "backup_recovery_point_encrypted", + "backup_reportplans_exist", + "rds_instance_backup_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_protected_by_backup_plan", + "rds_instance_multi_az", + "rds_cluster_multi_az", + "rds_cluster_backtrack_enabled", + "rds_instance_deletion_protection", + "rds_cluster_deletion_protection", + "rds_snapshots_encrypted", + "s3_bucket_object_versioning", + "s3_bucket_object_lock", + "s3_bucket_cross_region_replication", + "s3_bucket_no_mfa_delete", + "dynamodb_tables_pitr_enabled", + "dynamodb_table_deletion_protection_enabled", + "ec2_ebs_volume_protected_by_backup_plan", + "ec2_ebs_volume_snapshots_exists", + "autoscaling_group_multiple_az", + "elb_is_in_multiple_az", + "elbv2_is_in_multiple_az", + "cloudfront_distributions_multiple_origin_failover_configured", + "dynamodb_table_protected_by_backup_plan" + ], + "azure": [ + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period", + "vm_ensure_using_managed_disks", + "storage_ensure_soft_delete_is_enabled", + "storage_ensure_file_shares_soft_delete_is_enabled", + "storage_blob_versioning_is_enabled", + "storage_geo_redundant_enabled", + "keyvault_recoverable" + ], + "gcp": [ + "cloudsql_instance_automated_backups", + "cloudsql_instance_high_availability_enabled", + "cloudstorage_bucket_versioning_enabled", + "cloudstorage_bucket_soft_delete_enabled", + "cloudstorage_bucket_lifecycle_management_enabled", + "cloudstorage_bucket_sufficient_retention_period", + "compute_instance_group_multiple_zones", + "compute_instance_disk_auto_delete_disabled", + "compute_instance_deletion_protection_enabled", + "compute_snapshot_not_outdated", + "compute_instance_suspended_without_persistent_disks" + ] + } + }, + { + "id": "DORA-Art13", + "name": "Learning and evolving", + "description": "Financial entities shall have in place capabilities and staff to gather information on vulnerabilities and cyber threats, perform post ICT-related incident reviews, and continuously feed lessons learnt back into the ICT risk assessment process. Findings aggregation and continuous insights drive this cycle.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 13", + "ArticleTitle": "Learning and evolving" + }, + "checks": { + "aws": [ + "securityhub_enabled", + "guardduty_no_high_severity_findings", + "inspector2_active_findings_exist", + "accessanalyzer_enabled_without_findings", + "cloudtrail_insights_exist" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_assessments_vm_endpoint_protection_installed", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_system_updates_are_applied", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "iam_cloud_asset_inventory_enabled", + "logging_sink_created" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "securitycenter_all_assets_agent_installed", + "ecs_instance_latest_os_patches_applied" + ] + } + }, + { + "id": "DORA-Art14", + "name": "Communication", + "description": "As part of the ICT risk management framework, financial entities shall have in place crisis communication plans enabling a responsible disclosure of ICT-related incidents or major vulnerabilities to clients, counterparts and the public. Reliable, encrypted and access-controlled notification channels are required.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 14", + "ArticleTitle": "Communication" + }, + "checks": { + "aws": [ + "sns_topics_kms_encryption_at_rest_enabled", + "sns_topics_not_publicly_accessible", + "sns_subscription_not_using_http_endpoints", + "eventbridge_bus_exposed", + "eventbridge_bus_cross_account_access", + "eventbridge_schema_registry_cross_account_access", + "cloudwatch_alarm_actions_enabled", + "cloudwatch_alarm_actions_alarm_state_configured" + ], + "azure": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "defender_ensure_notify_alerts_severity_is_high", + "defender_attack_path_notifications_properly_configured", + "monitor_alert_service_health_exists" + ], + "gcp": [ + "iam_organization_essential_contacts_configured", + "logging_sink_created", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled" + ], + "alibabacloud": [ + "securitycenter_notification_enabled_high_risk" + ] + } + }, + { + "id": "DORA-Art17", + "name": "ICT-related incident management process", + "description": "Financial entities shall define, establish and implement an ICT-related incident management process to detect, manage and notify ICT-related incidents. Comprehensive trail logging, log integrity protection, retention and centralisation of ICT events are foundational requirements.", + "attributes": { + "Pillar": "ICT-Related Incident Reporting", + "Article": "Article 17", + "ArticleTitle": "ICT-related incident management process" + }, + "checks": { + "aws": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_kms_encryption_enabled", + "cloudtrail_log_file_validation_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "cloudtrail_bucket_requires_mfa_delete", + "cloudtrail_bedrock_logging_enabled", + "cloudwatch_log_group_retention_policy_specific_days_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "cloudwatch_log_group_no_secrets_in_logs", + "cloudwatch_log_group_not_publicly_accessible", + "vpc_flow_logs_enabled", + "ec2_client_vpn_endpoint_connection_logging_enabled", + "route53_public_hosted_zones_cloudwatch_logging_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "cloudfront_distributions_logging_enabled", + "s3_bucket_server_access_logging_enabled" + ], + "azure": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_storage_account_with_activity_logs_is_private", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "keyvault_logging_enabled", + "sqlserver_auditing_enabled", + "sqlserver_auditing_retention_90_days", + "mysql_flexible_server_audit_log_enabled", + "mysql_flexible_server_audit_log_connection_activated", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "postgresql_flexible_server_log_retention_days_greater_3", + "app_http_logs_enabled", + "app_function_application_insights_enabled", + "appinsights_ensure_is_configured" + ], + "gcp": [ + "iam_audit_logs_enabled", + "cloudstorage_audit_logs_enabled", + "logging_sink_created", + "cloudstorage_bucket_logging_enabled", + "cloudstorage_bucket_log_retention_policy_lock", + "cloudstorage_bucket_sufficient_retention_period", + "compute_subnet_flow_logs_enabled", + "compute_network_dns_logging_enabled", + "compute_loadbalancer_logging_enabled", + "cloudsql_instance_postgres_enable_pgaudit_flag", + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudsql_instance_postgres_log_statement_flag", + "cloudsql_instance_postgres_log_min_messages_flag", + "cloudsql_instance_postgres_log_error_verbosity_flag", + "cloudsql_instance_postgres_log_min_error_statement_flag", + "cloudsql_instance_postgres_log_min_duration_statement_flag" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "actiontrail_oss_bucket_not_publicly_accessible", + "oss_bucket_logging_enabled", + "sls_logstore_retention_period", + "vpc_flow_logs_enabled", + "cs_kubernetes_log_service_enabled", + "rds_instance_sql_audit_enabled", + "rds_instance_sql_audit_retention", + "rds_instance_postgresql_log_connections_enabled", + "rds_instance_postgresql_log_disconnections_enabled", + "rds_instance_postgresql_log_duration_enabled" + ] + } + }, + { + "id": "DORA-Art18", + "name": "Classification of ICT-related incidents and cyber threats", + "description": "Financial entities shall classify ICT-related incidents and shall determine their impact based on criteria such as the number of clients affected, duration, geographical spread, data losses, and criticality of the services affected. Severity-aware threat detection across the estate underpins this classification.", + "attributes": { + "Pillar": "ICT-Related Incident Reporting", + "Article": "Article 18", + "ArticleTitle": "Classification of ICT-related incidents and cyber threats" + }, + "checks": { + "aws": [ + "guardduty_no_high_severity_findings", + "guardduty_centrally_managed", + "guardduty_delegated_admin_enabled_all_regions", + "securityhub_enabled", + "inspector2_active_findings_exist", + "accessanalyzer_enabled_without_findings", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation" + ], + "azure": [ + "defender_ensure_notify_alerts_severity_is_high", + "defender_attack_path_notifications_properly_configured", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_mcas_is_enabled", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_public_address_shodan", + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled" + ], + "alibabacloud": [ + "securitycenter_notification_enabled_high_risk", + "securitycenter_vulnerability_scan_enabled" + ] + } + }, + { + "id": "DORA-Art19", + "name": "Reporting of major ICT-related incidents and voluntary notification of significant cyber threats", + "description": "Financial entities shall report major ICT-related incidents to the relevant competent authority and may, on a voluntary basis, notify significant cyber threats. Detective metric filters, change-tracking alarms and reliable notification topics are needed to surface and route reportable events.", + "attributes": { + "Pillar": "ICT-Related Incident Reporting", + "Article": "Article 19", + "ArticleTitle": "Reporting of major ICT-related incidents and voluntary notification of significant cyber threats" + }, + "checks": { + "aws": [ + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "sns_subscription_not_using_http_endpoints" + ], + "azure": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_delete_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_delete_nsg", + "monitor_alert_create_update_security_solution", + "monitor_alert_delete_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_sqlserver_fr", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_service_health_exists", + "defender_additional_email_configured_with_a_security_contact" + ], + "gcp": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "iam_organization_essential_contacts_configured", + "logging_sink_created" + ], + "alibabacloud": [ + "sls_management_console_authentication_failures_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled", + "sls_root_account_usage_alert_enabled", + "sls_unauthorized_api_calls_alert_enabled", + "sls_ram_role_changes_alert_enabled", + "sls_oss_bucket_policy_changes_alert_enabled", + "sls_oss_permission_changes_alert_enabled", + "sls_rds_instance_configuration_changes_alert_enabled", + "sls_security_group_changes_alert_enabled", + "sls_vpc_changes_alert_enabled", + "sls_vpc_network_route_changes_alert_enabled", + "sls_cloud_firewall_changes_alert_enabled", + "sls_customer_created_cmk_changes_alert_enabled" + ] + } + }, + { + "id": "DORA-Art24", + "name": "General requirements for the performance of digital operational resilience testing", + "description": "Financial entities shall establish, maintain and review a sound and comprehensive digital operational resilience testing programme, as an integral part of the ICT risk management framework. Continuous vulnerability discovery, configuration assessment and instance manageability are foundational.", + "attributes": { + "Pillar": "Digital Operational Resilience Testing", + "Article": "Article 24", + "ArticleTitle": "General requirements for the performance of digital operational resilience testing" + }, + "checks": { + "aws": [ + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "securityhub_enabled", + "ec2_instance_managed_by_ssm", + "ec2_instance_with_outdated_ami", + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_ensure_system_updates_are_applied", + "defender_container_images_scan_enabled", + "defender_assessments_vm_endpoint_protection_installed", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "vm_ensure_using_approved_images", + "vm_desired_sku_size" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_instance_shielded_vm_enabled", + "compute_snapshot_not_outdated", + "compute_public_address_shodan" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "securitycenter_advanced_or_enterprise_edition", + "ecs_instance_latest_os_patches_applied", + "cs_kubernetes_cluster_check_recent", + "cs_kubernetes_cluster_check_weekly" + ] + } + }, + { + "id": "DORA-Art25", + "name": "Testing of ICT tools and systems", + "description": "Financial entities shall ensure that tests are undertaken on ICT tools and systems, on critical ICT systems supporting all critical or important functions, at least yearly. Vulnerability assessments, deprecated component detection and certificate hygiene must be tracked.", + "attributes": { + "Pillar": "Digital Operational Resilience Testing", + "Article": "Article 25", + "ArticleTitle": "Testing of ICT tools and systems" + }, + "checks": { + "aws": [ + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "guardduty_is_enabled", + "guardduty_no_high_severity_findings", + "config_recorder_all_regions_enabled", + "ec2_instance_with_outdated_ami", + "ec2_instance_managed_by_ssm", + "ec2_instance_paravirtual_type", + "rds_instance_deprecated_engine_version", + "acm_certificates_expiration_check", + "rds_instance_certificate_expiration", + "iam_no_expired_server_certificates_stored", + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured", + "sqlserver_va_emails_notifications_admins_enabled", + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_rbac_key_expiration_set", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_rbac_secret_expiration_set", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_function_latest_runtime_version", + "storage_smb_protocol_version_is_latest" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_snapshot_not_outdated", + "compute_network_not_legacy", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec", + "apikeys_key_rotated_in_90_days", + "iam_sa_user_managed_key_rotate_90_days" + ], + "cloudflare": [ + "zone_min_tls_version_secure" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "ecs_instance_latest_os_patches_applied", + "ecs_instance_no_legacy_network" + ] + } + }, + { + "id": "DORA-Art28", + "name": "General principles (ICT third-party risk)", + "description": "Financial entities shall manage ICT third-party risk as an integral component of ICT risk within their ICT risk management framework. Cross-account access, trust boundaries, organization-level controls and dependency visibility are critical to monitor third-party exposure on AWS.", + "attributes": { + "Pillar": "ICT Third-Party Risk Management", + "Article": "Article 28", + "ArticleTitle": "General principles (ICT third-party risk)" + }, + "checks": { + "aws": [ + "iam_role_cross_service_confused_deputy_prevention", + "iam_role_cross_account_readonlyaccess_policy", + "iam_no_custom_policy_permissive_role_assumption", + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "s3_bucket_cross_account_access", + "dynamodb_table_cross_account_access", + "eventbridge_bus_cross_account_access", + "eventbridge_schema_registry_cross_account_access", + "cloudwatch_cross_account_sharing_disabled", + "organizations_delegated_administrators", + "organizations_account_part_of_organizations", + "organizations_scp_check_deny_regions", + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "vpc_peering_routing_tables_with_least_privilege", + "awslambda_function_using_cross_account_layers" + ], + "azure": [ + "entra_policy_guest_users_access_restrictions", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "storage_cross_tenant_replication_disabled", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "aks_clusters_created_with_private_nodes" + ], + "gcp": [ + "bigquery_dataset_public_access", + "cloudstorage_bucket_public_access", + "kms_key_not_publicly_accessible", + "cloudstorage_uses_vpc_service_controls", + "cloudfunction_function_inside_vpc", + "iam_sa_no_user_managed_keys", + "iam_sa_no_administrative_privileges", + "iam_no_service_roles_at_project_level", + "apikeys_api_restrictions_configured", + "apikeys_api_restricted_with_gemini_api", + "compute_image_not_publicly_shared", + "iam_cloud_asset_inventory_enabled" + ], + "cloudflare": [ + "zone_record_caa_exists", + "dns_record_cname_target_valid" + ], + "alibabacloud": [ + "ram_policy_no_administrative_privileges", + "oss_bucket_not_publicly_accessible", + "actiontrail_oss_bucket_not_publicly_accessible" + ] + } + }, + { + "id": "DORA-Art30", + "name": "Key contractual provisions", + "description": "Contractual arrangements with ICT third-party service providers shall be set out in writing and include, at minimum, agreed service levels and clear allocation of rights and obligations. Privilege boundaries, least-privilege policies and absence of administrative wildcards are the technical guardrails that enforce these contractual constraints inside AWS.", + "attributes": { + "Pillar": "ICT Third-Party Risk Management", + "Article": "Article 30", + "ArticleTitle": "Key contractual provisions" + }, + "checks": { + "aws": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_customer_unattached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_inline_policy_allows_privilege_escalation", + "iam_policy_allows_privilege_escalation", + "iam_inline_policy_no_full_access_to_cloudtrail", + "iam_inline_policy_no_full_access_to_kms", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms", + "iam_role_administratoraccess_policy", + "iam_user_administrator_access_policy", + "iam_group_administrator_access_policy", + "iam_administrator_access_with_mfa", + "iam_policy_attached_only_to_group_or_roles", + "accessanalyzer_enabled" + ], + "azure": [ + "iam_subscription_roles_owner_custom_not_created", + "iam_role_user_access_admin_restricted", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "entra_global_admin_in_less_than_five_users", + "app_function_identity_without_admin_privileges", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps" + ], + "gcp": [ + "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", + "iam_sa_no_user_managed_keys", + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access", + "gke_cluster_no_default_service_account", + "iam_account_access_approval_enabled", + "apikeys_api_restrictions_configured" + ], + "alibabacloud": [ + "ram_policy_no_administrative_privileges", + "ram_policy_attached_only_to_group_or_roles", + "ram_no_root_access_key" + ] + } + }, + { + "id": "DORA-Art45", + "name": "Information-sharing arrangements on cyber threat information and intelligence", + "description": "Financial entities may exchange amongst themselves cyber threat information and intelligence, including indicators of compromise, tactics, techniques and procedures, cyber security alerts and configuration tools. Centralised threat detection, sensitive data discovery and trail-based intelligence enable participation in such information-sharing arrangements.", + "attributes": { + "Pillar": "Information Sharing", + "Article": "Article 45", + "ArticleTitle": "Information-sharing arrangements on cyber threat information and intelligence" + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "guardduty_centrally_managed", + "securityhub_enabled", + "macie_is_enabled", + "macie_automated_sensitive_data_discovery_enabled", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation", + "accessanalyzer_enabled_without_findings" + ], + "azure": [ + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_defender_for_server_is_on", + "defender_attack_path_notifications_properly_configured", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled", + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_public_address_shodan", + "logging_sink_created" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "securitycenter_notification_enabled_high_risk", + "actiontrail_multi_region_enabled", + "sls_logstore_retention_period" + ] + } + } + ] +} diff --git a/prowler/compliance/linode/__init__.py b/prowler/compliance/linode/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/config/config.py b/prowler/config/config.py index 9e2b079da6..78b48756fe 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.31.0" +prowler_version = "5.32.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" @@ -80,6 +80,7 @@ class Provider(str, Enum): VERCEL = "vercel" OKTA = "okta" STACKIT = "stackit" + LINODE = "linode" # Compliance @@ -288,6 +289,11 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict: Returns: dict: The configuration dictionary for the specified provider. """ + # Imported lazily to avoid an import cycle: schemas may eventually want to + # import from prowler.config.config (e.g. for shared constants). + from prowler.config.schema.registry import SCHEMAS + from prowler.config.schema.validator import validate_provider_config + try: with open(config_file_path, "r", encoding=encoding_format_utf_8) as f: config_file = yaml.safe_load(f) @@ -313,7 +319,11 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict: else: config = {} - return config + return validate_provider_config( + provider=provider, + raw=config, + schema_cls=SCHEMAS.get(provider), + ) except FileNotFoundError as error: logger.error( diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 28f07c2051..9d41a0e592 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -380,6 +380,39 @@ aws: # Minimum number of Availability Zones that an ELBv2 must be in elbv2_min_azs: 2 + # AWS Post-Quantum TLS Configuration + # aws.acmpca_certificate_authority_pqc_key_algorithm + # Allowed post-quantum key algorithms for AWS Private CA certificate authorities + acmpca_pqc_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" + # aws.cloudfront_distributions_pqc_tls_enabled + # Allowed CloudFront MinimumProtocolVersion values that enable post-quantum hybrid key exchange + cloudfront_pqc_min_protocol_versions: + - "TLSv1.3_2025" + # aws.apigateway_domain_name_pqc_tls_enabled + # Allowed post-quantum TLS security policies for API Gateway custom domain names + apigateway_pqc_tls_allowed_policies: + - "SecurityPolicy_TLS13_1_2_FIPS_PFS_PQ_2025_09" + - "SecurityPolicy_TLS13_1_2_PFS_PQ_2025_09" + - "SecurityPolicy_TLS13_1_2_PQ_2025_09" + + # aws.rolesanywhere_trust_anchor_pqc_pki + # Allowed post-quantum key algorithms for AWS Private CAs backing IAM Roles Anywhere trust anchors + rolesanywhere_pqc_pca_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" + + # AWS Post-Quantum SSH Key Exchange Configuration + # aws.transfer_server_pqc_ssh_kex_enabled + # Allowed AWS Transfer Family security policies with post-quantum SSH key exchange + transfer_pqc_ssh_allowed_policies: + - "TransferSecurityPolicy-2025-03" + - "TransferSecurityPolicy-FIPS-2025-03" + - "TransferSecurityPolicy-AS2Restricted-2025-07" + # AWS Elasticache Configuration # aws.elasticache_redis_cluster_backup_enabled # Minimum number of days that a Redis cluster must have backups retention period @@ -549,6 +582,9 @@ gcp: # GCP Storage Sufficient Retention Period # gcp.cloudstorage_bucket_sufficient_retention_period storage_min_retention_days: 90 + # GCP Secret Manager Rotation Period + # gcp.secretmanager_secret_rotation_enabled + secretmanager_max_rotation_days: 90 # Kubernetes Configuration kubernetes: diff --git a/prowler/config/linode_mutelist_example.yaml b/prowler/config/linode_mutelist_example.yaml new file mode 100644 index 0000000000..1b08ab6fda --- /dev/null +++ b/prowler/config/linode_mutelist_example.yaml @@ -0,0 +1,18 @@ +### Account, Check and/or Region can be * to apply for all the cases. +### Account == +### Region == * (Linode is non-regional) +### Resources and tags are lists that can have either Regex or Keywords. +### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together. +### Use an alternation Regex to match one of multiple tags with "ORed" logic. +### For each check you can except Accounts, Regions, Resources and/or Tags. +########################### MUTELIST EXAMPLE ########################### +Mutelist: + Accounts: + "example-account-uuid": + Checks: + "administration_user_2fa_enabled": + Regions: + - "*" + Resources: + - "example-user@example.com" + - "another-user@example.com" diff --git a/prowler/config/scan_config_schema.py b/prowler/config/scan_config_schema.py new file mode 100644 index 0000000000..ac00250c78 --- /dev/null +++ b/prowler/config/scan_config_schema.py @@ -0,0 +1,116 @@ +"""Bridge between the Pydantic-based provider schemas in +`prowler.config.schema` and the Prowler App backend (Django) + UI. + +The SDK runtime is intentionally LENIENT: invalid keys are dropped with a +warning and downstream checks fall back to their defaults +(`prowler.config.schema.validator.validate_provider_config`). + +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`. + +- `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. +""" + +from typing import Any + +from pydantic import ValidationError + +from prowler.config.schema.registry import SCHEMAS + + +def _format_loc(loc: tuple) -> str: + """Render a Pydantic error location as a dot-separated path. + + Integer elements (array indices) are formatted as `[idx]` appended to the + previous component. String elements are joined with dots. An empty location + is rendered as ``. + + Examples: + ("aws", "regions", 0) -> "aws.regions[0]" + ("aws", "threshold") -> "aws.threshold" + () -> "" + """ + parts: list[str] = [] + for piece in loc: + if isinstance(piece, int): + if parts: + parts[-1] = f"{parts[-1]}[{piece}]" + else: + parts.append(f"[{piece}]") + else: + parts.append(str(piece)) + return ".".join(parts) if parts else "" + + +def validate_scan_config(payload: Any) -> list[dict]: + """Validate a scan config payload against the registered provider schemas. + + 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). + """ + if not isinstance(payload, dict): + return [ + { + "path": "", + "message": "Scan config must be a mapping with provider sections.", + } + ] + + errors: list[dict] = [] + for provider, section in payload.items(): + schema_cls = SCHEMAS.get(provider) + if schema_cls is None: + # Unknown provider type: tolerated. The SDK will simply ignore it. + continue + if not isinstance(section, dict): + errors.append( + { + "path": str(provider), + "message": "section must be a mapping.", + } + ) + continue + try: + 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"), + } + ) + return errors + + +def _build_aggregated_schema() -> dict: + """Compose one JSON Schema per provider into a single top-level schema. + + The output mirrors the layout of `prowler/config/config.yaml` (a mapping + keyed by provider type) and is what the UI consumes via `ajv`. + """ + properties: dict[str, dict] = {} + for provider, schema_cls in SCHEMAS.items(): + properties[provider] = schema_cls.model_json_schema() + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Prowler Scan Config", + "type": "object", + "additionalProperties": True, + "properties": properties, + } + + +SCAN_CONFIG_SCHEMA: dict = _build_aggregated_schema() diff --git a/prowler/config/schema/__init__.py b/prowler/config/schema/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/config/schema/aws.py b/prowler/config/schema/aws.py new file mode 100644 index 0000000000..1a44bb9bcc --- /dev/null +++ b/prowler/config/schema/aws.py @@ -0,0 +1,422 @@ +"""AWS provider config schema. + +Bounds on every field are intentionally conservative: they are not the +absolute service maxima but the values that produce a useful security +check. A user is free to keep the built-in default by omitting the key — +out-of-range values are dropped with a warning at SDK runtime, and +rejected at the Prowler App backend. + +Whenever an upper bound is uncertain, the cap is set to a value that +still keeps the check meaningful (e.g. a 10-year window for date-based +thresholds) and avoids ints that obviously break downstream maths +(`min_kinesis_stream_retention_hours = 99999`). +""" + +from typing import Annotated, Literal, Optional + +from pydantic import AfterValidator, Field + +from prowler.config.schema.base import ProviderConfigBase +from prowler.config.schema.validators import ( + make_dotted_version_validator, + validate_ip_networks, + validate_port_range, +) + +# ---- Reusable constants ----------------------------------------------------- + +# CloudWatch Logs only accepts these retention values (in days). Anything else +# is silently coerced to the next valid value by the API — we reject upfront. +_CLOUDWATCH_RETENTION_DAYS = ( + 1, + 3, + 5, + 7, + 14, + 30, + 60, + 90, + 120, + 150, + 180, + 365, + 400, + 545, + 731, + 1096, + 1827, + 2192, + 2557, + 2922, + 3288, + 3653, +) + +_VALID_CW_RETENTION_LITERAL = Literal[ + 1, + 3, + 5, + 7, + 14, + 30, + 60, + 90, + 120, + 150, + 180, + 365, + 400, + 545, + 731, + 1096, + 1827, + 2192, + 2557, + 2922, + 3288, + 3653, +] + + +# ---- Custom validators ------------------------------------------------------ + + +# Reusable validators shared across providers (see schema/validators.py). +_validate_port_range = validate_port_range +_validate_trusted_ips = validate_ip_networks +# "1.4.0" style strings (used by Fargate platform versions). +_validate_semver = make_dotted_version_validator(3, 3) +# "1.28" style strings (EKS minor versions). +_validate_eks_minor = make_dotted_version_validator(2, 2) + + +def _validate_account_ids(v: Optional[list[str]]) -> Optional[list[str]]: + if v is None: + return v + for account_id in v: + if not (account_id.isdigit() and len(account_id) == 12): + raise ValueError( + f"trusted_account_ids entry {account_id!r} is not a 12-digit AWS account id" + ) + return v + + +# ---- Nested models ---------------------------------------------------------- + + +class _DetectSecretsPlugin(ProviderConfigBase): + """One entry inside ``detect_secrets_plugins``. + + Only ``name`` is required by the upstream library. ``limit`` is used by + the entropy detectors. Any other plugin-specific kwarg is preserved by + the ``extra="allow"`` policy inherited from ProviderConfigBase. + """ + + name: str + limit: Optional[float] = Field( + default=None, + ge=0.0, + le=10.0, + description=( + "Entropy threshold for detect-secrets entropy plugins. Range: 0..10 " + "(Shannon entropy is bounded by log2(256)=8; >10 is meaningless)." + ), + ) + + +# ---- Main schema ------------------------------------------------------------ + + +class AWSProviderConfig(ProviderConfigBase): + # --- IAM --------------------------------------------------------------- + mute_non_default_regions: Optional[bool] = None + disallowed_regions: Optional[list[str]] = None + max_unused_access_keys_days: Optional[int] = Field( + default=None, + ge=30, + le=180, + description=( + "Days an IAM user access key can stay unused before being flagged. " + "Range: 30..180 days (CIS AWS 1.13 recommends 45; NIST IA-5 ≤90)." + ), + ) + max_console_access_days: Optional[int] = Field( + default=None, + ge=30, + le=180, + description=( + "Days an IAM console password can stay unused before being flagged. " + "Range: 30..180 days (CIS AWS 1.12 recommends 45)." + ), + ) + max_unused_sagemaker_access_days: Optional[int] = Field( + default=None, + ge=7, + le=180, + description=( + "Days a SageMaker user access key can stay unused. Range: 7..180 " + "(SageMaker tokens are usually high-privilege over S3/KMS)." + ), + ) + + # --- EC2 --------------------------------------------------------------- + shodan_api_key: Optional[str] = Field( + default=None, + max_length=512, + description="API key for Shodan lookups on EC2 public IPs.", + ) + max_security_group_rules: Optional[int] = Field( + default=None, + ge=1, + le=1000, + description="Max ingress+egress rules per security group. AWS hard limit is 1000.", + ) + max_ec2_instance_age_in_days: Optional[int] = Field( + default=None, + ge=1, + le=1095, + description=( + "Days an EC2 instance can run before being flagged as old. " + "Range: 1..1095 (3 years; instances should be refreshed for patching " + "per NIST CM-3 — anything older is a security smell)." + ), + ) + ec2_allowed_interface_types: Optional[list[str]] = None + ec2_allowed_instance_owners: Optional[list[str]] = None + ec2_high_risk_ports: Annotated[ + Optional[list[int]], AfterValidator(_validate_port_range) + ] = Field( + default=None, + description="TCP/UDP ports considered high-risk when reachable from the Internet (1..65535; port 0 is reserved).", + ) + + # --- ECS --------------------------------------------------------------- + fargate_linux_latest_version: Annotated[ + Optional[str], AfterValidator(_validate_semver) + ] = Field(default=None, description="Fargate Linux platform version (X.Y.Z).") + fargate_windows_latest_version: Annotated[ + Optional[str], AfterValidator(_validate_semver) + ] = Field(default=None, description="Fargate Windows platform version (X.Y.Z).") + + # --- Cross-account trust ---------------------------------------------- + trusted_account_ids: Annotated[ + Optional[list[str]], AfterValidator(_validate_account_ids) + ] = Field( + default=None, + description="Additional 12-digit AWS account IDs trusted by cross-account checks.", + ) + trusted_ips: Annotated[ + Optional[list[str]], AfterValidator(_validate_trusted_ips) + ] = Field( + default=None, + description="IPv4/IPv6 addresses or CIDR ranges that are NOT considered public.", + ) + + # --- CloudWatch / CloudFormation -------------------------------------- + log_group_retention_days: Optional[_VALID_CW_RETENTION_LITERAL] = Field( + default=None, + description=( + "Required CloudWatch Logs retention in days. Must match one of the " + f"values accepted by the AWS API: {list(_CLOUDWATCH_RETENTION_DAYS)}." + ), + ) + recommended_cdk_bootstrap_version: Optional[int] = Field( + default=None, + ge=1, + le=100, + description="Min CDK bootstrap version expected on the account.", + ) + + # --- AppStream -------------------------------------------------------- + max_idle_disconnect_timeout_in_seconds: Optional[int] = Field( + default=None, + ge=60, + le=1800, + description=( + "AppStream idle disconnect timeout (seconds). Range: 60..1800 " + "(NIST AC-12: sensitive sessions ≤15 min — cap at 30 min)." + ), + ) + max_disconnect_timeout_in_seconds: Optional[int] = Field( + default=None, + ge=60, + le=3600, + description="AppStream disconnect timeout (seconds). Range: 60..3600.", + ) + max_session_duration_seconds: Optional[int] = Field( + default=None, + ge=600, + le=86400, + description=( + "AppStream max session duration (seconds). Range: 600..86400 " + "(10 min .. 24 h — AWS AppStream hard limit per session)." + ), + ) + + # --- Lambda ----------------------------------------------------------- + obsolete_lambda_runtimes: Optional[list[str]] = None + lambda_min_azs: Optional[int] = Field( + default=None, + ge=1, + le=6, + description="Min number of AZs a VPC-bound Lambda must span. Range: 1..6.", + ) + + # --- Organizations ---------------------------------------------------- + organizations_enabled_regions: Optional[list[str]] = None + organizations_trusted_delegated_administrators: Annotated[ + Optional[list[str]], AfterValidator(_validate_account_ids) + ] = None + organizations_trusted_ids: Optional[list[str]] = None + + # --- ECR -------------------------------------------------------------- + ecr_repository_vulnerability_minimum_severity: Optional[ + Literal["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"] + ] = Field( + default=None, + description="Highest severity tolerated for ECR images.", + ) + + # --- Trusted Advisor -------------------------------------------------- + verify_premium_support_plans: Optional[bool] = None + + # --- CloudTrail threat detection: privilege escalation ---------------- + threat_detection_privilege_escalation_threshold: Optional[float] = Field( + default=None, + ge=0.0, + le=1.0, + description="Fraction of suspicious actions that triggers the priv-esc detection.", + ) + threat_detection_privilege_escalation_minutes: Optional[int] = Field( + default=None, + ge=5, + le=43200, + description=( + "Lookback window (minutes) for priv-esc detection. Range: 5..43200 " + "(under 5 min the signal is dominated by false positives)." + ), + ) + threat_detection_privilege_escalation_actions: Optional[list[str]] = None + + # --- CloudTrail threat detection: enumeration ------------------------- + threat_detection_enumeration_threshold: Optional[float] = Field( + default=None, + ge=0.0, + le=1.0, + description="Fraction of suspicious actions that triggers the enumeration detection.", + ) + threat_detection_enumeration_minutes: Optional[int] = Field( + default=None, + ge=5, + le=43200, + description="Lookback window (minutes) for enumeration detection. Range: 5..43200.", + ) + threat_detection_enumeration_actions: Optional[list[str]] = None + + # --- CloudTrail threat detection: LLM jacking ------------------------- + threat_detection_llm_jacking_threshold: Optional[float] = Field( + default=None, + ge=0.0, + le=1.0, + description="Fraction of suspicious actions that triggers the LLM-jacking detection.", + ) + threat_detection_llm_jacking_minutes: Optional[int] = Field( + default=None, + ge=5, + le=43200, + description="Lookback window (minutes) for LLM-jacking detection. Range: 5..43200.", + ) + threat_detection_llm_jacking_actions: Optional[list[str]] = None + + # --- RDS -------------------------------------------------------------- + check_rds_instance_replicas: Optional[bool] = None + + # --- ACM -------------------------------------------------------------- + days_to_expire_threshold: Optional[int] = Field( + default=None, + ge=7, + le=365, + description=( + "Days before certificate expiration to flag. Range: 7..365 " + "(PCI-DSS 4.2.1.1: alert ≥30 days before expiry; <7 days is too " + "tight to actually act on)." + ), + ) + insecure_key_algorithms: Optional[list[str]] = None + + # --- EKS -------------------------------------------------------------- + eks_required_log_types: Optional[ + list[ + Literal[ + "api", + "audit", + "authenticator", + "controllerManager", + "scheduler", + ] + ] + ] = Field( + default=None, + description="EKS control plane log types that must be enabled.", + ) + eks_cluster_oldest_version_supported: Annotated[ + Optional[str], AfterValidator(_validate_eks_minor) + ] = Field( + default=None, + description='Minimum supported EKS minor version, expected as "X.Y".', + ) + + # --- CodeBuild -------------------------------------------------------- + excluded_sensitive_environment_variables: Optional[list[str]] = None + codebuild_github_allowed_organizations: Optional[list[str]] = None + + # --- ELB / ELBv2 ------------------------------------------------------ + elb_min_azs: Optional[int] = Field( + default=None, + ge=1, + le=6, + description="Min AZs a Classic ELB must span. Range: 1..6.", + ) + elbv2_min_azs: Optional[int] = Field( + default=None, + ge=1, + le=6, + description="Min AZs an Application/Network LB must span. Range: 1..6.", + ) + + # --- ElastiCache ----------------------------------------------------- + minimum_snapshot_retention_period: Optional[int] = Field( + default=None, + ge=1, + le=35, + description="Days an ElastiCache backup must be retained. Range: 1..35 (service hard limit).", + ) + + # --- Secrets --------------------------------------------------------- + secrets_ignore_patterns: Optional[list[str]] = None + max_days_secret_unused: Optional[int] = Field( + default=None, + ge=7, + le=365, + description="Days a Secrets Manager secret can stay unused. Range: 7..365.", + ) + max_days_secret_unrotated: Optional[int] = Field( + default=None, + ge=1, + le=180, + description=( + "Days a Secrets Manager secret can go without rotation. Range: 1..180 " + "(NIST IA-5: rotate quarterly; CIS recommends ≤90)." + ), + ) + + # --- Kinesis --------------------------------------------------------- + min_kinesis_stream_retention_hours: Optional[int] = Field( + default=None, + ge=24, + le=8760, + description="Hours of Kinesis stream retention. Range: 24..8760 (1 day .. 1 year).", + ) + + # --- detect-secrets plugin list ------------------------------------- + detect_secrets_plugins: Optional[list[_DetectSecretsPlugin]] = None diff --git a/prowler/config/schema/azure.py b/prowler/config/schema/azure.py new file mode 100644 index 0000000000..75954c602b --- /dev/null +++ b/prowler/config/schema/azure.py @@ -0,0 +1,83 @@ +"""Azure provider config schema with safety bounds. + +Bounds aim for values that produce a meaningful security check; out-of-range +values are dropped (SDK runtime) or rejected (Prowler App backend). +""" + +from typing import Annotated, Literal, Optional + +from pydantic import AfterValidator, Field + +from prowler.config.schema.base import ProviderConfigBase +from prowler.config.schema.validators import make_dotted_version_validator + +# Accept "8.2", "3.12", "17" style version strings. Used by App Service +# language version fields where the upstream APIs accept either MAJOR or +# MAJOR.MINOR notation. +_validate_dotted_version = make_dotted_version_validator(1, 2) + + +class AzureProviderConfig(ProviderConfigBase): + # --- Network --------------------------------------------------------- + shodan_api_key: Optional[str] = Field( + default=None, + max_length=512, + description="API key for Shodan lookups on Azure public IPs.", + ) + + # --- Defender -------------------------------------------------------- + defender_attack_path_minimal_risk_level: Optional[ + Literal["Low", "Medium", "High", "Critical"] + ] = Field( + default=None, + description="Minimum attack-path risk level worth a notification.", + ) + + # --- App Service ---------------------------------------------------- + php_latest_version: Annotated[ + Optional[str], AfterValidator(_validate_dotted_version) + ] = Field(default=None, description='PHP minimum acceptable version, e.g. "8.2".') + python_latest_version: Annotated[ + Optional[str], AfterValidator(_validate_dotted_version) + ] = Field( + default=None, description='Python minimum acceptable version, e.g. "3.12".' + ) + java_latest_version: Annotated[ + Optional[str], AfterValidator(_validate_dotted_version) + ] = Field(default=None, description='Java minimum acceptable version, e.g. "17".') + + # --- SQL ------------------------------------------------------------ + recommended_minimal_tls_versions: Optional[list[Literal["1.2", "1.3"]]] = Field( + default=None, + description="TLS versions accepted on Azure SQL Server.", + ) + + # --- Virtual Machines ----------------------------------------------- + desired_vm_sku_sizes: Optional[list[str]] = None + vm_backup_min_daily_retention_days: Optional[int] = Field( + default=None, + ge=7, + le=9999, + description=( + "Min daily backup retention days. Range: 7..9999 " + "(Azure Backup hard limit; <7 days defeats DR/ransomware recovery)." + ), + ) + + # --- API Management threat detection (LLM jacking) ----------------- + apim_threat_detection_llm_jacking_threshold: Optional[float] = Field( + default=None, + ge=0.0, + le=1.0, + description="Fraction of suspicious actions that triggers the detection.", + ) + apim_threat_detection_llm_jacking_minutes: Optional[int] = Field( + default=None, + ge=5, + le=43200, + description=( + "Lookback window (minutes) for LLM-jacking detection. Range: 5..43200 " + "(under 5 min the signal is dominated by false positives)." + ), + ) + apim_threat_detection_llm_jacking_actions: Optional[list[str]] = None diff --git a/prowler/config/schema/base.py b/prowler/config/schema/base.py new file mode 100644 index 0000000000..cc473a4545 --- /dev/null +++ b/prowler/config/schema/base.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, ConfigDict + + +class ProviderConfigBase(BaseModel): + """Base for every provider config schema. + + ``extra="allow"`` is REQUIRED for backwards compatibility: third-party + check plugins frequently introduce config keys we do not know about, + and pre-existing user configs may carry deprecated keys. Validation + must never reject these. + """ + + model_config = ConfigDict( + extra="allow", + str_strip_whitespace=True, + validate_assignment=False, + ) diff --git a/prowler/config/schema/cloudflare.py b/prowler/config/schema/cloudflare.py new file mode 100644 index 0000000000..417092d6df --- /dev/null +++ b/prowler/config/schema/cloudflare.py @@ -0,0 +1,24 @@ +"""Cloudflare provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class CloudflareProviderConfig(ProviderConfigBase): + """Cloudflare provider configuration schema. + + Defines optional configuration parameters for Cloudflare security checks, + including API retry behavior. + """ + + max_retries: Optional[int] = Field( + default=None, + ge=0, + le=10, + description=( + "Max retries for Cloudflare API requests. Range: 0..10 (0 disables retries)." + ), + ) diff --git a/prowler/config/schema/gcp.py b/prowler/config/schema/gcp.py new file mode 100644 index 0000000000..59e1a162fa --- /dev/null +++ b/prowler/config/schema/gcp.py @@ -0,0 +1,45 @@ +"""GCP provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class GCPProviderConfig(ProviderConfigBase): + shodan_api_key: Optional[str] = Field( + default=None, + max_length=512, + description="API key for Shodan lookups on GCP public IPs.", + ) + mig_min_zones: Optional[int] = Field( + default=None, + ge=1, + le=5, + description="Min zones a Managed Instance Group must span. Range: 1..5.", + ) + max_snapshot_age_days: Optional[int] = Field( + default=None, + ge=1, + le=1095, + description=( + "Days a disk snapshot can age before being flagged. Range: 1..1095 " + "(3 years; older snapshots typically miss data-class compliance)." + ), + ) + max_unused_account_days: Optional[int] = Field( + default=None, + ge=30, + le=365, + description=( + "Days a service account or user-managed key can stay unused. " + "Range: 30..365." + ), + ) + storage_min_retention_days: Optional[int] = Field( + default=None, + ge=1, + le=3650, + description="Min retention period on Cloud Storage buckets. Range: 1..3650.", + ) diff --git a/prowler/config/schema/github.py b/prowler/config/schema/github.py new file mode 100644 index 0000000000..30b5a13b85 --- /dev/null +++ b/prowler/config/schema/github.py @@ -0,0 +1,20 @@ +"""GitHub provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class GitHubProviderConfig(ProviderConfigBase): + inactive_not_archived_days_threshold: Optional[int] = Field( + default=None, + ge=30, + le=3650, + description=( + "Days a repository can stay inactive without being archived before " + "being flagged. Range: 30..3650 (CIS GitHub recommends 180; " + "<30 days produces false positives on seasonal projects)." + ), + ) diff --git a/prowler/config/schema/kubernetes.py b/prowler/config/schema/kubernetes.py new file mode 100644 index 0000000000..3235971500 --- /dev/null +++ b/prowler/config/schema/kubernetes.py @@ -0,0 +1,45 @@ +"""Kubernetes provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class KubernetesProviderConfig(ProviderConfigBase): + audit_log_maxbackup: Optional[int] = Field( + default=None, + ge=2, + le=1000, + description=( + "API server audit log file rotations to keep. Range: 2..1000 " + "(CIS Kubernetes 1.2.18 recommends ≥10)." + ), + ) + audit_log_maxsize: Optional[int] = Field( + default=None, + ge=10, + le=10000, + description=( + "Max MB per audit log file before rotation. Range: 10..10000 MB " + "(CIS Kubernetes 1.2.19 recommends ≥100 MB)." + ), + ) + audit_log_maxage: Optional[int] = Field( + default=None, + ge=7, + le=3650, + description=( + "Days an audit log file is retained. Range: 7..3650 " + "(CIS Kubernetes 1.2.17 recommends ≥30 days)." + ), + ) + apiserver_strong_ciphers: Optional[list[str]] = Field( + default=None, + description="Whitelist of strong TLS cipher suites required on the API server.", + ) + kubelet_strong_ciphers: Optional[list[str]] = Field( + default=None, + description="Whitelist of strong TLS cipher suites required on kubelet.", + ) diff --git a/prowler/config/schema/m365.py b/prowler/config/schema/m365.py new file mode 100644 index 0000000000..ff1ff98285 --- /dev/null +++ b/prowler/config/schema/m365.py @@ -0,0 +1,54 @@ +"""M365 provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class M365ProviderConfig(ProviderConfigBase): + # --- Entra (sign-in policy) ---------------------------------------- + sign_in_frequency: Optional[int] = Field( + default=None, + ge=1, + le=168, + description=( + "Hours between forced sign-ins for admin users. Range: 1..168 (1 h .. 7 days). " + "Microsoft Conditional Access baseline for admin roles is ≤24 h." + ), + ) + + # --- Teams --------------------------------------------------------- + allowed_cloud_storage_services: Optional[list[str]] = Field( + default=None, + description="External cloud storage services allowed in Teams.", + ) + + # --- Exchange ------------------------------------------------------ + recommended_mailtips_large_audience_threshold: Optional[int] = Field( + default=None, + ge=5, + le=10000, + description=( + "Recipient count that should trigger a 'large audience' MailTip. " + "Range: 5..10000 (Microsoft default 25)." + ), + ) + + # --- Defender malware policy -------------------------------------- + default_recommended_extensions: Optional[list[str]] = Field( + default=None, + description="File extensions blocked by the malware policy.", + ) + + # --- Mailbox auditing --------------------------------------------- + audit_log_age: Optional[int] = Field( + default=None, + ge=30, + le=3650, + description=( + "Days mailbox audit logs must be retained. Range: 30..3650 " + "(M365 E3 default is 90 days; SEC/FINRA require ≥7 years)." + ), + ) diff --git a/prowler/config/schema/mongodbatlas.py b/prowler/config/schema/mongodbatlas.py new file mode 100644 index 0000000000..552e0a7bbd --- /dev/null +++ b/prowler/config/schema/mongodbatlas.py @@ -0,0 +1,25 @@ +"""MongoDB Atlas provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class MongoDBAtlasProviderConfig(ProviderConfigBase): + """MongoDB Atlas provider configuration schema. + + Defines optional configuration parameters for MongoDB Atlas security checks, + including service account secret validity constraints. + """ + + max_service_account_secret_validity_hours: Optional[int] = Field( + default=None, + ge=1, + le=720, + description=( + "Max hours a service account secret can stay valid. " + "Range: 1..720 (1 h .. 30 days)." + ), + ) diff --git a/prowler/config/schema/registry.py b/prowler/config/schema/registry.py new file mode 100644 index 0000000000..fa31f1e5eb --- /dev/null +++ b/prowler/config/schema/registry.py @@ -0,0 +1,28 @@ +"""Mapping of provider name to its Pydantic schema class. + +Kept in its own module so the validator stays free of provider-schema imports +and callers pay the import cost only when they actually need the registry. +""" + +from prowler.config.schema.aws import AWSProviderConfig +from prowler.config.schema.azure import AzureProviderConfig +from prowler.config.schema.base import ProviderConfigBase +from prowler.config.schema.cloudflare import CloudflareProviderConfig +from prowler.config.schema.gcp import GCPProviderConfig +from prowler.config.schema.github import GitHubProviderConfig +from prowler.config.schema.kubernetes import KubernetesProviderConfig +from prowler.config.schema.m365 import M365ProviderConfig +from prowler.config.schema.mongodbatlas import MongoDBAtlasProviderConfig +from prowler.config.schema.vercel import VercelProviderConfig + +SCHEMAS: dict[str, type[ProviderConfigBase]] = { + "aws": AWSProviderConfig, + "azure": AzureProviderConfig, + "gcp": GCPProviderConfig, + "kubernetes": KubernetesProviderConfig, + "m365": M365ProviderConfig, + "github": GitHubProviderConfig, + "mongodbatlas": MongoDBAtlasProviderConfig, + "cloudflare": CloudflareProviderConfig, + "vercel": VercelProviderConfig, +} diff --git a/prowler/config/schema/validator.py b/prowler/config/schema/validator.py new file mode 100644 index 0000000000..8113302855 --- /dev/null +++ b/prowler/config/schema/validator.py @@ -0,0 +1,66 @@ +from typing import Any + +from pydantic import ValidationError + +from prowler.config.schema.base import ProviderConfigBase +from prowler.lib.logger import logger + + +def validate_provider_config( + provider: str, + raw: Any, + schema_cls: type[ProviderConfigBase] | None, +) -> dict: + """Validate a provider's config dict against its Pydantic schema. + + Behavior is intentionally lenient to preserve backwards compatibility: + + - If ``raw`` is not a dict, return an empty dict (mirrors prior loader). + - If no schema is registered for ``provider``, return ``raw`` untouched. + - On validation errors, log one WARNING per offending field, DROP those + keys from the result, and continue. Consumers fall back to their own + hard-coded defaults via ``audit_config.get(key, default)``. + - Coerced values (e.g. ``"180"`` -> ``180``) replace the user's input + so that downstream checks never receive a wrongly-typed value. + """ + if not isinstance(raw, dict): + return {} + + if schema_cls is None: + return raw + + try: + model = schema_cls.model_validate(raw) + return model.model_dump(exclude_unset=True) + except ValidationError as exc: + bad_keys: set[str] = set() + for err in exc.errors(): + loc = err.get("loc") or () + if not loc: + continue + key = loc[0] + if not isinstance(key, str): + continue + bad_keys.add(key) + logger.warning( + f"prowler.config[{provider}.{key}] = {raw.get(key)!r} is invalid " + f"({err.get('msg', 'validation error')}); the value will be ignored " + f"and the built-in default will be used." + ) + + cleaned = {k: v for k, v in raw.items() if k not in bad_keys} + # Retry validation with the cleaned dict. Dropping invalid keys handles + # common field-level mismatches, but revalidation can still fail due to + # higher-level structural constraints (e.g. nested validation errors not + # captured in the top-level bad_keys). In that case, log and return the + # cleaned dict so consumers fall back to their own defaults. + try: + model = schema_cls.model_validate(cleaned) + return model.model_dump(exclude_unset=True) + except ValidationError as exc2: + logger.error( + f"prowler.config[{provider}] could not be revalidated after dropping " + f"invalid keys ({bad_keys}); passing through the cleaned dict as-is. " + f"Underlying errors: {exc2.errors()}" + ) + return cleaned diff --git a/prowler/config/schema/validators.py b/prowler/config/schema/validators.py new file mode 100644 index 0000000000..3c87f532c7 --- /dev/null +++ b/prowler/config/schema/validators.py @@ -0,0 +1,71 @@ +"""Reusable field validators shared across provider config schemas. + +These are factored out so multiple providers can reuse the same validation +logic (version strings, port ranges, IP/CIDR entries) instead of duplicating +it per schema. Each validator accepts ``None`` so optional fields stay valid +when the key is absent. +""" + +from ipaddress import ip_network +from typing import Callable, Optional + +_VERSION_PART_LABELS = ("X", "Y", "Z", "W") + + +def make_dotted_version_validator( + min_parts: int, max_parts: int +) -> Callable[[Optional[str]], Optional[str]]: + """Build a validator for dotted numeric version strings. + + The returned validator accepts ``None`` and strings made of between + ``min_parts`` and ``max_parts`` dot-separated numeric components. Anything + else raises ``ValueError``. + + Examples: + ``make_dotted_version_validator(3, 3)`` accepts ``"1.4.0"`` (semver). + ``make_dotted_version_validator(2, 2)`` accepts ``"1.28"`` (EKS minor). + ``make_dotted_version_validator(1, 2)`` accepts ``"17"`` or ``"8.2"``. + """ + if min_parts == max_parts: + expected = ".".join(_VERSION_PART_LABELS[:min_parts]) + else: + expected = " or ".join( + f"'{'.'.join(_VERSION_PART_LABELS[:n])}'" + for n in range(min_parts, max_parts + 1) + ) + + def _validate(v: Optional[str]) -> Optional[str]: + if v is None: + return v + parts = v.split(".") + if not (min_parts <= len(parts) <= max_parts) or not all( + p.isdigit() for p in parts + ): + raise ValueError(f"{v!r} is not a valid version (expected {expected})") + return v + + return _validate + + +def validate_port_range(v: Optional[list[int]]) -> Optional[list[int]]: + """Reject ports outside the valid ``1..65535`` range.""" + if v is None: + return v + for port in v: + if not 1 <= port <= 65535: + raise ValueError(f"port {port} is outside the valid range 1..65535") + return v + + +def validate_ip_networks(v: Optional[list[str]]) -> Optional[list[str]]: + """Reject entries that are not a valid IP address or CIDR network.""" + if v is None: + return v + for entry in v: + try: + ip_network(entry, strict=False) + except ValueError as exc: + raise ValueError( + f"entry {entry!r} is not a valid IP or CIDR ({exc})" + ) from exc + return v diff --git a/prowler/config/schema/vercel.py b/prowler/config/schema/vercel.py new file mode 100644 index 0000000000..2e466e2049 --- /dev/null +++ b/prowler/config/schema/vercel.py @@ -0,0 +1,68 @@ +"""Vercel provider config schema with safety bounds.""" + +from typing import Optional + +from pydantic import Field + +from prowler.config.schema.base import ProviderConfigBase + + +class VercelProviderConfig(ProviderConfigBase): + """Vercel provider configuration schema. + + Defines optional configuration parameters for Vercel security checks, + including deployment branch policies, credential staleness thresholds, + RBAC ownership limits, and secret detection patterns. + """ + + stable_branches: Optional[list[str]] = Field( + default=None, + description="Branches considered stable for production deployments.", + ) + days_to_expire_threshold: Optional[int] = Field( + default=None, + ge=7, + le=365, + description=( + "Days before token/certificate expiration to flag. Range: 7..365 " + "(PCI-DSS 4.2.1.1: alert ≥30 days before expiry)." + ), + ) + stale_token_threshold_days: Optional[int] = Field( + default=None, + ge=30, + le=3650, + description=( + "Days of inactivity before a token is considered stale. Range: 30..3650 " + "(NIST AC-2(3) typical window 30..90 days)." + ), + ) + stale_invitation_threshold_days: Optional[int] = Field( + default=None, + ge=7, + le=365, + description=( + "Days a pending invitation can stay open. Range: 7..365 " + "(OWASP ASVS 2.7.1 recommends short-lived invitations)." + ), + ) + max_owner_percentage: Optional[int] = Field( + default=None, + ge=1, + le=50, + description=( + "Max percentage of team members that can have the OWNER role. " + "Range: 1..50 (PoLP — having >50% of a team as OWNER defeats RBAC; " + "industry guidance recommends ≤25%)." + ), + ) + max_owners: Optional[int] = Field( + default=None, + ge=1, + le=1000, + description="Absolute max owners (overrides percentage for large teams). Range: 1..1000.", + ) + secret_suffixes: Optional[list[str]] = Field( + default=None, + description="Suffixes that mark a project env var as secret-like.", + ) diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py index 300520f589..c0c6a02e5d 100644 --- a/prowler/lib/check/check.py +++ b/prowler/lib/check/check.py @@ -797,6 +797,10 @@ def execute( is_finding_muted_args["org_domain"] = ( global_provider.identity.org_domain ) + elif global_provider.type == "linode": + is_finding_muted_args["account_id"] = ( + global_provider.identity.account_id + ) elif not is_builtin_provider(global_provider.type): # External/custom provider — delegate identity args is_finding_muted_args = global_provider.get_mutelist_finding_args() diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index f9155c68f7..aa16d7969b 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -1106,6 +1106,37 @@ class CheckReportCloudflare(Check_Report): return "global" +@dataclass +class CheckReportLinode(Check_Report): + """Contains the Linode Check's finding information.""" + + resource_name: str + resource_id: str + region: str + + def __init__( + self, + metadata: Dict, + resource: Any, + resource_name: str, + resource_id: str, + region: str = "global", + ) -> None: + """Initialize the Linode Check's finding information. + + Args: + metadata: The metadata of the check. + resource: Basic information about the resource. + resource_name: The name of the resource related with the finding. + resource_id: The id of the resource related with the finding. + region: The region of the resource related with the finding. + """ + super().__init__(metadata, resource) + self.resource_name = resource_name + self.resource_id = resource_id + self.region = region + + @dataclass class CheckReportM365(Check_Report): """Contains the M365 Check's finding information.""" diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 2848e9c788..b65a702fbc 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -51,6 +51,7 @@ class ProwlerArgumentParser: "okta", "scaleway", "stackit", + "linode", } all_providers = set(Provider.get_available_providers()) new_providers = sorted(all_providers - known_providers) @@ -73,10 +74,10 @@ class ProwlerArgumentParser: self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm{extra_providers_csv}}} ...", + usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,dashboard,iac,image,llm{extra_providers_csv}}} ...", epilog=f""" Available Cloud Providers: - {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel{extra_providers_csv}}} + {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode{extra_providers_csv}}} aws AWS Provider azure Azure Provider gcp GCP Provider @@ -96,7 +97,8 @@ Available Cloud Providers: nhn NHN Provider (Unofficial) mongodbatlas MongoDB Atlas Provider scaleway Scaleway Provider - vercel Vercel Provider{extra_providers_text} + vercel Vercel Provider + linode Linode Provider{extra_providers_text} Available components: diff --git a/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py index 75481fb6aa..df23aeb1d1 100644 --- a/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py +++ b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py @@ -22,11 +22,14 @@ def get_asd_essential_eight_table( pass_count = [] fail_count = [] muted_count = [] + section_seen = {} + provider = "" for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ASD-Essential-Eight": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section @@ -36,21 +39,33 @@ def get_asd_essential_eight_table( "PASS": 0, "Muted": 0, } + section_seen[section] = set() + + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - asd_essential_eight_compliance_table["Provider"].append(compliance.Provider) + asd_essential_eight_compliance_table["Provider"].append(provider) asd_essential_eight_compliance_table["Section"].append(section) if sections[section]["FAIL"] > 0: asd_essential_eight_compliance_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/c5/c5.py b/prowler/lib/outputs/compliance/c5/c5.py index 32eb7e0f5a..b48260b5a2 100644 --- a/prowler/lib/outputs/compliance/c5/c5.py +++ b/prowler/lib/outputs/compliance/c5/c5.py @@ -22,33 +22,47 @@ def get_c5_table( fail_count = [] muted_count = [] sections = {} + section_seen = {} + provider = "" for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "C5": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/ccc/ccc.py b/prowler/lib/outputs/compliance/ccc/ccc.py index 99a6c91cd9..d8d76ad2d9 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc.py +++ b/prowler/lib/outputs/compliance/ccc/ccc.py @@ -22,33 +22,47 @@ def get_ccc_table( fail_count = [] muted_count = [] sections = {} + section_seen = {} + provider = "" for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "CCC": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/cis/cis.py b/prowler/lib/outputs/compliance/cis/cis.py index 7f161f34c2..4acbd7fe58 100644 --- a/prowler/lib/outputs/compliance/cis/cis.py +++ b/prowler/lib/outputs/compliance/cis/cis.py @@ -13,6 +13,9 @@ def get_cis_table( compliance_overview: bool, ): sections = {} + section_muted_seen = {} + section_split_seen = {} + provider = "" cis_compliance_table = { "Provider": [], "Section": [], @@ -29,6 +32,7 @@ def get_cis_table( for compliance in check_compliances: version_in_name = compliance_framework.split("_")[1] if compliance.Framework == "CIS" and version_in_name in compliance.Version: + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section @@ -40,9 +44,19 @@ def get_cis_table( "Level 2": {"FAIL": 0, "PASS": 0}, "Muted": 0, } + section_muted_seen[section] = set() + section_split_seen[section] = { + "Level 1": set(), + "Level 2": set(), + } if finding.muted: + # Overview total: count each finding once per framework if index not in muted_count: muted_count.append(index) + # Per-section Muted: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_muted_seen[section]: + section_muted_seen[section].add(index) sections[section]["Muted"] += 1 else: if finding.status == "FAIL" and index not in fail_count: @@ -50,13 +64,21 @@ def get_cis_table( elif finding.status == "PASS" and index not in pass_count: pass_count.append(index) if "Level 1" in attribute.Profile: - if not finding.muted: + if ( + not finding.muted + and index not in section_split_seen[section]["Level 1"] + ): + section_split_seen[section]["Level 1"].add(index) if finding.status == "FAIL": sections[section]["Level 1"]["FAIL"] += 1 else: sections[section]["Level 1"]["PASS"] += 1 elif "Level 2" in attribute.Profile: - if not finding.muted: + if ( + not finding.muted + and index not in section_split_seen[section]["Level 2"] + ): + section_split_seen[section]["Level 2"].add(index) if finding.status == "FAIL": sections[section]["Level 2"]["FAIL"] += 1 else: @@ -65,7 +87,7 @@ def get_cis_table( # Add results to table sections = dict(sorted(sections.items())) for section in sections: - cis_compliance_table["Provider"].append(compliance.Provider) + cis_compliance_table["Provider"].append(provider) cis_compliance_table["Section"].append(section) if sections[section]["Level 1"]["FAIL"] > 0: cis_compliance_table["Level 1"].append( diff --git a/prowler/lib/outputs/compliance/ens/ens.py b/prowler/lib/outputs/compliance/ens/ens.py index a414a0206d..c39abe0a6a 100644 --- a/prowler/lib/outputs/compliance/ens/ens.py +++ b/prowler/lib/outputs/compliance/ens/ens.py @@ -13,6 +13,8 @@ def get_ens_table( compliance_overview: bool, ): marcos = {} + marco_muted_seen = {} + provider = "" ens_compliance_table = { "Proveedor": [], "Marco/Categoria": [], @@ -31,6 +33,7 @@ def get_ens_table( check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ENS": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: marco_categoria = f"{attribute.Marco}/{attribute.Categoria}" @@ -44,17 +47,23 @@ def get_ens_table( "Bajo": 0, "Muted": 0, } + marco_muted_seen[marco_categoria] = set() if finding.muted: + # Overview total: count each finding once per framework if index not in muted_count: muted_count.append(index) + # Per-marco Muted: count each finding once per marco + # it belongs to (a finding can map to several marcos). + if index not in marco_muted_seen[marco_categoria]: + marco_muted_seen[marco_categoria].add(index) marcos[marco_categoria]["Muted"] += 1 else: if finding.status == "FAIL": - if ( - attribute.Tipo != "recomendacion" - and index not in fail_count - ): - fail_count.append(index) + if attribute.Tipo != "recomendacion": + if index not in fail_count: + fail_count.append(index) + # Mark every marco the finding belongs to as + # NO CUMPLE, not just the first one seen. marcos[marco_categoria][ "Estado" ] = f"{Fore.RED}NO CUMPLE{Style.RESET_ALL}" @@ -71,7 +80,7 @@ def get_ens_table( # Add results to table for marco in sorted(marcos): - ens_compliance_table["Proveedor"].append(compliance.Provider) + ens_compliance_table["Proveedor"].append(provider) ens_compliance_table["Marco/Categoria"].append(marco) ens_compliance_table["Estado"].append(marcos[marco]["Estado"]) ens_compliance_table["Opcional"].append( diff --git a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py index 93b925a5ff..e7c00b188d 100644 --- a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py +++ b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py @@ -13,7 +13,9 @@ def get_kisa_ismsp_table( compliance_overview: bool, ): sections = {} + section_seen = {} sections_status = {} + provider = "" kisa_ismsp_compliance_table = { "Provider": [], "Section": [], @@ -31,6 +33,7 @@ def get_kisa_ismsp_table( compliance.Framework.startswith("KISA") and compliance.Version in compliance_framework ): + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section @@ -43,16 +46,28 @@ def get_kisa_ismsp_table( }, "Muted": 0, } + section_seen[section] = set() + + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["Status"]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["Status"]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["Status"]["PASS"] += 1 # Add results to table @@ -70,7 +85,7 @@ def get_kisa_ismsp_table( else: sections_status[section] = f"{Fore.GREEN}PASS{Style.RESET_ALL}" for section in sections: - kisa_ismsp_compliance_table["Provider"].append(compliance.Provider) + kisa_ismsp_compliance_table["Provider"].append(provider) kisa_ismsp_compliance_table["Section"].append(section) kisa_ismsp_compliance_table["Status"].append(sections_status[section]) kisa_ismsp_compliance_table["Muted"].append( diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py index bab3e4e31a..7492624aff 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py @@ -13,6 +13,8 @@ def get_mitre_attack_table( compliance_overview: bool, ): tactics = {} + tactic_seen = {} + provider = "" mitre_compliance_table = { "Provider": [], "Tactic": [], @@ -30,27 +32,38 @@ def get_mitre_attack_table( "MITRE-ATTACK" in compliance.Framework and compliance.Version in compliance_framework ): + provider = compliance.Provider for requirement in compliance.Requirements: for tactic in requirement.Tactics: if tactic not in tactics: tactics[tactic] = {"FAIL": 0, "PASS": 0, "Muted": 0} + tactic_seen[tactic] = set() + + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) + elif finding.status == "FAIL": + if index not in fail_count: + fail_count.append(index) + elif finding.status == "PASS": + if index not in pass_count: + pass_count.append(index) + + # Per-tactic counts: count each finding once per tactic + # it belongs to (a finding can map to several tactics). + if index not in tactic_seen[tactic]: + tactic_seen[tactic].add(index) + if finding.muted: tactics[tactic]["Muted"] += 1 - else: - if finding.status == "FAIL": - if index not in fail_count: - fail_count.append(index) - tactics[tactic]["FAIL"] += 1 + elif finding.status == "FAIL": + tactics[tactic]["FAIL"] += 1 elif finding.status == "PASS": - if index not in pass_count: - pass_count.append(index) - tactics[tactic]["PASS"] += 1 + tactics[tactic]["PASS"] += 1 # Add results to table tactics = dict(sorted(tactics.items())) for tactic in tactics: - mitre_compliance_table["Provider"].append(compliance.Provider) + mitre_compliance_table["Provider"].append(provider) mitre_compliance_table["Tactic"].append(tactic) if tactics[tactic]["FAIL"] > 0: mitre_compliance_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py index 5c76055a06..1febe02f60 100644 --- a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py @@ -22,33 +22,47 @@ def get_okta_idaas_stig_table( fail_count = [] muted_count = [] sections = {} + section_seen = {} + provider = "" for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "Okta-IDaaS-STIG": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py index cfcd4a006e..b17307f04a 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py @@ -24,6 +24,8 @@ def get_prowler_threatscore_table( fail_count = [] muted_count = [] pillars = {} + pillar_seen = {} + provider = "" generic_score = 0 max_generic_score = 0 counted_findings_generic = [] @@ -35,6 +37,7 @@ def get_prowler_threatscore_table( check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ProwlerThreatScore": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: pillar = attribute.Section @@ -65,17 +68,28 @@ def get_prowler_threatscore_table( if pillar not in pillars: pillars[pillar] = {"FAIL": 0, "PASS": 0, "Muted": 0} + pillar_seen[pillar] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - pillars[pillar]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - pillars[pillar]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-pillar counts: count each finding once per pillar + # it belongs to (a finding can map to several pillars). + if index not in pillar_seen[pillar]: + pillar_seen[pillar].add(index) + if finding.muted: + pillars[pillar]["Muted"] += 1 + elif finding.status == "FAIL": + pillars[pillar]["FAIL"] += 1 + elif finding.status == "PASS": pillars[pillar]["PASS"] += 1 # Generic score @@ -90,18 +104,21 @@ def get_prowler_threatscore_table( counted_findings_generic.append(index) no_findings_pillars = [] - bulk_compliance = Compliance.get_bulk(provider=compliance.Provider.lower()).get( - compliance_framework + bulk_compliance = ( + Compliance.get_bulk(provider=provider.lower()).get(compliance_framework) + if provider + else None ) - for requirement in bulk_compliance.Requirements: - for attribute in requirement.Attributes: - pillar = attribute.Section - if pillar not in pillars.keys() and pillar not in no_findings_pillars: - no_findings_pillars.append(pillar) + if bulk_compliance: + for requirement in bulk_compliance.Requirements: + for attribute in requirement.Attributes: + pillar = attribute.Section + if pillar not in pillars.keys() and pillar not in no_findings_pillars: + no_findings_pillars.append(pillar) pillars = dict(sorted(pillars.items())) for pillar in pillars: - pillar_table["Provider"].append(compliance.Provider) + pillar_table["Provider"].append(provider) pillar_table["Pillar"].append(pillar) if max_score_per_pillar[pillar] == 0: pillar_score = 100.0 @@ -127,7 +144,7 @@ def get_prowler_threatscore_table( ) for pillar in no_findings_pillars: - pillar_table["Provider"].append(compliance.Provider) + pillar_table["Provider"].append(provider) pillar_table["Pillar"].append(pillar) pillar_table["Score"].append(f"{Style.BRIGHT}{Fore.GREEN}100%{Style.RESET_ALL}") pillar_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}") diff --git a/prowler/lib/outputs/compliance/universal/universal_table.py b/prowler/lib/outputs/compliance/universal/universal_table.py index e838c5e9cf..e54ad5155c 100644 --- a/prowler/lib/outputs/compliance/universal/universal_table.py +++ b/prowler/lib/outputs/compliance/universal/universal_table.py @@ -163,6 +163,7 @@ def _render_grouped( """Grouped mode: one row per group with pass/fail counts.""" check_map = _build_requirement_check_map(framework, provider) groups = {} + group_seen = {} pass_count = [] fail_count = [] muted_count = [] @@ -176,17 +177,28 @@ def _render_grouped( for group_key in _get_group_key(req, group_by): if group_key not in groups: groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} + group_seen[group_key] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - groups[group_key]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - groups[group_key]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-group counts: count each finding once per group it belongs + # to (a finding can map to several groups via several requirements). + if index not in group_seen[group_key]: + group_seen[group_key].add(index) + if finding.muted: + groups[group_key]["Muted"] += 1 + elif finding.status == "FAIL": + groups[group_key]["FAIL"] += 1 + elif finding.status == "PASS": groups[group_key]["PASS"] += 1 if not _print_overview( @@ -258,6 +270,8 @@ def _render_split( split_field = split_by.field split_values = split_by.values groups = {} + group_muted_seen = {} + group_split_seen = {} pass_count = [] fail_count = [] muted_count = [] @@ -274,12 +288,19 @@ def _render_split( sv: {"FAIL": 0, "PASS": 0} for sv in split_values } groups[group_key]["Muted"] = 0 + group_muted_seen[group_key] = set() + group_split_seen[group_key] = {sv: set() for sv in split_values} split_val = req.attributes.get(split_field, "") if finding.muted: + # Overview total: count each finding once per framework if index not in muted_count: muted_count.append(index) + # Per-group Muted: count each finding once per group it + # belongs to (a finding can map to several groups). + if index not in group_muted_seen[group_key]: + group_muted_seen[group_key].add(index) groups[group_key]["Muted"] += 1 else: if finding.status == "FAIL" and index not in fail_count: @@ -289,7 +310,8 @@ def _render_split( for sv in split_values: if sv in str(split_val): - if not finding.muted: + if index not in group_split_seen[group_key][sv]: + group_split_seen[group_key][sv].add(index) if finding.status == "FAIL": groups[group_key][sv]["FAIL"] += 1 else: @@ -364,6 +386,7 @@ def _render_scored( risk_field = scoring.risk_field weight_field = scoring.weight_field groups = {} + group_seen = {} pass_count = [] fail_count = [] muted_count = [] @@ -388,6 +411,7 @@ def _render_scored( if group_key not in groups: groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} + group_seen[group_key] = set() score_per_group[group_key] = 0 max_score_per_group[group_key] = 0 counted_per_group[group_key] = [] @@ -398,16 +422,26 @@ def _render_scored( max_score_per_group[group_key] += risk * weight counted_per_group[group_key].append(index) + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - groups[group_key]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - groups[group_key]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-group counts: count each finding once per group it belongs + # to (a finding can map to several groups via several requirements). + if index not in group_seen[group_key]: + group_seen[group_key].add(index) + if finding.muted: + groups[group_key]["Muted"] += 1 + elif finding.status == "FAIL": + groups[group_key]["FAIL"] += 1 + elif finding.status == "PASS": groups[group_key]["PASS"] += 1 if index not in counted_generic and not finding.muted: diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 9f772d17c4..ed8bda4039 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -468,6 +468,24 @@ class Finding(BaseModel): output_data["resource_uid"] = check_output.resource_id output_data["region"] = check_output.region + elif provider.type == "linode": + output_data["auth_method"] = "api_token" + # account_uid is a required string, but the account ID may be + # unavailable when the token lacks account:read_only scope. Fall + # back to the username/email so findings are never dropped. + output_data["account_uid"] = ( + get_nested_attribute(provider, "identity.account_id") + or get_nested_attribute(provider, "identity.username") + or get_nested_attribute(provider, "identity.email") + or "linode" + ) + output_data["account_name"] = get_nested_attribute( + provider, "identity.username" + ) or get_nested_attribute(provider, "identity.email") + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_id + output_data["region"] = check_output.region + elif provider.type == "alibabacloud": output_data["auth_method"] = get_nested_attribute( provider, "identity.identity_arn" diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 545bcbc456..18dd4b0f3d 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -1582,6 +1582,63 @@ class HTML(Output): ) return "" + @staticmethod + def get_linode_assessment_summary(provider: Provider) -> str: + """ + get_linode_assessment_summary gets the HTML assessment summary for the Linode provider + + Args: + provider (Provider): the Linode provider object + + Returns: + str: HTML assessment summary for the Linode provider + """ + try: + username = getattr(provider.identity, "username", None) or "-" + email = getattr(provider.identity, "email", None) or "-" + account_id = getattr(provider.identity, "account_id", None) or "-" + + assessment_items = f""" +
  • + Account ID: {account_id} +
  • +
  • + Username: {username} +
  • +
  • + Email: {email} +
  • """ + + credentials_items = """ +
  • + Authentication: API Token +
  • """ + + return f""" +
    +
    +
    + Linode Assessment Summary +
    +
      {assessment_items} +
    +
    +
    +
    +
    +
    + Linode Credentials +
    +
      {credentials_items} +
    +
    +
    """ + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return "" + @staticmethod def get_assessment_summary(provider: Provider) -> str: """ diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index f826d09026..9005b5274e 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -341,7 +341,7 @@ class Jira: } TOKEN_URL = "https://auth.atlassian.com/oauth/token" API_TOKEN_URL = "https://api.atlassian.com/oauth/token/accessible-resources" - REQUEST_TIMEOUT = 30 + REQUEST_TIMEOUT = 90 HEADER_TEMPLATE = { "Content-Type": "application/json", "X-Force-Accept-Language": "true", diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index a1f37a9dfc..40dc4635ba 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -46,6 +46,8 @@ def stdout_report(finding, color, verbose, status, fix, provider=None): details = finding.region elif finding.check_metadata.Provider == "scaleway": details = finding.region + elif finding.check_metadata.Provider == "linode": + details = finding.region else: # Dynamic fallback: any external/custom provider if provider is None: diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index 43d4a547c1..77b4c2725a 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -121,6 +121,11 @@ def display_summary_table( elif provider.type == "scaleway": entity_type = "Organization" audited_entities = provider.identity.organization_id + elif provider.type == "linode": + entity_type = "Account" + audited_entities = ( + provider.identity.username or provider.identity.email or "linode" + ) else: # Dynamic fallback: any external/custom provider entity_type, audited_entities = provider.get_summary_entity() diff --git a/prowler/providers/aws/services/acmpca/__init__.py b/prowler/providers/aws/services/acmpca/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/__init__.py b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.metadata.json b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.metadata.json new file mode 100644 index 0000000000..6915540539 --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "acmpca_certificate_authority_pqc_key_algorithm", + "CheckTitle": "AWS Private CA certificate authorities use a post-quantum (ML-DSA) key algorithm", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "acmpca", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsAcmPcaCertificateAuthority", + "ResourceGroup": "security", + "Description": "**AWS Private Certificate Authorities (Private CAs)** are assessed for use of a **post-quantum digital signature key algorithm** (`ML_DSA_44`, `ML_DSA_65`, `ML_DSA_87`). CAs that still issue certificates with RSA or ECC algorithms produce signatures vulnerable to forgery once a cryptographically relevant quantum computer is available.", + "Risk": "RSA and ECC signatures can be broken by Shor's algorithm on a sufficiently large quantum computer. A compromised CA private key would let an attacker issue arbitrary certificates trusted across the PKI, undermining identity and code-signing controls. Migrating CAs to **ML-DSA** (NIST FIPS 204) provides quantum-resistant signatures so issued certificates retain integrity in the post-quantum era.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/privateca/latest/userguide/PcaTerms.html", + "https://aws.amazon.com/about-aws/whats-new/2025/11/aws-private-ca-post-quantum-digital-certificates/", + "https://aws.amazon.com/blogs/security/post-quantum-ml-dsa-code-signing-with-aws-private-ca-and-aws-kms/", + "https://csrc.nist.gov/pubs/fips/204/final" + ], + "Remediation": { + "Code": { + "CLI": "aws acm-pca create-certificate-authority --certificate-authority-configuration '{\"KeyAlgorithm\":\"ML_DSA_65\",\"SigningAlgorithm\":\"ML_DSA_65\",\"Subject\":{...}}' --certificate-authority-type SUBORDINATE", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::ACMPCA::CertificateAuthority\n Properties:\n Type: SUBORDINATE\n KeyAlgorithm: ML_DSA_65 # FIX: post-quantum signature algorithm\n SigningAlgorithm: ML_DSA_65\n Subject:\n CommonName: example-pqc-ca\n```", + "Other": "Existing CAs cannot have their key algorithm changed; create a new CA with KeyAlgorithm = ML_DSA_44 / ML_DSA_65 / ML_DSA_87, re-issue certificates from it, and decommission the legacy CA once dependent workloads have rotated.", + "Terraform": "```hcl\nresource \"aws_acmpca_certificate_authority\" \"\" {\n type = \"SUBORDINATE\"\n certificate_authority_configuration {\n key_algorithm = \"ML_DSA_65\" # FIX: post-quantum signature algorithm\n signing_algorithm = \"ML_DSA_65\"\n subject {\n common_name = \"example-pqc-ca\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Create new Private CAs with a **post-quantum key algorithm** (`ML_DSA_44`, `ML_DSA_65`, or `ML_DSA_87`) and migrate workloads off legacy RSA/ECC CAs. Plan crypto-agility for your PKI so that quantum-resistant trust anchors can be rolled out before threat actors gain access to a cryptographically relevant quantum computer.", + "Url": "https://hub.prowler.com/check/acmpca_certificate_authority_pqc_key_algorithm" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.py b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.py new file mode 100644 index 0000000000..738875baee --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.py @@ -0,0 +1,49 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.acmpca.acmpca_client import acmpca_client + +PQC_PCA_KEY_ALGORITHMS_DEFAULT = [ + "ML_DSA_44", + "ML_DSA_65", + "ML_DSA_87", +] + + +class acmpca_certificate_authority_pqc_key_algorithm(Check): + """Verify that every AWS Private CA uses a post-quantum key algorithm. + + A Private CA PASSES when its ``KeyAlgorithm`` belongs to the configured + allowlist of post-quantum signature algorithms (ML-DSA family). + Deleted CAs are skipped. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check against AWS Private CAs. + + Returns: + A list of reports with each non-deleted CA PQC key algorithm status. + """ + + findings = [] + pqc_algorithms = acmpca_client.audit_config.get( + "acmpca_pqc_key_algorithms", PQC_PCA_KEY_ALGORITHMS_DEFAULT + ) + for ca in acmpca_client.certificate_authorities.values(): + if ca.status == "DELETED": + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=ca) + algorithm = ca.key_algorithm or "" + if ca.key_algorithm in pqc_algorithms: + report.status = "PASS" + report.status_extended = ( + f"AWS Private CA {ca.id} uses post-quantum key algorithm " + f"{algorithm}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"AWS Private CA {ca.id} uses key algorithm {algorithm}, " + "which is not post-quantum (ML-DSA)." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/acmpca/acmpca_client.py b/prowler/providers/aws/services/acmpca/acmpca_client.py new file mode 100644 index 0000000000..c8e22730bc --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_client.py @@ -0,0 +1,4 @@ +from prowler.providers.aws.services.acmpca.acmpca_service import ACMPCA +from prowler.providers.common.provider import Provider + +acmpca_client = ACMPCA(Provider.get_global_provider()) diff --git a/prowler/providers/aws/services/acmpca/acmpca_service.py b/prowler/providers/aws/services/acmpca/acmpca_service.py new file mode 100644 index 0000000000..9c6fb77096 --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_service.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import Any, Dict, List + +from pydantic.v1 import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.lib.scan_filters.scan_filters import is_resource_filtered +from prowler.providers.aws.aws_provider import AwsProvider +from prowler.providers.aws.lib.service.service import AWSService + + +class ACMPCA(AWSService): + """AWS Private CA service class to list certificate authorities.""" + + def __init__(self, provider: AwsProvider) -> None: + """Initialize the AWS Private CA service. + + Args: + provider: AWS provider instance with session and audit context. + """ + + # The boto3 client identifier for AWS Private CA is "acm-pca" + super().__init__("acm-pca", provider) + self.certificate_authorities: dict[str, CertificateAuthority] = {} + self.__threading_call__(self._list_certificate_authorities) + + def _list_certificate_authorities(self, regional_client: Any) -> None: + """List AWS Private CAs and their tags in a region. + + Args: + regional_client: Regional AWS Private CA client. + """ + + logger.info("ACM PCA - Listing Certificate Authorities...") + try: + paginator = regional_client.get_paginator("list_certificate_authorities") + for page in paginator.paginate(): + for ca in page.get("CertificateAuthorities", []): + arn = ca.get("Arn", "") + if not arn: + continue + if self.audit_resources and not is_resource_filtered( + arn, self.audit_resources + ): + continue + config = ca.get("CertificateAuthorityConfiguration", {}) + tags = [] + try: + tags = regional_client.list_tags( + CertificateAuthorityArn=arn + ).get("Tags", []) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.certificate_authorities[arn] = CertificateAuthority( + arn=arn, + id=arn.split("/")[-1], + region=regional_client.region, + status=ca.get("Status", ""), + type=ca.get("Type", ""), + usage_mode=ca.get("UsageMode", ""), + key_algorithm=config.get("KeyAlgorithm", ""), + signing_algorithm=config.get("SigningAlgorithm", ""), + tags=tags, + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class CertificateAuthority(BaseModel): + """AWS Private Certificate Authority metadata. + + Attributes: + arn: Certificate authority ARN. + id: Certificate authority identifier. + region: AWS region where the certificate authority exists. + status: Certificate authority lifecycle status. + type: Certificate authority type. + usage_mode: Certificate authority usage mode. + key_algorithm: Key algorithm configured for the certificate authority. + signing_algorithm: Signing algorithm configured for the certificate authority. + tags: Tags attached to the certificate authority. + """ + + arn: str + id: str + region: str + status: str = "" + type: str = "" + usage_mode: str = "" + key_algorithm: str = "" + signing_algorithm: str = "" + tags: List[Dict[str, str]] = Field(default_factory=list) diff --git a/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/__init__.py b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.metadata.json new file mode 100644 index 0000000000..276f6e1741 --- /dev/null +++ b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "apigateway_domain_name_pqc_tls_enabled", + "CheckTitle": "API Gateway custom domain names use a post-quantum TLS security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "apigateway", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsApiGatewayDomainName", + "ResourceGroup": "network", + "Description": "**API Gateway custom domain names** for REST APIs are assessed for use of a **post-quantum (PQ) TLS security policy** such as `SecurityPolicy_TLS13_1_2_PQ_2025_09`. Custom domains with legacy policies such as `TLS_1_0` or `TLS_1_2` lack hybrid ML-KEM key exchange, leaving captured traffic vulnerable to future quantum decryption.", + "Risk": "Without a PQ-ready TLS policy, traffic to API Gateway custom domains captured today can be decrypted once a **cryptographically relevant quantum computer** exists (**harvest-now, decrypt-later** attack). This threatens long-term **confidentiality** of API payloads, credentials, and bearer tokens.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-custom-domain-tls-version.html", + "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-security-policies-list.html", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/projects/post-quantum-cryptography" + ], + "Remediation": { + "Code": { + "CLI": "aws apigateway update-domain-name --domain-name --patch-operations op=replace,path=/securityPolicy,value=SecurityPolicy_TLS13_1_2_PQ_2025_09", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::ApiGateway::DomainName\n Properties:\n DomainName: api.example.com\n RegionalCertificateArn: \n SecurityPolicy: SecurityPolicy_TLS13_1_2_PQ_2025_09 # FIX: enhanced post-quantum security policy\n EndpointConfiguration:\n Types:\n - REGIONAL\n```", + "Other": "1. In the AWS Console, go to API Gateway > Custom domain names\n2. Select the custom domain and choose Edit on Domain name configurations\n3. Set Security policy to SecurityPolicy_TLS13_1_2_PQ_2025_09 (post-quantum) and Endpoint access mode to Strict\n4. Save the changes", + "Terraform": "```hcl\nresource \"aws_api_gateway_domain_name\" \"\" {\n domain_name = \"api.example.com\"\n regional_certificate_arn = \"\"\n security_policy = \"SecurityPolicy_TLS13_1_2_PQ_2025_09\" # FIX: enhanced post-quantum security policy\n endpoint_configuration {\n types = [\"REGIONAL\"]\n }\n}\n```" + }, + "Recommendation": { + "Text": "Migrate every API Gateway custom domain name to an **enhanced post-quantum TLS policy** such as `SecurityPolicy_TLS13_1_2_PQ_2025_09` that enables hybrid ML-KEM key exchange. Note that you must also enable Strict endpoint access mode and that mutual TLS is not supported on enhanced policies. Review allowed policies regularly as AWS publishes new PQ-ready options.", + "Url": "https://hub.prowler.com/check/apigateway_domain_name_pqc_tls_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "API Gateway HTTP and WebSocket APIs only support the legacy TLS_1_2 security policy and therefore cannot use post-quantum TLS today; this check evaluates REST API custom domain names only." +} diff --git a/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.py b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.py new file mode 100644 index 0000000000..d9c2d071f9 --- /dev/null +++ b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.py @@ -0,0 +1,55 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.apigateway.apigateway_client import ( + apigateway_client, +) + +PQC_APIGATEWAY_POLICIES_DEFAULT = [ + "SecurityPolicy_TLS13_1_2_FIPS_PFS_PQ_2025_09", + "SecurityPolicy_TLS13_1_2_PFS_PQ_2025_09", + "SecurityPolicy_TLS13_1_2_PQ_2025_09", +] + + +def _get_allowed_policies(configured_policies: object) -> list[str]: + if not isinstance(configured_policies, list): + return PQC_APIGATEWAY_POLICIES_DEFAULT + + return configured_policies + + +class apigateway_domain_name_pqc_tls_enabled(Check): + """Verify that every API Gateway custom domain name uses a post-quantum TLS policy. + + A custom domain name PASSES when its ``securityPolicy`` belongs to the + configured allowlist of enhanced post-quantum policies. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the API Gateway custom domain post-quantum TLS check. + + Returns: + A list of reports for API Gateway custom domain names and their + post-quantum TLS policy compliance status. + """ + findings = [] + pqc_policies = _get_allowed_policies( + apigateway_client.audit_config.get("apigateway_pqc_tls_allowed_policies") + ) + for domain in apigateway_client.domain_names: + report = Check_Report_AWS(metadata=self.metadata(), resource=domain) + policy = domain.security_policy or "" + if domain.security_policy in pqc_policies: + report.status = "PASS" + report.status_extended = ( + f"API Gateway custom domain {domain.name} uses post-quantum " + f"TLS policy {policy}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"API Gateway custom domain {domain.name} uses TLS policy " + f"{policy}, which is not in the post-quantum allowlist." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/apigateway/apigateway_service.py b/prowler/providers/aws/services/apigateway/apigateway_service.py index 4f887a2da8..099551f440 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_service.py +++ b/prowler/providers/aws/services/apigateway/apigateway_service.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Any, Optional from botocore.exceptions import ClientError from pydantic.v1 import BaseModel @@ -13,12 +13,45 @@ class APIGateway(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.rest_apis = [] + self.domain_names = [] self.__threading_call__(self._get_rest_apis) + self.__threading_call__(self._get_domain_names) self._get_authorizers() self._get_rest_api() self._get_stages() self._get_resources() + def _get_domain_names(self, regional_client: Any) -> None: + """Get API Gateway custom domain names for a regional client. + + Args: + regional_client: Regional API Gateway boto3 client used to list + custom domain names. + """ + logger.info("APIGateway - Getting custom domain names...") + try: + paginator = regional_client.get_paginator("get_domain_names") + for page in paginator.paginate(): + for item in page.get("items", []): + domain_name = item.get("domainName", "") + arn = f"arn:{self.audited_partition}:apigateway:{regional_client.region}::/domainnames/{domain_name}" + if not self.audit_resources or ( + is_resource_filtered(arn, self.audit_resources) + ): + self.domain_names.append( + DomainName( + name=domain_name, + arn=arn, + region=regional_client.region, + security_policy=item.get("securityPolicy", ""), + tags=[item.get("tags", {})], + ) + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _get_rest_apis(self, regional_client): logger.info("APIGateway - Getting Rest APIs...") try: @@ -249,3 +282,21 @@ class RestAPI(BaseModel): stages: list[Stage] = [] tags: Optional[list] = [] resources: list[PathResourceMethods] = [] + + +class DomainName(BaseModel): + """API Gateway custom domain name metadata. + + Attributes: + name: Custom domain name. + arn: Custom domain name ARN. + region: AWS region where the custom domain name exists. + security_policy: TLS security policy configured for the custom domain. + tags: Custom domain tags. + """ + + name: str + arn: str + region: str + security_policy: str = "" + tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/__init__.py b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.metadata.json new file mode 100644 index 0000000000..465f4335c2 --- /dev/null +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "cloudfront_distributions_pqc_tls_enabled", + "CheckTitle": "CloudFront distributions enforce a post-quantum TLS 1.3 security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "cloudfront", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsCloudFrontDistribution", + "ResourceGroup": "network", + "Description": "**CloudFront distributions** are assessed for use of a **TLS 1.3-only security policy** (`TLSv1.3_2025`). CloudFront's quantum-safe key exchanges (`X25519MLKEM768`, `SecP256r1MLKEM768`) only work with TLS 1.3. Distributions that allow TLS 1.2 (or older) fallback to classical key exchanges and are exposed to `harvest-now, decrypt-later` attacks.", + "Risk": "Without a TLS 1.3-only policy, viewer traffic captured today can be decrypted once a **cryptographically relevant quantum computer** is available. Distributions using the default CloudFront certificate (`*.cloudfront.net`) cannot enable a post-quantum policy because they are pinned to the legacy `TLSv1` policy.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html", + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DownloadDistValuesGeneral.html#DownloadDistValues-security-policy", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/projects/post-quantum-cryptography" + ], + "Remediation": { + "Code": { + "CLI": "aws cloudfront update-distribution --id --distribution-config '{...\"ViewerCertificate\":{\"MinimumProtocolVersion\":\"TLSv1.3_2025\",...}}'", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n ViewerCertificate:\n AcmCertificateArn: \n SslSupportMethod: sni-only\n MinimumProtocolVersion: TLSv1.3_2025 # FIX: enforces TLS 1.3 + post-quantum KEX\n```", + "Other": "1. In the AWS Console, go to CloudFront > Distributions\n2. Select the distribution and open the General tab\n3. Choose Edit on Settings\n4. Set Custom SSL certificate (do not use the default *.cloudfront.net certificate)\n5. Set Security policy to TLSv1.3_2025\n6. Save changes", + "Terraform": "```hcl\nresource \"aws_cloudfront_distribution\" \"\" {\n # ...\n viewer_certificate {\n acm_certificate_arn = \"\"\n ssl_support_method = \"sni-only\"\n minimum_protocol_version = \"TLSv1.3_2025\" # FIX: enforces TLS 1.3 + post-quantum KEX\n }\n}\n```" + }, + "Recommendation": { + "Text": "Use a **custom SSL certificate** with **SNI** support and set `MinimumProtocolVersion` to `TLSv1.3_2025` so CloudFront refuses TLS 1.2 handshakes and uses the hybrid ML-KEM key exchange. Distributions still using the default CloudFront certificate must be migrated to a custom certificate to enable post-quantum TLS.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_pqc_tls_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudfront_distributions_using_deprecated_ssl_protocols" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.py b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.py new file mode 100644 index 0000000000..6c472d43c7 --- /dev/null +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.py @@ -0,0 +1,56 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.cloudfront.cloudfront_client import ( + cloudfront_client, +) + +PQC_CLOUDFRONT_POLICIES_DEFAULT = [ + "TLSv1.3_2025", +] + + +class cloudfront_distributions_pqc_tls_enabled(Check): + """Verify that every CloudFront distribution enforces TLS 1.3 with post-quantum key exchange. + + Quantum-safe key exchanges (``X25519MLKEM768``, ``SecP256r1MLKEM768``) are + only available on TLS 1.3 viewer connections. A distribution PASSES when + its ``MinimumProtocolVersion`` belongs to the configured allowlist of + TLS 1.3-only policies. Distributions that rely on the default CloudFront + certificate are pinned to the legacy ``TLSv1`` policy and therefore FAIL. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the CloudFront post-quantum TLS policy check. + + Returns: + A list of reports containing each CloudFront distribution's + post-quantum TLS compliance status. + """ + findings = [] + pqc_policies = cloudfront_client.audit_config.get( + "cloudfront_pqc_min_protocol_versions", PQC_CLOUDFRONT_POLICIES_DEFAULT + ) + for distribution in cloudfront_client.distributions.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=distribution) + policy = distribution.minimum_protocol_version or "" + if distribution.default_certificate: + report.status = "FAIL" + report.status_extended = ( + f"CloudFront Distribution {distribution.id} uses the default " + "CloudFront certificate, which pins the security policy to " + "TLSv1 and cannot enable post-quantum TLS." + ) + elif distribution.minimum_protocol_version in pqc_policies: + report.status = "PASS" + report.status_extended = ( + f"CloudFront Distribution {distribution.id} uses post-quantum " + f"TLS policy {policy}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"CloudFront Distribution {distribution.id} uses TLS policy " + f"{policy}, which is not in the post-quantum allowlist." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_service.py b/prowler/providers/aws/services/cloudfront/cloudfront_service.py index 0826c9adfd..660b483c4c 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_service.py +++ b/prowler/providers/aws/services/cloudfront/cloudfront_service.py @@ -48,6 +48,9 @@ class CloudFront(AWSService): "SSLSupportMethod", "static-ip" ) ) + minimum_protocol_version = item["ViewerCertificate"].get( + "MinimumProtocolVersion", "" + ) origins = [] for origin in item.get("Origins", {}).get("Items", []): origins.append( @@ -79,6 +82,7 @@ class CloudFront(AWSService): ssl_support_method=ssl_support_method, default_certificate=default_certificate, certificate=certificate, + minimum_protocol_version=minimum_protocol_version, ) self.distributions[distribution_id] = distribution @@ -268,3 +272,4 @@ class Distribution(BaseModel): origin_failover: Optional[bool] = None ssl_support_method: Optional[SSLSupportMethod] = None certificate: Optional[str] = None + minimum_protocol_version: str = "" diff --git a/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.metadata.json b/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.metadata.json index 79b02882d7..4c7962242e 100644 --- a/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.metadata.json +++ b/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.metadata.json @@ -37,7 +37,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "privilege-escalation" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/rolesanywhere/__init__.py b/prowler/providers/aws/services/rolesanywhere/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_client.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_client.py new file mode 100644 index 0000000000..254f0a5dbf --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_client.py @@ -0,0 +1,6 @@ +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import ( + RolesAnywhere, +) +from prowler.providers.common.provider import Provider + +rolesanywhere_client = RolesAnywhere(Provider.get_global_provider()) diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py new file mode 100644 index 0000000000..46dca281d6 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py @@ -0,0 +1,64 @@ +from typing import Dict, List + +from pydantic.v1 import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.lib.scan_filters.scan_filters import is_resource_filtered +from prowler.providers.aws.lib.service.service import AWSService + + +class RolesAnywhere(AWSService): + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + self.trust_anchors = {} + self.__threading_call__(self._list_trust_anchors) + + def _list_trust_anchors(self, regional_client): + logger.info("RolesAnywhere - Listing Trust Anchors...") + try: + paginator = regional_client.get_paginator("list_trust_anchors") + for page in paginator.paginate(): + for ta in page.get("trustAnchors", []): + arn = ta.get("trustAnchorArn", "") + if not arn: + continue + if self.audit_resources and not is_resource_filtered( + arn, self.audit_resources + ): + continue + source = ta.get("source", {}) or {} + source_data = source.get("sourceData", {}) or {} + tags = [] + try: + tags = regional_client.list_tags_for_resource( + resourceArn=arn + ).get("tags", []) + except Exception as error: + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.trust_anchors[arn] = TrustAnchor( + arn=arn, + id=ta.get("trustAnchorId", ""), + name=ta.get("name", ""), + region=regional_client.region, + enabled=ta.get("enabled", False), + source_type=source.get("sourceType", ""), + acm_pca_arn=source_data.get("acmPcaArn", ""), + tags=tags, + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class TrustAnchor(BaseModel): + arn: str + id: str + name: str + region: str + enabled: bool = False + source_type: str = "" + acm_pca_arn: str = "" + tags: List[Dict[str, str]] = Field(default_factory=list) diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/__init__.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.metadata.json b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.metadata.json new file mode 100644 index 0000000000..7d919f83a7 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "rolesanywhere_trust_anchor_pqc_pki", + "CheckTitle": "IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "rolesanywhere", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsRolesAnywhereTrustAnchor", + "ResourceGroup": "security", + "Description": "**IAM Roles Anywhere trust anchors** are assessed for use of a **post-quantum digital signature algorithm** (ML-DSA). A trust anchor backed by an AWS Private CA whose `KeyAlgorithm` is RSA or ECC produces signatures vulnerable to forgery by a future quantum attacker, allowing an unintended actor to issue certificates and obtain unauthorized AWS access.", + "Risk": "Trust anchors are the root of trust for workloads authenticating to AWS via X.509 certificates. If the signing CA uses RSA or ECC, an attacker with quantum capability could forge end-entity certificates and impersonate workloads. Migrating trust anchors to **ML-DSA-backed PKI** (NIST FIPS 204) protects this control plane in the post-quantum era.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/rolesanywhere/latest/userguide/introduction.html", + "https://aws.amazon.com/about-aws/whats-new/2026/03/iam-roles-anywhere-post-quantum-digital-certificates/", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/pubs/fips/204/final" + ], + "Remediation": { + "Code": { + "CLI": "aws rolesanywhere create-trust-anchor --name pqc-trust --source 'sourceType=AWS_ACM_PCA,sourceData={acmPcaArn=}' --enabled", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::RolesAnywhere::TrustAnchor\n Properties:\n Name: pqc-trust\n Enabled: true\n Source:\n SourceType: AWS_ACM_PCA\n SourceData:\n AcmPcaArn: # FIX: PCA must use ML_DSA key algorithm\n```", + "Other": "1. Create a new AWS Private CA with a post-quantum KeyAlgorithm (ML_DSA_44/65/87)\n2. Create a new Roles Anywhere trust anchor with sourceType=AWS_ACM_PCA pointing to the new CA\n3. Rotate end-entity certificates issued from the new CA\n4. Delete the legacy trust anchor once workloads have rotated", + "Terraform": "```hcl\nresource \"aws_rolesanywhere_trust_anchor\" \"\" {\n name = \"pqc-trust\"\n enabled = true\n source {\n source_type = \"AWS_ACM_PCA\"\n source_data {\n acm_pca_arn = \"\" # FIX: PCA must use ML_DSA key algorithm\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Back IAM Roles Anywhere trust anchors with an **AWS Private CA that uses an ML-DSA key algorithm**. For trust anchors backed by an external certificate bundle, ensure the certificates were issued by an ML-DSA CA and re-verify periodically as the cryptographic landscape evolves.", + "Url": "https://hub.prowler.com/check/rolesanywhere_trust_anchor_pqc_pki" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [ + "acmpca_certificate_authority_pqc_key_algorithm" + ], + "RelatedTo": [], + "Notes": "Trust anchors backed by an external CERTIFICATE_BUNDLE cannot be evaluated automatically by this check and are reported as FAIL with guidance to migrate to an AWS Private CA using an ML-DSA key algorithm." +} diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py new file mode 100644 index 0000000000..495ed4e9c7 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py @@ -0,0 +1,79 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.acmpca.acmpca_client import acmpca_client +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_client import ( + rolesanywhere_client, +) + +PQC_PCA_KEY_ALGORITHMS_DEFAULT = [ + "ML_DSA_44", + "ML_DSA_65", + "ML_DSA_87", +] + + +class rolesanywhere_trust_anchor_pqc_pki(Check): + """Verify that IAM Roles Anywhere trust anchors are backed by a post-quantum PKI. + + For trust anchors whose source is ``AWS_ACM_PCA``, the linked Private CA's + ``KeyAlgorithm`` is checked against the configured ML-DSA allowlist. + Trust anchors backed by an external ``CERTIFICATE_BUNDLE`` are reported as + FAIL because their certificate signature algorithm cannot be inspected + from the IAM Roles Anywhere API alone. + """ + + def execute(self) -> list[Check_Report_AWS]: + findings = [] + pqc_algorithms = rolesanywhere_client.audit_config.get( + "rolesanywhere_pqc_pca_key_algorithms", PQC_PCA_KEY_ALGORITHMS_DEFAULT + ) + for trust_anchor in rolesanywhere_client.trust_anchors.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=trust_anchor) + if trust_anchor.source_type == "AWS_ACM_PCA": + linked_ca = acmpca_client.certificate_authorities.get( + trust_anchor.acm_pca_arn + ) + if linked_ca and linked_ca.status != "ACTIVE": + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " + f"backed by Private CA {linked_ca.id}, which is in " + f"{linked_ca.status or ''} status and cannot be " + "used as an active post-quantum PKI trust root." + ) + elif linked_ca and linked_ca.key_algorithm in pqc_algorithms: + report.status = "PASS" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " + f"backed by Private CA {linked_ca.id} using post-quantum " + f"key algorithm {linked_ca.key_algorithm}." + ) + elif linked_ca: + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " + f"backed by Private CA {linked_ca.id} using key algorithm " + f"{linked_ca.key_algorithm or ''}, which is not " + "post-quantum (ML-DSA)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " + f"backed by Private CA {trust_anchor.acm_pca_arn}, which " + "could not be inspected (cross-account or missing " + "acm-pca permissions). Verify the CA uses an ML-DSA key " + "algorithm." + ) + else: + source = trust_anchor.source_type or "" + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} uses " + f"source type {source}; the certificate signature algorithm " + "cannot be inspected automatically. Migrate to an AWS Private " + "CA using an ML-DSA key algorithm to enable post-quantum " + "evaluation." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/__init__.py b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.metadata.json b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.metadata.json new file mode 100644 index 0000000000..773c85b7ab --- /dev/null +++ b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "transfer_server_pqc_ssh_kex_enabled", + "CheckTitle": "AWS Transfer Family server uses a post-quantum hybrid SSH key exchange security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "transfer", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsTransferServer", + "ResourceGroup": "network", + "Description": "**AWS Transfer Family servers** (SFTP, FTPS, AS2) are assessed for use of an approved **post-quantum (PQ) hybrid SSH key exchange security policy**. Servers whose `SecurityPolicyName` is not in the configured allowlist of PQ-ready Transfer Family security policies leave file-transfer sessions exposed to **harvest-now, decrypt-later** attacks.", + "Risk": "Without a PQ-ready security policy, SSH/SFTP traffic captured today can be stored and decrypted in the future once a **cryptographically relevant quantum computer** is available. This threatens long-term **confidentiality** of transferred files, credentials, and metadata.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/transfer/latest/userguide/post-quantum-security-policies.html", + "https://docs.aws.amazon.com/transfer/latest/userguide/security-policies.html", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/projects/post-quantum-cryptography" + ], + "Remediation": { + "Code": { + "CLI": "aws transfer update-server --server-id --security-policy-name TransferSecurityPolicy-2025-03", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::Transfer::Server\n Properties:\n Protocols:\n - SFTP\n SecurityPolicyName: TransferSecurityPolicy-2025-03 # FIX: post-quantum hybrid SSH KEX policy\n```", + "Other": "1. In the AWS Console, go to AWS Transfer Family > Servers\n2. Select the server and choose Edit on the Additional details panel\n3. Set Cryptographic algorithm options (Security policy) to TransferSecurityPolicy-2025-03 (or another approved PQ policy)\n4. Save the changes", + "Terraform": "```hcl\nresource \"aws_transfer_server\" \"\" {\n protocols = [\"SFTP\"]\n security_policy_name = \"TransferSecurityPolicy-2025-03\" # FIX: post-quantum hybrid SSH KEX policy\n endpoint_type = \"PUBLIC\"\n}\n```" + }, + "Recommendation": { + "Text": "Migrate AWS Transfer Family servers to a **post-quantum security policy** (e.g., `TransferSecurityPolicy-2025-03`, `TransferSecurityPolicy-FIPS-2025-03`, `TransferSecurityPolicy-AS2Restricted-2025-07`) that adds ML-KEM hybrid SSH key exchange. Avoid deprecated `*-PQ-SSH-Experimental-2023-04` policies. Review allowed policies regularly as AWS publishes new PQ-ready options.", + "Url": "https://hub.prowler.com/check/transfer_server_pqc_ssh_kex_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "transfer_server_in_transit_encryption_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.py b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.py new file mode 100644 index 0000000000..a7d70df2df --- /dev/null +++ b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.py @@ -0,0 +1,50 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.transfer.transfer_client import transfer_client + +PQC_TRANSFER_POLICIES_DEFAULT = [ + "TransferSecurityPolicy-2025-03", + "TransferSecurityPolicy-FIPS-2025-03", + "TransferSecurityPolicy-AS2Restricted-2025-07", +] + + +class transfer_server_pqc_ssh_kex_enabled(Check): + """Verify that every AWS Transfer Family server uses a post-quantum security policy. + + A Transfer Family server PASSES when its ``SecurityPolicyName`` is in the + configured allowlist of policies that enable hybrid ML-KEM SSH key exchange. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Check whether Transfer Family servers use approved PQ SSH KEX policies. + + Iterates through discovered AWS Transfer Family servers and compares each + server's ``SecurityPolicyName`` with the configured allowlist of + post-quantum hybrid SSH key exchange security policies. + + Returns: + list[Check_Report_AWS]: A list of reports for each Transfer Family + server, including the evaluated security policy context. + """ + findings = [] + pqc_policies = transfer_client.audit_config.get( + "transfer_pqc_ssh_allowed_policies", PQC_TRANSFER_POLICIES_DEFAULT + ) + for server in transfer_client.servers.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=server) + policy = server.security_policy_name or "" + if server.security_policy_name in pqc_policies: + report.status = "PASS" + report.status_extended = ( + f"Transfer Server {server.id} uses post-quantum security policy " + f"{policy}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Transfer Server {server.id} uses security policy {policy}, " + "which does not enable post-quantum hybrid SSH key exchange." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/transfer/transfer_service.py b/prowler/providers/aws/services/transfer/transfer_service.py index f86e195a31..968b0c9e60 100644 --- a/prowler/providers/aws/services/transfer/transfer_service.py +++ b/prowler/providers/aws/services/transfer/transfer_service.py @@ -46,6 +46,9 @@ class Transfer(AWSService): ) for protocol in server_description.get("Protocols", []): server.protocols.append(Protocol(protocol)) + server.security_policy_name = server_description.get( + "SecurityPolicyName", "" + ) server.tags = server_description.get("Tags", []) except Exception as error: logger.error( @@ -65,4 +68,5 @@ class Server(BaseModel): id: str region: str protocols: List[Protocol] = Field(default_factory=list) + security_policy_name: str = "" tags: List[Dict[str, str]] = Field(default_factory=list) diff --git a/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.metadata.json new file mode 100644 index 0000000000..362b8e4d73 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_azure_monitor_enabled", + "CheckTitle": "AKS cluster has Azure Monitor metrics enabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**Azure Kubernetes Service** clusters are evaluated for **Azure Monitor managed service for Prometheus** (the cluster's `azureMonitorProfile.metrics` configuration). When enabled, cluster and workload metrics are collected into an Azure Monitor workspace for dashboards and alerting. This is distinct from Container Insights (the `omsagent` logs addon).", + "Risk": "Without **Azure Monitor metrics**, cluster degradation, resource exhaustion, and anomalous behavior go undetected. Lack of metric-based observability delays incident detection and hampers investigation after a security or availability event.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-monitor/containers/kubernetes-monitoring-enable", + "https://learn.microsoft.com/en-us/azure/azure-monitor/containers/prometheus-metrics-enable", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.containerservice/managedclusters" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --enable-azure-monitor-metrics", + "NativeIaC": "```bicep\n// Bicep: AKS cluster with Azure Monitor managed Prometheus metrics enabled\nresource aks 'Microsoft.ContainerService/managedClusters@2024-02-01' = {\n name: ''\n location: resourceGroup().location\n properties: {\n azureMonitorProfile: {\n metrics: {\n enabled: true // CRITICAL: enables Azure Monitor managed service for Prometheus (metrics)\n }\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your AKS cluster\n2. In the left menu, select Monitoring > Insights\n3. Click Configure monitoring\n4. Ensure 'Enable Prometheus metrics' is selected and choose (or create) an Azure Monitor workspace\n5. Click Configure to enable Azure Monitor managed Prometheus metrics", + "Terraform": "```hcl\n# Terraform: AKS cluster with Azure Monitor managed Prometheus metrics enabled\nresource \"azurerm_kubernetes_cluster\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n\n monitor_metrics {} # CRITICAL: enables Azure Monitor managed service for Prometheus (metrics)\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Azure Monitor managed service for Prometheus** on AKS clusters so cluster and workload metrics are collected into an Azure Monitor workspace. Pair it with alerting on cluster health and resource saturation, and consider also enabling Container Insights (logs) for full observability.", + "Url": "https://hub.prowler.com/check/aks_cluster_azure_monitor_enabled" + } + }, + "Categories": [ + "logging" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.py b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.py new file mode 100644 index 0000000000..0c47f6373c --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.py @@ -0,0 +1,32 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_azure_monitor_enabled(Check): + """ + Ensure Azure Monitor is enabled for AKS clusters. + + This check evaluates whether each Azure Kubernetes Service cluster has Azure Monitor integration enabled for metrics collection, log aggregation, and alerting. + + - PASS: The cluster has Azure Monitor enabled. + - FAIL: The cluster does not have Azure Monitor enabled. + """ + + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + if cluster.azure_monitor_enabled: + report.status = "PASS" + report.status_extended = f"Cluster '{cluster.name}' has Azure Monitor managed Prometheus metrics enabled." + else: + report.status = "FAIL" + report.status_extended = f"Cluster '{cluster.name}' does not have Azure Monitor managed Prometheus metrics enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json new file mode 100644 index 0000000000..90bbd45f3b --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_defender_enabled", + "CheckTitle": "AKS cluster has Microsoft Defender security profile enabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**AKS clusters** are evaluated for the **Microsoft Defender security profile** (`securityProfile.defender.securityMonitoring.enabled=true`). Defender for Containers extends security monitoring, threat detection, vulnerability assessment, and security posture insights to Azure Kubernetes Service workloads.", + "Risk": "Without the Microsoft Defender security profile, AKS runtime threats such as **cryptomining**, suspicious Kubernetes API activity, vulnerable container images, and malicious workload behavior may go undetected. Compromised containers can pivot to cluster resources and cloud APIs, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-containers-deployment-overview", + "https://learn.microsoft.com/en-us/cli/azure/aks?view=azure-cli-latest#az-aks-update", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.containerservice/managedclusters" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --enable-defender", + "NativeIaC": "```bicep\n// AKS cluster with Microsoft Defender security monitoring enabled\nresource aks 'Microsoft.ContainerService/managedClusters@2024-05-01' = {\n name: ''\n location: ''\n identity: {\n type: 'SystemAssigned'\n }\n properties: {\n dnsPrefix: ''\n securityProfile: {\n defender: {\n logAnalyticsWorkspaceResourceId: ''\n securityMonitoring: {\n enabled: true // Critical: enables Defender security monitoring for AKS\n }\n }\n }\n agentPoolProfiles: [\n {\n name: 'system'\n count: 1\n vmSize: 'Standard_DS2_v2'\n mode: 'System'\n }\n ]\n }\n}\n```", + "Other": "1. In Microsoft Defender for Cloud, enable the Containers plan for the subscription that contains the AKS cluster\n2. In the AKS cluster configuration, enable the Microsoft Defender security profile\n3. Verify that Defender security monitoring is enabled for the managed cluster", + "Terraform": "```hcl\nresource \"azurerm_log_analytics_workspace\" \"example\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n sku = \"PerGB2018\"\n}\n\nresource \"azurerm_kubernetes_cluster\" \"example\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n\n default_node_pool {\n name = \"system\"\n node_count = 1\n vm_size = \"Standard_DS2_v2\"\n }\n\n identity {\n type = \"SystemAssigned\"\n }\n\n microsoft_defender {\n log_analytics_workspace_id = azurerm_log_analytics_workspace.example.id\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable the Microsoft Defender security profile for AKS clusters and ensure the subscription has Defender for Containers enabled. Route security monitoring data to the appropriate workspace and review Defender alerts and recommendations as part of the cluster operations process.", + "Url": "https://hub.prowler.com/check/aks_cluster_defender_enabled" + } + }, + "Categories": [ + "threat-detection", + "cluster-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check evaluates the AKS managed cluster Defender security monitoring flag. Defender for Containers plan availability, billing, workspace routing, and alert response processes should be validated separately at the subscription and security operations levels." +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py new file mode 100644 index 0000000000..08074754a6 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py @@ -0,0 +1,29 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_defender_enabled(Check): + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + if cluster.defender_enabled is True: + report.status = "PASS" + report.status_extended = ( + f"Cluster '{cluster.name}' has Defender for Containers " + "enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Cluster '{cluster.name}' does not have Defender for " + "Containers enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.metadata.json new file mode 100644 index 0000000000..5577d4799c --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_local_accounts_disabled", + "CheckTitle": "AKS cluster has local accounts disabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**Azure Kubernetes Service** clusters are evaluated for **local account** status. Disabling local accounts forces all cluster authentication through Microsoft Entra ID, ensuring centralized identity management, MFA, and conditional access.", + "Risk": "Local accounts bypass **Entra ID** authentication, MFA, and conditional access policies. Compromised local credentials (such as the static cluster-admin certificate) provide persistent cluster access without an identity-based audit trail or governance.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/aks/manage-local-accounts-managed-azure-ad", + "https://learn.microsoft.com/en-us/cli/azure/aks?view=azure-cli-latest#az-aks-update", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.containerservice/managedclusters" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --disable-local-accounts", + "NativeIaC": "```bicep\n// Bicep: AKS cluster with local accounts disabled\nresource aks 'Microsoft.ContainerService/managedClusters@2024-02-01' = {\n name: ''\n location: resourceGroup().location\n properties: {\n disableLocalAccounts: true // CRITICAL: forces authentication through Entra ID\n aadProfile: {\n managed: true\n enableAzureRbac: true\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your AKS cluster\n2. Ensure Microsoft Entra ID (AAD) authentication is configured for the cluster\n3. Under Settings, select Cluster configuration (or Authentication and Authorization)\n4. Set Local accounts to Disabled\n5. Save the configuration", + "Terraform": "```hcl\n# Terraform: AKS cluster with local accounts disabled\nresource \"azurerm_kubernetes_cluster\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n local_account_disabled = true # CRITICAL: forces authentication through Entra ID\n\n azure_active_directory_role_based_access_control {\n azure_rbac_enabled = true\n }\n}\n```" + }, + "Recommendation": { + "Text": "Disable local accounts on AKS clusters and require **Microsoft Entra ID** authentication with Azure RBAC. Before disabling, confirm Entra ID integration and break-glass access are in place so administrators retain authenticated access, then enforce MFA and conditional access on cluster identities.", + "Url": "https://hub.prowler.com/check/aks_cluster_local_accounts_disabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.py b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.py new file mode 100644 index 0000000000..85670e0d15 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_local_accounts_disabled(Check): + """ + Ensure local accounts are disabled on AKS clusters. + + This check evaluates whether each Azure Kubernetes Service cluster has local accounts disabled, forcing all authentication through Microsoft Entra ID. + + - PASS: The cluster has local accounts disabled. + - FAIL: The cluster has local accounts enabled. + """ + + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + if cluster.local_accounts_disabled: + report.status = "PASS" + report.status_extended = ( + f"Cluster '{cluster.name}' has local accounts disabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Cluster '{cluster.name}' has local accounts enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/databricks/databricks_service.py b/prowler/providers/azure/services/databricks/databricks_service.py index 1e30452294..b7367d3cbb 100644 --- a/prowler/providers/azure/services/databricks/databricks_service.py +++ b/prowler/providers/azure/services/databricks/databricks_service.py @@ -62,6 +62,9 @@ class Databricks(AzureService): else: managed_disk_encryption = None + enable_no_public_ip = getattr( + workspace_parameters, "enable_no_public_ip", None + ) workspaces[subscription][workspace.id] = DatabricksWorkspace( id=workspace.id, name=workspace.name, @@ -69,6 +72,11 @@ class Databricks(AzureService): public_network_access=getattr( workspace, "public_network_access", None ), + no_public_ip_enabled=( + getattr(enable_no_public_ip, "value", None) + if enable_no_public_ip + else None + ), custom_managed_vnet_id=( getattr( workspace_parameters, "custom_virtual_network_id", None @@ -111,6 +119,7 @@ class DatabricksWorkspace(BaseModel): name: The name of the workspace. location: The Azure region where the workspace is deployed. public_network_access: Whether public network access is "Enabled" or "Disabled", if configured. + no_public_ip_enabled: Whether secure cluster connectivity (no public IP) is enabled. None when the workspace does not expose this classic-compute setting (e.g. serverless workspaces). custom_managed_vnet_id: The ID of the custom managed virtual network, if configured. managed_disk_encryption: The encryption settings for the workspace's managed disks. """ @@ -119,5 +128,6 @@ class DatabricksWorkspace(BaseModel): name: str location: str public_network_access: Optional[str] = None + no_public_ip_enabled: Optional[bool] = None custom_managed_vnet_id: Optional[str] = None managed_disk_encryption: Optional[ManagedDiskEncryption] = None diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/__init__.py b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json new file mode 100644 index 0000000000..4062199474 --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "databricks_workspace_no_public_ip_enabled", + "CheckTitle": "Databricks workspace has secure cluster connectivity (no public IP)", + "CheckType": [], + "ServiceName": "databricks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.databricks/workspaces", + "ResourceGroup": "ai_ml", + "Description": "**Azure Databricks workspaces** are evaluated for **secure cluster connectivity** (No Public IP / NPIP). When enabled, compute (cluster) nodes are deployed without public IP addresses and communicate with the control plane through a secure relay, reducing the workspace's exposure to the internet.", + "Risk": "Without **secure cluster connectivity**, Databricks compute nodes are assigned **public IP addresses** and are directly reachable from the internet. This enables **port scanning**, **lateral movement** from a compromised node, and **data exfiltration** that bypasses private-network controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/secure-cluster-connectivity", + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/vnet-inject", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.databricks/workspaces" + ], + "Remediation": { + "Code": { + "CLI": "az databricks workspace create --name --resource-group --location --sku premium --enable-no-public-ip", + "NativeIaC": "```bicep\n// Bicep: Deploy a Databricks workspace with secure cluster connectivity (No Public IP)\nresource workspace 'Microsoft.Databricks/workspaces@2023-02-01' = {\n name: ''\n location: resourceGroup().location\n sku: {\n name: 'premium'\n }\n properties: {\n managedResourceGroupId: '/subscriptions//resourceGroups/'\n parameters: {\n enableNoPublicIp: {\n value: true // CRITICAL: Deploys cluster nodes without public IP addresses\n }\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and start creating a new Azure Databricks workspace (No Public IP must be set at creation; it cannot be enabled on an existing workspace)\n2. On the Networking tab, set Deploy Azure Databricks workspace with Secure Cluster Connectivity (No Public IP) to Yes\n3. Complete the VNet injection configuration if required\n4. Review + create the workspace\n5. Migrate workloads to the new workspace and decommission the old one", + "Terraform": "```hcl\n# Terraform: Databricks workspace with secure cluster connectivity (No Public IP)\nresource \"azurerm_databricks_workspace\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"premium\"\n\n custom_parameters {\n no_public_ip = true # CRITICAL: Deploys cluster nodes without public IP addresses\n }\n}\n```" + }, + "Recommendation": { + "Text": "Deploy Databricks workspaces with **secure cluster connectivity (No Public IP)** so compute nodes have no public IP addresses. Because this is set at creation, plan a migration for existing workspaces, and pair it with **VNet injection**, **private endpoints / disabled public network access**, and least-privilege NSG rules to uphold **defense in depth**.", + "Url": "https://hub.prowler.com/check/databricks_workspace_no_public_ip_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Secure cluster connectivity (No Public IP) applies to classic compute and is set at workspace creation. Serverless workspaces do not expose this setting (they have no customer-managed cluster nodes with public IPs) and are reported as MANUAL for verification." +} diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py new file mode 100644 index 0000000000..13b7742ffa --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.databricks.databricks_client import ( + databricks_client, +) + + +class databricks_workspace_no_public_ip_enabled(Check): + """ + Ensure Azure Databricks workspaces have secure cluster connectivity (no public IP) enabled. + + This check evaluates whether each Azure Databricks workspace in the subscription is deployed with secure cluster connectivity (No Public IP / NPIP), so cluster nodes are not assigned public IP addresses. + + Secure cluster connectivity is a classic-compute setting. Serverless workspaces do not expose it (they have no customer-managed cluster nodes with public IPs), so the workspace is reported as MANUAL for verification rather than failed. + + - PASS: The workspace has secure cluster connectivity enabled (no_public_ip_enabled is True). + - FAIL: The workspace has secure cluster connectivity disabled (no_public_ip_enabled is False). + - MANUAL: The workspace does not expose the setting (no_public_ip_enabled is None, e.g. serverless workspaces). + """ + + def execute(self): + findings = [] + for subscription, workspaces in databricks_client.workspaces.items(): + subscription_name = databricks_client.subscriptions.get( + subscription, subscription + ) + for workspace in workspaces.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=workspace + ) + report.subscription = subscription + if workspace.no_public_ip_enabled is None: + report.status = "MANUAL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) does not expose secure cluster connectivity (no public IP) settings (for example, serverless workspaces have no public-IP cluster nodes); verify the network configuration manually." + elif workspace.no_public_ip_enabled: + report.status = "PASS" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) has secure cluster connectivity (no public IP) enabled." + else: + report.status = "FAIL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) does not have secure cluster connectivity (no public IP) enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/__init__.py b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json new file mode 100644 index 0000000000..cae92490af --- /dev/null +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "defender_ensure_defender_cspm_is_on", + "CheckTitle": "Microsoft Defender CSPM is set to On", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.security/pricings", + "ResourceGroup": "security", + "Description": "**Microsoft Defender for Cloud** Cloud Security Posture Management (CSPM) plan is evaluated for **standard tier** activation. Defender CSPM provides advanced posture management capabilities including attack path analysis, cloud security explorer, agentless scanning, and governance rules.", + "Risk": "Without Defender CSPM, the subscription relies on **foundational CSPM** (free tier) which lacks attack path analysis, agentless vulnerability scanning, and security governance. Advanced threats exploiting misconfiguration chains go undetected.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-cloud-security-posture-management", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-enhanced-security" + ], + "Remediation": { + "Code": { + "CLI": "az security pricing create -n CloudPosture --tier Standard", + "NativeIaC": "```bicep\ntargetScope = 'subscription'\n\nresource defenderCSPM 'Microsoft.Security/pricings@2024-01-01' = {\n name: 'CloudPosture'\n properties: {\n pricingTier: 'Standard' // Critical: enables Defender CSPM\n }\n}\n```", + "Other": "1. Sign in to Azure portal\n2. Go to Microsoft Defender for Cloud\n3. Select Environment Settings\n4. Click on the subscription\n5. Set Cloud Security Posture Management (CSPM) to On\n6. Click Save", + "Terraform": "```hcl\nresource \"azurerm_security_center_subscription_pricing\" \"\" {\n tier = \"Standard\" # Critical: enables Defender CSPM\n resource_type = \"CloudPosture\"\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Defender CSPM** standard tier for advanced cloud security posture management. Evaluate the cost against the security benefits \u2014 CSPM provides attack path analysis and agentless scanning.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_cspm_is_on" + } + }, + "Categories": [ + "threat-detection" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py new file mode 100644 index 0000000000..cf7957400c --- /dev/null +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.defender.defender_client import defender_client + + +class defender_ensure_defender_cspm_is_on(Check): + """ + Ensure Microsoft Defender Cloud Security Posture Management (CSPM) is set to On. + + This check evaluates whether the Defender CSPM plan (CloudPosture pricing) is enabled with the Standard tier for each subscription. + + - PASS: The CloudPosture pricing tier is "Standard" (Defender CSPM is on). + - FAIL: The CloudPosture pricing tier is not "Standard" (Defender CSPM is off). + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) + if "CloudPosture" in pricings: + report = Check_Report_Azure( + metadata=self.metadata(), + resource=pricings["CloudPosture"], + ) + report.subscription = subscription + report.resource_name = "Defender plan CSPM" + report.status = "PASS" + report.status_extended = f"Defender plan CSPM from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." + if pricings["CloudPosture"].pricing_tier != "Standard": + report.status = "FAIL" + report.status_extended = f"Defender plan CSPM from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." + + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/__init__.py b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.metadata.json b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.metadata.json new file mode 100644 index 0000000000..c786fd49af --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "entra_app_registration_credential_not_expired", + "CheckTitle": "App registration credentials are not expired or expiring soon", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Microsoft Entra ID** app registrations are evaluated for **credential validity**. Each app's password secrets and certificate credentials are checked for expiration. Credentials that are already expired, expiring within 30 days, or have no expiration date are flagged.", + "Risk": "Expired credentials cause **service outages** when apps can no longer authenticate. Credentials without expiration violate **least privilege** by persisting indefinitely. Long-lived or leaked secrets enable **unauthorized API access**, **data exfiltration**, and **lateral movement** via the app's permissions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity-platform/howto-create-service-principal-portal", + "https://learn.microsoft.com/en-us/graph/api/resources/application" + ], + "Remediation": { + "Code": { + "CLI": "az ad app credential reset --id --years 1", + "NativeIaC": "", + "Other": "1. Sign in to Microsoft Entra admin center\n2. Go to Identity > Applications > App registrations\n3. Select the application with the expiring credential\n4. Go to Certificates & secrets\n5. Delete the expired credential\n6. Add a new credential with an appropriate expiration (recommended: 6-12 months)\n7. Update the consuming application with the new credential\n8. Consider migrating to managed identities or federated credentials where possible", + "Terraform": "" + }, + "Recommendation": { + "Text": "Rotate app registration credentials before expiration. Set expiration to 6-12 months maximum. Prefer **managed identities** or **federated credentials** over secrets. Monitor credential expiry with Azure Monitor alerts or Microsoft Entra workbooks.", + "Url": "https://hub.prowler.com/check/entra_app_registration_credential_not_expired" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check evaluates both password secrets and certificate credentials. Each credential is reported individually. Apps with no credentials are skipped." +} diff --git a/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.py b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.py new file mode 100644 index 0000000000..5cbdf009a0 --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone + +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.entra.entra_client import entra_client + +EXPIRY_WARNING_DAYS = 30 + + +class entra_app_registration_credential_not_expired(Check): + """ + Ensure Microsoft Entra ID app registration credentials are not expired or expiring soon. + + This check evaluates each app registration's password secrets and certificate credentials. A credential is reported individually and flagged when it is already expired, expiring within 30 days, or has no expiration date. Apps with no credentials are skipped. + + - PASS: The credential is valid for more than 30 days. + - FAIL: The credential is expired, expiring within 30 days, or has no expiration date. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for tenant_domain, apps in entra_client.app_registrations.items(): + for app_id, app in apps.items(): + if not app.credentials: + continue + + for credential in app.credentials: + report = Check_Report_Azure(metadata=self.metadata(), resource=app) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = ( + f"{app.name} ({credential.credential_type}: " + f"{credential.display_name or 'unnamed'})" + ) + + if credential.end_date_time is None: + report.status = "FAIL" + report.status_extended = ( + f"App '{app.name}' has a {credential.credential_type} " + f"credential with no expiration date." + ) + else: + now = datetime.now(timezone.utc) + end = credential.end_date_time + if end.tzinfo is None: + end = end.replace(tzinfo=timezone.utc) + if end <= now: + days_ago = (now - end).days + report.status = "FAIL" + report.status_extended = ( + f"App '{app.name}' has a {credential.credential_type} " + f"credential that expired {days_ago} days ago." + ) + elif (end - now).days <= EXPIRY_WARNING_DAYS: + days_left = (end - now).days + report.status = "FAIL" + report.status_extended = ( + f"App '{app.name}' has a {credential.credential_type} " + f"credential expiring in {days_left} days (within the " + f"{EXPIRY_WARNING_DAYS}-day rotation threshold); rotate it soon." + ) + else: + days_left = (end - now).days + report.status = "PASS" + report.status_extended = ( + f"App '{app.name}' has a {credential.credential_type} " + f"credential valid for {days_left} more days (beyond the " + f"{EXPIRY_WARNING_DAYS}-day rotation threshold)." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/__init__.py b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.metadata.json b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.metadata.json new file mode 100644 index 0000000000..11dfa6e85b --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "entra_authentication_methods_policy_strong_auth_enforced", + "CheckTitle": "Strong authentication methods are enabled with registration enforcement", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Microsoft Entra ID** authentication methods policy is evaluated for **strong authentication enforcement**. The check verifies that at least one strong method (Microsoft Authenticator, FIDO2, or X.509 Certificate) is enabled and that the MFA registration campaign is active to prompt users to enroll.", + "Risk": "Without strong authentication methods, the tenant relies on **passwords alone** or weak factors like SMS/voice. Password-only authentication enables **credential stuffing**, **phishing**, and **brute force** attacks. Without registration enforcement, users may never enroll in MFA even when it is available.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods-manage", + "https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-nudge-authenticator-app" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to Microsoft Entra admin center\n2. Go to Protection > Authentication methods > Policies\n3. Enable Microsoft Authenticator and/or FIDO2 security keys\n4. Go to Protection > Authentication methods > Registration campaign\n5. Set State to Enabled\n6. Configure included users/groups\n7. Consider disabling weak methods (SMS, Voice) after users migrate", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **Microsoft Authenticator** and **FIDO2 security keys** as authentication methods. Activate the **MFA registration campaign** to prompt users to register. Disable weak methods (SMS, voice) after migration. Use **Conditional Access** to require strong authentication for all users.", + "Url": "https://hub.prowler.com/check/entra_authentication_methods_policy_strong_auth_enforced" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check reports a single finding per tenant and passes only when both the MFA registration campaign is enabled and at least one strong method is enabled. Microsoft recommends phishing-resistant methods (FIDO2, certificate-based) over app-based push notifications." +} diff --git a/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.py b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.py new file mode 100644 index 0000000000..efdbcfa6a0 --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.py @@ -0,0 +1,63 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.entra.entra_client import entra_client + +# Methods considered strong (phishing-resistant or app-based MFA) +STRONG_METHODS = {"microsoftAuthenticator", "fido2", "x509Certificate"} + + +class entra_authentication_methods_policy_strong_auth_enforced(Check): + """ + Ensure the Entra ID authentication methods policy enforces strong authentication. + + This check evaluates the tenant authentication methods policy and reports a single finding per tenant. Strong authentication is considered enforced only when BOTH conditions hold: + 1. The MFA registration campaign is enabled (users are prompted to register methods). + 2. At least one strong, phishing-resistant or app-based method (Microsoft Authenticator, FIDO2, or X.509 certificate) is enabled. + + - PASS: Both conditions hold. + - FAIL: One or both conditions are missing; the status extended names exactly what is missing. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for tenant_domain, policy in entra_client.authentication_methods_policy.items(): + if policy is None: + continue + + report = Check_Report_Azure(metadata=self.metadata(), resource=policy) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = "Authentication Methods Policy" + report.resource_id = policy.id + + registration_enabled = policy.registration_enforcement_state == "enabled" + enabled_strong = [ + config.method_name + for config in policy.method_configurations + if config.state == "enabled" and config.method_name in STRONG_METHODS + ] + + if registration_enabled and enabled_strong: + report.status = "PASS" + report.status_extended = ( + f"Strong authentication is enforced for tenant {tenant_domain}: " + f"the MFA registration campaign is enabled and strong methods are " + f"enabled ({', '.join(enabled_strong)})." + ) + else: + issues = [] + if not registration_enabled: + issues.append("the MFA registration campaign is not enabled") + if not enabled_strong: + issues.append( + "no strong authentication methods (Microsoft Authenticator, " + "FIDO2, or X.509 Certificate) are enabled" + ) + report.status = "FAIL" + report.status_extended = ( + f"Strong authentication is not enforced for tenant " + f"{tenant_domain}: {'; '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/entra/entra_service.py b/prowler/providers/azure/services/entra/entra_service.py index eb1d62ac11..e23f3cd34a 100644 --- a/prowler/providers/azure/services/entra/entra_service.py +++ b/prowler/providers/azure/services/entra/entra_service.py @@ -1,5 +1,6 @@ import asyncio from asyncio import gather +from datetime import datetime from typing import List, Optional from uuid import UUID @@ -49,6 +50,8 @@ class Entra(AzureService): self._get_named_locations(), self._get_directory_roles(), self._get_conditional_access_policy(), + self._get_app_registrations(), + self._get_authentication_methods_policy(), ) ) @@ -58,6 +61,8 @@ class Entra(AzureService): self.named_locations = attributes[3] self.directory_roles = attributes[4] self.conditional_access_policy = attributes[5] + self.app_registrations = attributes[6] + self.authentication_methods_policy = attributes[7] if created_loop: asyncio.set_event_loop(None) @@ -69,7 +74,12 @@ class Entra(AzureService): try: request_configuration = RequestConfiguration( query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( - select=["id", "displayName", "accountEnabled"] + select=[ + "id", + "displayName", + "accountEnabled", + "signInActivity", + ] ) ) for tenant, client in self.clients.items(): @@ -82,6 +92,16 @@ class Entra(AzureService): try: while users_response: for user in getattr(users_response, "value", []) or []: + sign_in_activity = getattr(user, "sign_in_activity", None) + last_sign_in = ( + getattr( + sign_in_activity, + "last_sign_in_date_time", + None, + ) + if sign_in_activity + else None + ) users[tenant].update( { user.id: User( @@ -93,6 +113,7 @@ class Entra(AzureService): account_enabled=getattr( user, "account_enabled", True ), + last_sign_in=last_sign_in, ) } ) @@ -416,12 +437,142 @@ class Entra(AzureService): return conditional_access_policy + async def _get_app_registrations(self): + logger.info("Entra - Getting app registrations...") + app_registrations = {} + try: + for tenant, client in self.clients.items(): + app_registrations[tenant] = {} + apps_response = await client.applications.get() + + try: + while apps_response: + for app in getattr(apps_response, "value", []) or []: + credentials = [] + for cred in getattr(app, "password_credentials", []) or []: + credentials.append( + AppCredential( + display_name=getattr(cred, "display_name", "") + or "", + credential_type="password", + end_date_time=getattr( + cred, "end_date_time", None + ), + ) + ) + for cred in getattr(app, "key_credentials", []) or []: + credentials.append( + AppCredential( + display_name=getattr(cred, "display_name", "") + or "", + credential_type="certificate", + end_date_time=getattr( + cred, "end_date_time", None + ), + ) + ) + app_registrations[tenant][app.id] = AppRegistration( + id=app.id, + name=getattr(app, "display_name", "") or "", + credentials=credentials, + ) + + next_link = getattr(apps_response, "odata_next_link", None) + if not next_link: + break + apps_response = await client.applications.with_url( + next_link + ).get() + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return app_registrations + + async def _get_authentication_methods_policy(self): + logger.info("Entra - Getting authentication methods policy...") + auth_methods_policy = {} + try: + for tenant, client in self.clients.items(): + policy_response = ( + await client.policies.authentication_methods_policy.get() + ) + + if not policy_response: + auth_methods_policy[tenant] = None + continue + + # Parse registration enforcement + reg_enforcement = getattr( + policy_response, "registration_enforcement", None + ) + reg_campaign = ( + getattr( + reg_enforcement, + "authentication_methods_registration_campaign", + None, + ) + if reg_enforcement + else None + ) + registration_enforcement_state = ( + getattr(reg_campaign, "state", None) if reg_campaign else None + ) + + # Parse authentication method configurations + method_configs = [] + for config in ( + getattr( + policy_response, + "authentication_method_configurations", + [], + ) + or [] + ): + odata_type = getattr(config, "odata_type", "") or "" + # Extract method name from odata_type + # e.g. "#microsoft.graph.microsoftAuthenticatorAuthenticationMethodConfiguration" + method_name = ( + odata_type.split(".")[-1].replace( + "AuthenticationMethodConfiguration", "" + ) + if odata_type + else getattr(config, "id", "unknown") + ) + method_configs.append( + AuthMethodConfig( + id=getattr(config, "id", "") or "", + method_name=method_name, + state=getattr(config, "state", "disabled") or "disabled", + ) + ) + + auth_methods_policy[tenant] = AuthMethodsPolicy( + id=getattr(policy_response, "id", "") or "", + registration_enforcement_state=registration_enforcement_state, + method_configurations=method_configs, + ) + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return auth_methods_policy + class User(BaseModel): id: str name: str is_mfa_capable: bool = False account_enabled: bool = True + last_sign_in: Optional[datetime] = None class DefaultUserRolePermissions(BaseModel): @@ -481,3 +632,27 @@ class ConditionalAccessPolicy(BaseModel): users: dict[str, List[str]] target_resources: dict[str, List[str]] access_controls: dict[str, List[str]] + + +class AppCredential(BaseModel): + display_name: str = "" + credential_type: str # "password" or "certificate" + end_date_time: Optional[datetime] = None + + +class AppRegistration(BaseModel): + id: str + name: str + credentials: List[AppCredential] = [] + + +class AuthMethodConfig(BaseModel): + id: str = "" + method_name: str + state: str = "disabled" # "enabled" or "disabled" + + +class AuthMethodsPolicy(BaseModel): + id: str = "" + registration_enforcement_state: Optional[str] = None # "enabled" or "disabled" + method_configurations: List[AuthMethodConfig] = [] diff --git a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/__init__.py b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json new file mode 100644 index 0000000000..64ccb383a1 --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "entra_user_with_recent_sign_in", + "CheckTitle": "Enabled user has signed in within the last 90 days", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Microsoft Entra ID** enabled user accounts are evaluated for **recent sign-in activity**. Accounts that have not signed in for more than 90 days are flagged as stale. Stale accounts indicate orphaned identities that may have been abandoned after personnel changes, project completions, or role transitions without proper deprovisioning.", + "Risk": "Stale accounts retain **role assignments** and **group memberships**. Attackers target dormant accounts via **credential stuffing** and **password spraying** because owners are unlikely to notice anomalous activity. Compromise enables **lateral movement**, **data exfiltration**, and **persistence** while evading detection tuned to active users.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/signinactivity", + "https://learn.microsoft.com/en-us/entra/identity/monitoring-health/concept-sign-ins" + ], + "Remediation": { + "Code": { + "CLI": "az rest --method patch --url https://graph.microsoft.com/v1.0/users/ --body '{\"accountEnabled\":false}'", + "NativeIaC": "", + "Other": "1. Sign in to Microsoft Entra admin center\n2. Go to Identity > Users > All users\n3. Add filter: Sign-in activity > Last interactive sign-in date is before <90 days ago>\n4. Review each stale account with the account owner or manager\n5. Disable accounts confirmed as no longer needed\n6. After a grace period, delete disabled accounts\n7. Establish a recurring access review to automate this process", + "Terraform": "" + }, + "Recommendation": { + "Text": "Implement **automated access reviews** in Entra ID to periodically review and remove stale accounts. Configure reviews to run quarterly, targeting all users or specific groups. Set auto-apply to disable accounts that are not confirmed by reviewers. For immediate remediation, filter users by last sign-in date and disable accounts inactive for more than 90 days after confirming with managers.", + "Url": "https://hub.prowler.com/check/entra_user_with_recent_sign_in" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The signInActivity resource requires Microsoft Entra ID P1 or P2 license. Tenants without this license will not have sign-in activity data available, and all users will be reported as never having signed in." +} diff --git a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py new file mode 100644 index 0000000000..fa6d1af05d --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py @@ -0,0 +1,77 @@ +from datetime import datetime, timezone + +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.entra.entra_client import entra_client + +STALE_THRESHOLD_DAYS = 90 + + +class entra_user_with_recent_sign_in(Check): + """ + Ensure enabled Entra ID users have signed in within the last 90 days. + + This check evaluates each enabled user's last interactive sign-in to detect stale or dormant accounts that should be reviewed or deprovisioned. Sign-in activity requires Entra ID P1/P2 licensing. + + - PASS: The enabled user signed in within the last 90 days. + - FAIL: The enabled user has not signed in for more than 90 days, or has never signed in. + - FAIL (tenant-level): No sign-in activity data is available for any enabled user, indicating missing P1/P2 licensing or Graph permissions (reported once instead of flagging every user). + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for tenant_domain, users in entra_client.users.items(): + enabled_users = {k: v for k, v in users.items() if v.account_enabled} + + if not enabled_users: + continue + + # If all enabled users are missing sign-in data, avoid claiming + # they never signed in. This usually indicates missing telemetry, + # often due to licensing or Graph permission limitations. + all_null = all(u.last_sign_in is None for u in enabled_users.values()) + if all_null: + first_user = next(iter(enabled_users.values())) + report = Check_Report_Azure( + metadata=self.metadata(), resource=first_user + ) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = "Sign-in Activity Data" + count = len(enabled_users) + noun = "user" if count == 1 else "users" + report.status = "FAIL" + report.status_extended = ( + f"No sign-in activity data available for any of the " + f"{count} enabled {noun}. This likely means the tenant " + f"is missing Entra ID P1/P2 licensing or the required " + f"Graph permissions to read sign-in activity." + ) + findings.append(report) + continue + + for user_domain_name, user in enabled_users.items(): + report = Check_Report_Azure(metadata=self.metadata(), resource=user) + report.subscription = f"Tenant: {tenant_domain}" + + if user.last_sign_in is None: + report.status = "FAIL" + report.status_extended = f"User {user.name} has never signed in." + else: + last = user.last_sign_in + if last.tzinfo is None: + last = last.replace(tzinfo=timezone.utc) + days_since = (datetime.now(timezone.utc) - last).days + if days_since > STALE_THRESHOLD_DAYS: + report.status = "FAIL" + report.status_extended = ( + f"User {user.name} has not signed in for {days_since} days." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"User {user.name} signed in {days_since} days ago." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json new file mode 100644 index 0000000000..1dac541283 --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "mysql_flexible_server_geo_redundant_backup_enabled", + "CheckTitle": "MySQL flexible server has geo-redundant backup enabled", + "CheckType": [], + "ServiceName": "mysql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbformysql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure MySQL Flexible Server** is evaluated for **geo-redundant backup**. Geo-redundant backup stores backup copies in a paired Azure region, enabling restore and recovery from a full regional outage.", + "Risk": "Without **geo-redundant backup**, a regional disaster can cause **permanent data loss**. Locally redundant backups only protect against storage hardware failures within the same region and cannot be restored if the primary region becomes unavailable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-backup-restore", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbformysql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az mysql flexible-server create --name --resource-group --location --geo-redundant-backup Enabled", + "NativeIaC": "```bicep\n// Bicep: MySQL Flexible Server with geo-redundant backup (set at creation)\nresource mysql 'Microsoft.DBforMySQL/flexibleServers@2023-12-30' = {\n name: ''\n location: ''\n properties: {\n backup: {\n geoRedundantBackup: 'Enabled' // CRITICAL: stores backups in the paired region\n }\n }\n}\n```", + "Other": "1. Geo-redundant backup must be configured when the server is created; it cannot be enabled on an existing server\n2. In the Azure portal, start creating a new Azure Database for MySQL flexible server\n3. On the Basics tab, under Compute + storage, open Configure server\n4. Set Geographically redundant backup to Enabled and save\n5. Finish creating the server and migrate workloads to it", + "Terraform": "```hcl\n# Terraform: MySQL Flexible Server with geo-redundant backup (set at creation)\nresource \"azurerm_mysql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n geo_redundant_backup_enabled = true # CRITICAL: stores backups in the paired region\n}\n```" + }, + "Recommendation": { + "Text": "Enable **geo-redundant backup** on MySQL Flexible Servers so backups are replicated to the paired Azure region and can be restored during a regional outage. Because this is set at server creation, plan a migration for existing servers, and pair it with an appropriate backup retention period and periodic restore testing.", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_geo_redundant_backup_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Geo-redundant backup for Azure MySQL Flexible Server can only be configured at server creation time and cannot be changed afterwards." +} diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py new file mode 100644 index 0000000000..5b1685354c --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.mysql.mysql_client import mysql_client + + +class mysql_flexible_server_geo_redundant_backup_enabled(Check): + """ + Ensure Azure MySQL Flexible Servers have geo-redundant backup enabled. + + This check evaluates whether each Azure MySQL Flexible Server stores backups in a paired Azure region, enabling recovery from a full regional outage. + + - PASS: The server has geo-redundant backup enabled (geo_redundant_backup is "Enabled"). + - FAIL: The server does not have geo-redundant backup enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription_id, servers in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) + for server in servers.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription_id + if server.geo_redundant_backup == "Enabled": + report.status = "PASS" + report.status_extended = f"Geo-redundant backup is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + else: + report.status = "FAIL" + report.status_extended = f"Geo-redundant backup is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/__init__.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.metadata.json new file mode 100644 index 0000000000..22a9b93401 --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "mysql_flexible_server_high_availability_enabled", + "CheckTitle": "MySQL flexible server has high availability enabled", + "CheckType": [], + "ServiceName": "mysql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbformysql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure MySQL Flexible Server** is evaluated for **high availability** mode. Zone-redundant or same-zone HA provisions a standby replica and provides automatic failover during planned and unplanned outages.", + "Risk": "Without **high availability**, a server or zone failure causes **downtime** until manual recovery or redeployment. Applications experience extended unavailability and potential data loss for in-flight transactions during unplanned outages.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-high-availability", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbformysql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az mysql flexible-server update --name --resource-group --high-availability ZoneRedundant", + "NativeIaC": "```bicep\n// Bicep: MySQL Flexible Server with zone-redundant high availability\nresource mysql 'Microsoft.DBforMySQL/flexibleServers@2023-12-30' = {\n name: ''\n location: ''\n sku: {\n name: 'Standard_D2ds_v4'\n tier: 'GeneralPurpose'\n }\n properties: {\n highAvailability: {\n mode: 'ZoneRedundant' // CRITICAL: provisions a standby replica with automatic failover\n }\n }\n}\n```", + "Other": "1. In the Azure portal, open your Azure Database for MySQL flexible server\n2. Under Settings, select High availability\n3. Enable High availability and choose Zone redundant (or Same zone)\n4. Save (high availability requires the General Purpose or Business Critical tier)", + "Terraform": "```hcl\n# Terraform: MySQL Flexible Server with zone-redundant high availability\nresource \"azurerm_mysql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku_name = \"GP_Standard_D2ds_v4\"\n\n high_availability {\n mode = \"ZoneRedundant\" # CRITICAL: provisions a standby replica with automatic failover\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **high availability** (zone-redundant where supported, otherwise same-zone) on production MySQL Flexible Servers so a standby replica provides automatic failover. High availability requires the General Purpose or Business Critical tier; pair it with geo-redundant backup and periodic failover testing for full resilience.", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_high_availability_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "High availability for Azure MySQL Flexible Server requires the General Purpose or Business Critical compute tier and is not available on the Burstable tier." +} diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.py new file mode 100644 index 0000000000..e1f3054baf --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.mysql.mysql_client import mysql_client + + +class mysql_flexible_server_high_availability_enabled(Check): + """ + Ensure Azure MySQL Flexible Servers have high availability enabled. + + This check evaluates whether each Azure MySQL Flexible Server is configured with high availability (zone-redundant or same-zone), providing automatic failover to a standby replica during outages. + + - PASS: The server has high availability enabled (high_availability_mode is set and not "Disabled"). + - FAIL: The server does not have high availability enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription_id, servers in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) + for server in servers.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription_id + if ( + server.high_availability_mode is not None + and server.high_availability_mode != "Disabled" + ): + report.status = "PASS" + report.status_extended = f"High availability is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + else: + report.status = "FAIL" + report.status_extended = f"High availability is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/mysql/mysql_service.py b/prowler/providers/azure/services/mysql/mysql_service.py index 565e14da01..b3a386a193 100644 --- a/prowler/providers/azure/services/mysql/mysql_service.py +++ b/prowler/providers/azure/services/mysql/mysql_service.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional from azure.mgmt.rdbms.mysql_flexibleservers import MySQLManagementClient @@ -21,6 +22,8 @@ class MySQL(AzureService): servers_list = client.servers.list() servers.update({subscription_id: {}}) for server in servers_list: + backup = getattr(server, "backup", None) + ha = getattr(server, "high_availability", None) servers[subscription_id].update( { server.id: FlexibleServer( @@ -31,6 +34,10 @@ class MySQL(AzureService): configurations=self._get_configurations( client, server.id.split("/")[4], server.name ), + geo_redundant_backup=getattr( + backup, "geo_redundant_backup", None + ), + high_availability_mode=getattr(ha, "mode", None), ) } ) @@ -78,3 +85,5 @@ class FlexibleServer: location: str version: str configurations: dict[Configuration] + geo_redundant_backup: Optional[str] = None + high_availability_mode: Optional[str] = None diff --git a/prowler/providers/azure/services/network/network_service.py b/prowler/providers/azure/services/network/network_service.py index 0eb6144534..a924cf9609 100644 --- a/prowler/providers/azure/services/network/network_service.py +++ b/prowler/providers/azure/services/network/network_service.py @@ -17,6 +17,7 @@ class Network(AzureService): self.bastion_hosts = self._get_bastion_hosts() self.network_watchers = self._get_network_watchers() self.public_ip_addresses = self._get_public_ip_addresses() + self.virtual_networks = self._get_virtual_networks() def _get_security_groups(self): logger.info("Network - Getting Network Security Groups...") @@ -203,6 +204,41 @@ class Network(AzureService): ) return public_ip_addresses + def _get_virtual_networks(self): + logger.info("Network - Getting Virtual Networks...") + virtual_networks = {} + for subscription, client in self.clients.items(): + try: + virtual_networks[subscription] = [] + vnet_list = client.virtual_networks.list_all() + for vnet in vnet_list: + subnets = [] + for subnet in getattr(vnet, "subnets", []) or []: + nsg = getattr(subnet, "network_security_group", None) + subnets.append( + VNetSubnet( + id=subnet.id, + name=subnet.name, + nsg_id=getattr(nsg, "id", None) if nsg else None, + ) + ) + virtual_networks[subscription].append( + VirtualNetwork( + id=vnet.id, + name=vnet.name, + location=vnet.location, + enable_ddos_protection=getattr( + vnet, "enable_ddos_protection", False + ), + subnets=subnets, + ) + ) + except Exception as error: + logger.error( + f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return virtual_networks + @dataclass class BastionHost: @@ -261,3 +297,23 @@ class PublicIp: name: str location: str ip_address: str + + +@dataclass +class VNetSubnet: + id: str + name: str + nsg_id: Optional[str] = None + + +@dataclass +class VirtualNetwork: + id: str + name: str + location: str + enable_ddos_protection: bool = False + subnets: List[VNetSubnet] = None + + def __post_init__(self): + if self.subnets is None: + self.subnets = [] diff --git a/prowler/providers/azure/services/network/network_subnet_nsg_associated/__init__.py b/prowler/providers/azure/services/network/network_subnet_nsg_associated/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.metadata.json b/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.metadata.json new file mode 100644 index 0000000000..73cb41f886 --- /dev/null +++ b/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "network_subnet_nsg_associated", + "CheckTitle": "Subnet has a network security group associated", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.network/virtualnetworks/subnets", + "ResourceGroup": "network", + "Description": "**Azure Virtual Network** subnets are evaluated for **Network Security Group (NSG)** association. Each subnet should have an NSG to enforce inbound and outbound traffic filtering rules. Subnets without NSGs allow all traffic by default.", + "Risk": "Subnets without NSGs have **no network-level access control**. All inbound and outbound traffic is allowed by default, enabling **lateral movement**, **unauthorized access** to resources, and **data exfiltration** across the virtual network.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview", + "https://learn.microsoft.com/en-us/azure/virtual-network/tutorial-filter-network-traffic" + ], + "Remediation": { + "Code": { + "CLI": "az network vnet subnet update --resource-group --vnet-name --name --network-security-group ", + "NativeIaC": "```bicep\nresource vnet 'Microsoft.Network/virtualNetworks@2023-09-01' existing = {\n name: ''\n}\n\nresource subnet 'Microsoft.Network/virtualNetworks/subnets@2023-09-01' = {\n name: ''\n parent: vnet\n properties: {\n addressPrefix: '10.0.1.0/24'\n networkSecurityGroup: {\n id: '' // Critical: associates NSG with subnet\n }\n }\n}\n```", + "Other": "1. Sign in to Azure portal\n2. Go to Virtual networks and select the VNet\n3. Click on Subnets\n4. Select the subnet without an NSG\n5. Under Network security group, select an existing NSG or create a new one\n6. Click Save", + "Terraform": "```hcl\nresource \"azurerm_subnet_network_security_group_association\" \"\" {\n subnet_id = \"\" # Critical: associates NSG with subnet\n network_security_group_id = \"\"\n}\n```" + }, + "Recommendation": { + "Text": "Associate a **Network Security Group** with every subnet. Create NSG rules following **least privilege** \u2014 deny all by default and allow only required traffic. Exclude Azure-managed subnets (GatewaySubnet, AzureFirewallSubnet, AzureBastionSubnet) which have their own security controls.", + "Url": "https://hub.prowler.com/check/network_subnet_nsg_associated" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check excludes Azure-managed subnets (GatewaySubnet, AzureFirewallSubnet, AzureFirewallManagementSubnet, AzureBastionSubnet, RouteServerSubnet) which should not have custom NSGs." +} diff --git a/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.py b/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.py new file mode 100644 index 0000000000..054f663351 --- /dev/null +++ b/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.py @@ -0,0 +1,54 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.network.network_client import network_client + +# Subnets that are managed by Azure and should not have custom NSGs +EXCLUDED_SUBNET_NAMES = { + "GatewaySubnet", + "AzureFirewallSubnet", + "AzureFirewallManagementSubnet", + "AzureBastionSubnet", + "RouteServerSubnet", +} + + +class network_subnet_nsg_associated(Check): + """ + Ensure every subnet has a Network Security Group (NSG) associated. + + This check evaluates whether each subnet in every virtual network has an NSG associated to enforce inbound and outbound traffic filtering. Azure-managed subnets (e.g. GatewaySubnet, AzureFirewallSubnet, AzureBastionSubnet) are excluded because they must not have custom NSGs. + + - PASS: The subnet has an NSG associated. + - FAIL: The subnet does not have an NSG associated. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for subscription_name, vnets in network_client.virtual_networks.items(): + for vnet in vnets: + for subnet in vnet.subnets: + if subnet.name in EXCLUDED_SUBNET_NAMES: + continue + + report = Check_Report_Azure(metadata=self.metadata(), resource=vnet) + report.subscription = subscription_name + report.resource_name = f"{vnet.name}/{subnet.name}" + report.resource_id = subnet.id + report.location = vnet.location + + if subnet.nsg_id: + report.status = "PASS" + report.status_extended = ( + f"Subnet '{subnet.name}' in VNet '{vnet.name}' " + f"has an NSG associated." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Subnet '{subnet.name}' in VNet '{vnet.name}' " + f"does not have an NSG associated." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/__init__.py b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.metadata.json b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.metadata.json new file mode 100644 index 0000000000..dfeef103c1 --- /dev/null +++ b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "azure", + "CheckID": "network_vnet_ddos_protection_enabled", + "CheckTitle": "Virtual network has Azure DDoS Network Protection enabled", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.network/virtualnetworks", + "ResourceGroup": "network", + "Description": "**Azure Virtual Networks** are evaluated for **DDoS Network Protection**. When enabled, Azure DDoS Protection provides enhanced mitigation against volumetric, protocol, and application-layer DDoS attacks targeting resources deployed in the virtual network.", + "Risk": "Without DDoS protection, Azure resources are only covered by **DDoS Infrastructure Protection** (basic), which may not mitigate sophisticated or large-scale attacks. Successful DDoS attacks cause **service unavailability** and potential **data exposure** during failover.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ddos-protection/ddos-protection-overview" + ], + "Remediation": { + "Code": { + "CLI": "az network ddos-protection create --resource-group --name && az network vnet update --resource-group --name --ddos-protection-plan ", + "NativeIaC": "```bicep\nresource vnet 'Microsoft.Network/virtualNetworks@2023-09-01' = {\n name: ''\n location: ''\n properties: {\n addressSpace: { addressPrefixes: ['10.0.0.0/16'] }\n enableDdosProtection: true // Critical: enables DDoS Protection\n ddosProtectionPlan: { id: '' }\n }\n}\n```", + "Other": "1. Sign in to Azure portal\n2. Search for 'DDoS protection plans'\n3. Create a new DDoS protection plan\n4. Go to Virtual networks and select the target VNet\n5. Under DDoS protection, select 'Enable' and select the plan\n6. Click Save", + "Terraform": "```hcl\nresource \"azurerm_virtual_network\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n address_space = [\"10.0.0.0/16\"]\n\n ddos_protection_plan {\n id = \"\" # Critical: associates DDoS Protection Plan\n enable = true\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Azure DDoS Network Protection** on virtual networks hosting internet-facing workloads. Consider cost implications — DDoS Network Protection has a monthly charge per plan. Use **DDoS IP Protection** as a lower-cost alternative for individual public IPs.", + "Url": "https://hub.prowler.com/check/network_vnet_ddos_protection_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Azure DDoS Network Protection has a monthly cost (~$2,944/month per plan). Consider whether the workload justifies this cost. Internal-only VNets without internet-facing resources may not need DDoS protection." +} diff --git a/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.py b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.py new file mode 100644 index 0000000000..6a9a081fb7 --- /dev/null +++ b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.network.network_client import network_client + + +class network_vnet_ddos_protection_enabled(Check): + """ + Ensure Azure DDoS Network Protection is enabled on virtual networks. + + This check evaluates whether each virtual network has a DDoS protection plan associated, providing always-on traffic monitoring and adaptive mitigation against volumetric and protocol attacks. + + - PASS: The virtual network has DDoS protection enabled. + - FAIL: The virtual network does not have DDoS protection enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for subscription_name, vnets in network_client.virtual_networks.items(): + for vnet in vnets: + report = Check_Report_Azure(metadata=self.metadata(), resource=vnet) + report.subscription = subscription_name + report.resource_name = vnet.name + report.resource_id = vnet.id + report.location = vnet.location + + if vnet.enable_ddos_protection: + report.status = "PASS" + report.status_extended = ( + f"Virtual network '{vnet.name}' has DDoS " + f"protection enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Virtual network '{vnet.name}' does not have " + f"DDoS protection enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/__init__.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json new file mode 100644 index 0000000000..7be125ecf2 --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "postgresql_flexible_server_geo_redundant_backup_enabled", + "CheckTitle": "PostgreSQL flexible server has geo-redundant backup enabled", + "CheckType": [], + "ServiceName": "postgresql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbforpostgresql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure PostgreSQL Flexible Server** is evaluated for **geo-redundant backup**. Geo-redundant backup stores backup copies in a paired Azure region, enabling restore and cross-region disaster recovery.", + "Risk": "Without **geo-redundant backup**, a regional disaster can cause **permanent data loss**. Locally redundant backups only protect against storage hardware failures within the same region and cannot be restored if the primary region becomes unavailable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-backup-restore", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbforpostgresql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az postgres flexible-server create --name --resource-group --location --geo-redundant-backup Enabled", + "NativeIaC": "```bicep\n// Bicep: PostgreSQL Flexible Server with geo-redundant backup (set at creation)\nresource postgresql 'Microsoft.DBforPostgreSQL/flexibleServers@2023-06-01-preview' = {\n name: ''\n location: ''\n properties: {\n backup: {\n geoRedundantBackup: 'Enabled' // CRITICAL: stores backups in the paired region\n }\n }\n}\n```", + "Other": "1. Geo-redundant backup must be configured when the server is created; it cannot be enabled on an existing server\n2. In the Azure portal, start creating a new Azure Database for PostgreSQL flexible server\n3. On the Basics tab, under Compute + storage, open Configure server\n4. Set Geographically redundant backup to Enabled and save\n5. Finish creating the server and migrate workloads to it", + "Terraform": "```hcl\n# Terraform: PostgreSQL Flexible Server with geo-redundant backup (set at creation)\nresource \"azurerm_postgresql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n geo_redundant_backup_enabled = true # CRITICAL: stores backups in the paired region\n}\n```" + }, + "Recommendation": { + "Text": "Enable **geo-redundant backup** on PostgreSQL Flexible Servers so backups are replicated to the paired Azure region and can be restored during a regional outage. Because this is set at server creation, plan a migration for existing servers, and pair it with an appropriate backup retention period and periodic restore testing.", + "Url": "https://hub.prowler.com/check/postgresql_flexible_server_geo_redundant_backup_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Geo-redundant backup for Azure PostgreSQL Flexible Server can only be configured at server creation time and cannot be changed afterwards." +} diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py new file mode 100644 index 0000000000..94fdaa7a1c --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.postgresql.postgresql_client import ( + postgresql_client, +) + + +class postgresql_flexible_server_geo_redundant_backup_enabled(Check): + """ + Ensure Azure PostgreSQL Flexible Servers have geo-redundant backup enabled. + + This check evaluates whether each Azure PostgreSQL Flexible Server stores backups in a paired Azure region, enabling cross-region disaster recovery. + + - PASS: The server has geo-redundant backup enabled (geo_redundant_backup is "Enabled"). + - FAIL: The server does not have geo-redundant backup enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for ( + subscription, + flexible_servers, + ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) + for server in flexible_servers: + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription + if server.geo_redundant_backup == "Enabled": + report.status = "PASS" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has geo-redundant backup enabled." + else: + report.status = "FAIL" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) does not have geo-redundant backup enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/__init__.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.metadata.json b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.metadata.json new file mode 100644 index 0000000000..58b6588886 --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "postgresql_flexible_server_high_availability_enabled", + "CheckTitle": "PostgreSQL flexible server has high availability enabled", + "CheckType": [], + "ServiceName": "postgresql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbforpostgresql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure PostgreSQL Flexible Server** is evaluated for **high availability** mode. Zone-redundant or same-zone HA provisions a standby replica and provides automatic failover during planned and unplanned outages.", + "Risk": "Without **high availability**, a server or zone failure causes **downtime** until manual recovery or redeployment. Critical databases experience extended unavailability and potential data loss for in-flight transactions during unplanned outages.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-high-availability", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbforpostgresql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az postgres flexible-server update --name --resource-group --high-availability ZoneRedundant", + "NativeIaC": "```bicep\n// Bicep: PostgreSQL Flexible Server with zone-redundant high availability\nresource postgresql 'Microsoft.DBforPostgreSQL/flexibleServers@2023-06-01-preview' = {\n name: ''\n location: ''\n sku: {\n name: 'Standard_D2ds_v4'\n tier: 'GeneralPurpose'\n }\n properties: {\n highAvailability: {\n mode: 'ZoneRedundant' // CRITICAL: provisions a standby replica with automatic failover\n }\n }\n}\n```", + "Other": "1. In the Azure portal, open your Azure Database for PostgreSQL flexible server\n2. Under Settings, select High availability\n3. Enable High availability and choose Zone redundant (or Same zone)\n4. Save (high availability requires the General Purpose or Memory Optimized tier)", + "Terraform": "```hcl\n# Terraform: PostgreSQL Flexible Server with zone-redundant high availability\nresource \"azurerm_postgresql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku_name = \"GP_Standard_D2ds_v4\"\n\n high_availability {\n mode = \"ZoneRedundant\" # CRITICAL: provisions a standby replica with automatic failover\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **high availability** (zone-redundant where supported, otherwise same-zone) on production PostgreSQL Flexible Servers so a standby replica provides automatic failover. High availability requires the General Purpose or Memory Optimized tier; pair it with geo-redundant backup and periodic failover testing for full resilience.", + "Url": "https://hub.prowler.com/check/postgresql_flexible_server_high_availability_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "High availability for Azure PostgreSQL Flexible Server requires the General Purpose or Memory Optimized compute tier and is not available on the Burstable tier." +} diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.py new file mode 100644 index 0000000000..14c69f591e --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.postgresql.postgresql_client import ( + postgresql_client, +) + + +class postgresql_flexible_server_high_availability_enabled(Check): + """ + Ensure Azure PostgreSQL Flexible Servers have high availability enabled. + + This check evaluates whether each Azure PostgreSQL Flexible Server is configured with high availability (zone-redundant or same-zone), providing automatic failover to a standby replica during outages. + + - PASS: The server has high availability enabled (high_availability_mode is set and not "Disabled"). + - FAIL: The server does not have high availability enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for ( + subscription, + flexible_servers, + ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) + for server in flexible_servers: + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription + if ( + server.high_availability_mode is not None + and server.high_availability_mode != "Disabled" + ): + report.status = "PASS" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has high availability enabled." + else: + report.status = "FAIL" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) does not have high availability enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_service.py b/prowler/providers/azure/services/postgresql/postgresql_service.py index 13081ad270..2048299bf1 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_service.py +++ b/prowler/providers/azure/services/postgresql/postgresql_service.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient @@ -53,6 +54,8 @@ class PostgreSQL(AzureService): subscription, resource_group, postgresql_server.name ) location = server_details.location + backup = getattr(server_details, "backup", None) + ha = getattr(server_details, "high_availability", None) flexible_servers[subscription].append( Server( id=postgresql_server.id, @@ -68,6 +71,10 @@ class PostgreSQL(AzureService): connection_throttling=connection_throttling, log_retention_days=log_retention_days, firewall=firewall, + geo_redundant_backup=getattr( + backup, "geo_redundant_backup", None + ), + high_availability_mode=getattr(ha, "mode", None), ) ) except Exception as error: @@ -149,15 +156,37 @@ class PostgreSQL(AzureService): ) return admin_list except Exception as e: - logger.error(f"Error getting Entra ID admins for {server_name}: {e}") + if "authentication is not enabled" in str(e): + # Expected when the server uses PostgreSQL authentication only + # (Entra/Azure AD auth disabled); not an error. + logger.warning( + f"Entra ID authentication is not enabled for {server_name}; skipping Entra ID admins." + ) + else: + logger.error(f"Error getting Entra ID admins for {server_name}: {e}") return [] def _get_connection_throttling(self, subscription, resouce_group_name, server_name): client = self.clients[subscription] - connection_throttling = client.configurations.get( - resouce_group_name, server_name, "connection_throttle.enable" - ) - return connection_throttling.value.upper() + try: + connection_throttling = client.configurations.get( + resouce_group_name, server_name, "connection_throttle.enable" + ) + return connection_throttling.value.upper() + except Exception as error: + message = str(error).lower() + if "connection_throttle.enable" in message and ( + "not exist" in message or "not found" in message + ): + # The "connection_throttle.enable" parameter does not exist on + # newer PostgreSQL versions (e.g. v18); this is expected. + return None + # Any other failure is a genuine problem: surface it, but still + # degrade gracefully instead of aborting the subscription inventory. + logger.error( + f"Error getting connection throttling for {server_name}: {error}" + ) + return None def _get_log_retention_days(self, subscription, resouce_group_name, server_name): client = self.clients[subscription] @@ -214,6 +243,8 @@ class Server: log_checkpoints: str log_connections: str log_disconnections: str - connection_throttling: str - log_retention_days: str + connection_throttling: Optional[str] + log_retention_days: Optional[str] firewall: list[Firewall] + geo_redundant_backup: Optional[str] = None + high_availability_mode: Optional[str] = None diff --git a/prowler/providers/azure/services/recovery/__init__.py b/prowler/providers/azure/services/recovery/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/recovery/recovery_service.py b/prowler/providers/azure/services/recovery/recovery_service.py index 16b5bc3248..38645219bd 100644 --- a/prowler/providers/azure/services/recovery/recovery_service.py +++ b/prowler/providers/azure/services/recovery/recovery_service.py @@ -52,6 +52,7 @@ class Recovery(AzureService): """ logger.info("Recovery - Getting Recovery Services vaults...") vaults_dict: dict[str, dict[str, BackupVault]] = {} + subscription_id = "unknown" try: vaults_dict: dict[str, dict[str, BackupVault]] = {} for subscription_id, client in self.clients.items(): @@ -66,21 +67,33 @@ class Recovery(AzureService): vaults_dict[subscription_id][vault_obj.id] = vault_obj except Exception as error: logger.error( - f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- " + f"{error.__class__.__name__}" + f"[{error.__traceback__.tb_lineno}]: {error}" ) return vaults_dict class RecoveryBackup(AzureService): + """ + Service wrapper for Azure Recovery Services backup data. + + Collects the backup protected items and backup policies for each Recovery + Services vault discovered by the Recovery service. + """ + def __init__( - self, provider: AzureProvider, vaults: dict[str, dict[str, BackupVault]] + self, + provider: AzureProvider, + vaults: dict[str, dict[str, BackupVault]], ): super().__init__(RecoveryServicesBackupClient, provider) for subscription_id, vaults in vaults.items(): for vault in vaults.values(): - vault.backup_protected_items = self._get_backup_protected_items( + protected_items = self._get_backup_protected_items( subscription_id=subscription_id, vault=vault ) + vault.backup_protected_items = protected_items vault.backup_policies = self._get_backup_policies( subscription_id=subscription_id, vault=vault ) @@ -102,19 +115,22 @@ class RecoveryBackup(AzureService): ) for item in backup_protected_items: item_properties = getattr(item, "properties", None) + workload_type = None + backup_policy_id = None + if item_properties: + workload_type = item_properties.workload_type + backup_policy_id = item_properties.policy_id backup_protected_items_dict[item.id] = BackupItem( id=item.id, name=item.name, - workload_type=( - item_properties.workload_type if item_properties else None - ), - backup_policy_id=( - item_properties.policy_id if item_properties else None - ), + workload_type=workload_type, + backup_policy_id=backup_policy_id, ) except Exception as error: logger.error( - f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- " + f"{error.__class__.__name__}" + f"[{error.__traceback__.tb_lineno}]: {error}" ) return backup_protected_items_dict @@ -126,40 +142,44 @@ class RecoveryBackup(AzureService): """ logger.info("Recovery - Getting backup policies...") backup_policies_dict: dict[str, BackupPolicy] = {} - unique_backup_policies: set[str] = set() try: - for item in vault.backup_protected_items.values(): - if item.backup_policy_id: - unique_backup_policies.add(item.backup_policy_id) - for policy_id in unique_backup_policies: - policy = self.clients[subscription_id].protection_policies.get( - vault_name=vault.name, - resource_group_name=vault.id.split("/")[4], - policy_name=policy_id.split("/")[-1], - ) - backup_policies_dict[policy_id] = BackupPolicy( + client = self.clients[subscription_id] + backup_policies = client.backup_policies.list( + vault_name=vault.name, + resource_group_name=vault.id.split("/")[4], + ) + for policy in backup_policies: + retention_days = self._get_backup_policy_retention_days(policy) + backup_policies_dict[policy.id] = BackupPolicy( id=policy.id, name=policy.name, - retention_days=getattr( - getattr( - getattr( - getattr( - getattr(policy, "properties", None), - "retention_policy", - None, - ), - "daily_schedule", - None, - ), - "retention_duration", - None, - ), - "count", - None, - ), + retention_days=retention_days, ) except Exception as error: logger.error( - f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription ID: {subscription_id} -- " + f"{error.__class__.__name__}" + f"[{error.__traceback__.tb_lineno}]: {error}" ) return backup_policies_dict + + @staticmethod + def _get_backup_policy_retention_days(policy) -> Optional[int]: + """Return the daily retention duration count for a backup policy.""" + return getattr( + getattr( + getattr( + getattr( + getattr(policy, "properties", None), + "retention_policy", + None, + ), + "daily_schedule", + None, + ), + "retention_duration", + None, + ), + "count", + None, + ) diff --git a/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/__init__.py b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.metadata.json b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.metadata.json new file mode 100644 index 0000000000..33f8516d1b --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "recovery_vault_backup_policy_retention_adequate", + "CheckTitle": "Recovery Services backup policy has at least 30 days retention", + "CheckType": [], + "ServiceName": "recovery", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "**Azure Recovery Services** backup policies are evaluated for adequate **daily retention**. Policies with fewer than 30 days of retention may not provide sufficient recovery points for incident investigation, compliance requirements, or ransomware recovery scenarios.", + "Risk": "Short retention periods limit the ability to recover from **delayed-discovery incidents** such as ransomware that encrypts backups over time, data corruption that propagates across backup cycles, or compliance investigations requiring historical data. Insufficient retention may violate regulatory requirements.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/backup/backup-azure-vm-backup-faq", + "https://learn.microsoft.com/en-us/azure/backup/backup-azure-backup-faq" + ], + "Remediation": { + "Code": { + "CLI": "az backup policy set --resource-group --vault-name --name --policy @policy.json", + "NativeIaC": "", + "Other": "1. Sign in to the Azure portal\n2. Go to Recovery Services vaults and select the vault\n3. Go to Backup policies\n4. Select the policy to modify\n5. Under Retention range, set Daily backup point retention to at least 30 days\n6. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set daily backup retention to at least **30 days**. For production workloads, consider 90+ days. Configure **weekly, monthly, and yearly** retention points for long-term compliance. Use **immutable vaults** to prevent backup deletion by compromised admin accounts.", + "Url": "https://hub.prowler.com/check/recovery_vault_backup_policy_retention_adequate" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check evaluates daily retention from the backup policy's retention_policy.daily_schedule.retention_duration.count. Policies without a daily schedule are flagged. The 30-day minimum aligns with common compliance requirements and incident response timelines." +} diff --git a/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.py b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.py new file mode 100644 index 0000000000..b770f87a62 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.py @@ -0,0 +1,65 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.recovery.recovery_client import recovery_client + +MINIMUM_RETENTION_DAYS = 30 + + +class recovery_vault_backup_policy_retention_adequate(Check): + """Check if vault backup policies retain backups for at least 30 days.""" + + def execute(self) -> list[Check_Report_Azure]: + """Execute the check across Recovery Services vault backup policies.""" + + findings = [] + + for subscription_name, vaults in recovery_client.vaults.items(): + for vault in vaults.values(): + if not vault.backup_policies: + report = Check_Report_Azure( + metadata=self.metadata(), resource=vault + ) + report.subscription = subscription_name + report.resource_name = vault.name + report.resource_id = vault.id + report.location = vault.location + report.status = "FAIL" + report.status_extended = ( + f"Recovery vault '{vault.name}' has no backup " + f"policies configured." + ) + findings.append(report) + continue + + for policy in vault.backup_policies.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=vault + ) + report.subscription = subscription_name + report.resource_name = f"{vault.name}/{policy.name}" + report.resource_id = policy.id + report.location = vault.location + + if policy.retention_days is None: + report.status = "FAIL" + report.status_extended = ( + f"Backup policy '{policy.name}' in vault " + f"'{vault.name}' has no daily retention configured." + ) + elif policy.retention_days < MINIMUM_RETENTION_DAYS: + report.status = "FAIL" + report.status_extended = ( + f"Backup policy '{policy.name}' in vault " + f"'{vault.name}' has {policy.retention_days}-day " + f"retention (minimum: {MINIMUM_RETENTION_DAYS})." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Backup policy '{policy.name}' in vault " + f"'{vault.name}' has {policy.retention_days}-day " + f"retention." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/__init__.py b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.metadata.json b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.metadata.json new file mode 100644 index 0000000000..04e4405851 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "recovery_vault_has_protected_items", + "CheckTitle": "Recovery Services vault has backup protected items configured", + "CheckType": [], + "ServiceName": "recovery", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.recoveryservices/vaults", + "ResourceGroup": "storage", + "Description": "**Azure Recovery Services** vaults are evaluated for **backup protected items**. A vault with no protected items indicates that no resources (VMs, databases, file shares) are configured for backup through this vault, potentially leaving critical workloads unprotected.", + "Risk": "Empty vaults represent a gap in **disaster recovery** coverage. Resources without backup protection face permanent data loss from accidental deletion, ransomware, corruption, or regional outages. The vault may have been provisioned but backup was never configured.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/backup/backup-azure-vms-first-look-arm", + "https://learn.microsoft.com/en-us/azure/backup/backup-overview" + ], + "Remediation": { + "Code": { + "CLI": "az backup protection enable-for-vm --resource-group --vault-name --vm --policy-name DefaultPolicy", + "NativeIaC": "", + "Other": "1. Sign in to the Azure portal\n2. Go to Recovery Services vaults and select the vault\n3. Click + Backup\n4. Select the workload type (Azure VM, SQL, File Share, etc.)\n5. Select the backup policy\n6. Choose the resources to protect\n7. Click Enable Backup", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure **backup protection** for critical resources including VMs, databases, and file shares. Use **Azure Backup policies** with appropriate retention and frequency. If the vault is unused, consider deleting it to reduce resource sprawl.", + "Url": "https://hub.prowler.com/check/recovery_vault_has_protected_items" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check reports on each Recovery Services vault individually. An empty vault is not necessarily a security risk if backups are managed through a different vault or Azure Backup center." +} diff --git a/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.py b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.py new file mode 100644 index 0000000000..6ebd9e5328 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.recovery.recovery_client import recovery_client + + +class recovery_vault_has_protected_items(Check): + """Check if Recovery Services vaults have protected backup items.""" + + def execute(self) -> list[Check_Report_Azure]: + """Execute the check across Recovery Services vaults.""" + + findings = [] + + for subscription_name, vaults in recovery_client.vaults.items(): + for vault in vaults.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=vault) + report.subscription = subscription_name + report.resource_name = vault.name + report.resource_id = vault.id + report.location = vault.location + + if vault.backup_protected_items: + report.status = "PASS" + report.status_extended = ( + f"Recovery Services vault '{vault.name}' has " + f"{len(vault.backup_protected_items)} protected items." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Recovery Services vault '{vault.name}' has no " + f"protected items configured." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 77ae9180a3..9c314b3233 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -586,6 +586,16 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) + elif arguments.provider == "linode": + # Credentials are read from the LINODE_TOKEN env var by the + # provider itself; there are no credential CLI flags to + # avoid leaking secrets. + provider_class( + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + regions=getattr(arguments, "region", None), + ) else: # Dynamic fallback: any external/custom provider. # Honor the from_cli_args type hint (-> Provider): if the diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py index 46866b350d..3b28db7d8e 100644 --- a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py @@ -25,7 +25,11 @@ class cloudfunction_function_inside_vpc(Check): for function in cloudfunction_client.functions: if function.state != "ACTIVE": continue - report = Check_Report_GCP(metadata=self.metadata(), resource=function) + report = Check_Report_GCP( + metadata=self.metadata(), + resource=function, + resource_id=function.name, + ) if function.vpc_connector: report.status = "PASS" report.status_extended = ( diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/__init__.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.metadata.json b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.metadata.json new file mode 100644 index 0000000000..8c9247b2bd --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "cloudfunction_function_not_publicly_accessible", + "CheckTitle": "Cloud Function is not publicly invocable", + "CheckType": [], + "ServiceName": "cloudfunction", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "cloudfunctions.googleapis.com/Function", + "Description": "Cloud Functions deny invocation to `allUsers` and `allAuthenticatedUsers`, so only **explicitly authorized identities or services** can trigger them.\n\nThe evaluation reviews each function's IAM policy bindings to confirm no public principals are granted invoker access.", + "Risk": "Publicly invocable Cloud Functions expose **business logic** to the internet and let any caller trigger execution. This enables **unauthorized data access** when the function returns sensitive output, **code execution** in shared environments, and **denial-of-wallet** attacks driven by uncontrolled invocation costs.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/functions/docs/securing/authenticating", + "https://cloud.google.com/iam/docs/overview" + ], + "Remediation": { + "Code": { + "CLI": "gcloud functions remove-iam-policy-binding --region= --member= --role=roles/cloudfunctions.invoker", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Cloud Functions\n2. Select the function and open the Permissions tab\n3. Remove any binding with allUsers or allAuthenticatedUsers\n4. Grant invocation rights only to specific service accounts or user groups", + "Terraform": "```hcl\nresource \"google_cloudfunctions2_function_iam_binding\" \"\" {\n project = \"\"\n location = \"\"\n cloud_function = \"\"\n role = \"roles/cloudfunctions.invoker\"\n members = [\"serviceAccount:\"] # Critical: never include allUsers or allAuthenticatedUsers\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least privilege** to Cloud Function invocation: grant `roles/cloudfunctions.invoker` only to specific service accounts or groups.\n\nFor externally exposed functions, front them with **API Gateway** or **Cloud Endpoints** that enforce authentication and rate limiting.", + "Url": "https://hub.prowler.com/check/cloudfunction_function_not_publicly_accessible" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudfunction_function_inside_vpc", + "secretmanager_secret_not_publicly_accessible", + "cloudstorage_bucket_public_access" + ], + "Notes": "This check evaluates function-level IAM policies. Organization policy constraints/iam.allowedPolicyMemberDomains can prevent public bindings at the org level." +} diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.py new file mode 100644 index 0000000000..14ee874f22 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.py @@ -0,0 +1,44 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudfunction.cloudfunction_client import ( + cloudfunction_client, +) + + +class cloudfunction_function_not_publicly_accessible(Check): + """Check that Cloud Functions do not grant invocation rights to all users. + + Verifies that no active Cloud Function has an IAM binding granting access + to `allUsers` or `allAuthenticatedUsers`. Non-`ACTIVE` functions are + skipped because their IAM bindings are transient. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the public-access check across all Cloud Functions. + + Returns: + A list of `Check_Report_GCP` findings, one per active Cloud + Function. Status is `FAIL` when the function is invokable by + `allUsers` or `allAuthenticatedUsers` and `PASS` otherwise. + """ + findings = [] + for function in cloudfunction_client.functions: + if function.state != "ACTIVE": + continue + report = Check_Report_GCP( + metadata=self.metadata(), + resource=function, + resource_id=function.name, + ) + if function.publicly_accessible: + report.status = "FAIL" + report.status_extended = ( + f"Cloud Function {function.name} is publicly invocable " + f"(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Cloud Function {function.name} is not publicly accessible." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py index d1043151b7..9905c98748 100644 --- a/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py @@ -1,5 +1,6 @@ from typing import Optional +from googleapiclient import discovery from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -20,7 +21,9 @@ class CloudFunction(GCPService): """Initialize the service and preload Cloud Functions.""" super().__init__("cloudfunctions", provider, api_version="v2") self.functions = [] + self._run_client = None self._get_functions() + self._get_functions_iam_policy() def _get_functions(self) -> None: """Fetch Cloud Functions for every project and location.""" @@ -47,10 +50,13 @@ class CloudFunction(GCPService): service_config = fn.get("serviceConfig", {}) self.functions.append( Function( + id=fn["name"], name=fn["name"].split("/")[-1], project_id=project_id, location=location_id, state=fn.get("state", "UNKNOWN"), + environment=fn.get("environment", "GEN_1"), + service=service_config.get("service"), vpc_connector=service_config.get( "vpcConnector" ), @@ -73,12 +79,68 @@ class CloudFunction(GCPService): f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_functions_iam_policy(self) -> None: + """Fetch IAM policy for every Cloud Function in parallel. + + For gen2 functions, IAM is delegated to the underlying Cloud Run + service, so a `run.googleapis.com` v2 client is required. + """ + if any(f.environment == "GEN_2" for f in self.functions): + self._run_client = discovery.build( + "run", + "v2", + credentials=self.credentials, + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + self.__threading_call__(self._get_function_iam_policy, self.functions) + + def _get_function_iam_policy(self, function: "Function") -> None: + """Mark a Cloud Function as publicly accessible when bound to `allUsers` or `allAuthenticatedUsers`. + + Cloud Functions gen2 delegates invocation IAM to its backing Cloud Run + service, so the binding is queried via the Run API. Gen1 functions are + queried through the Cloud Functions API directly. + """ + try: + if function.environment == "GEN_2" and function.service: + response = ( + self._run_client.projects() + .locations() + .services() + .getIamPolicy(resource=function.service) + .execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + ) + else: + response = ( + self.client.projects() + .locations() + .functions() + .getIamPolicy(resource=function.id) + .execute( + http=self.__get_AuthorizedHttp_client__(), + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + ) + for binding in response.get("bindings", []): + members = binding.get("members", []) + if "allUsers" in members or "allAuthenticatedUsers" in members: + function.publicly_accessible = True + break + except Exception as error: + logger.error( + f"{function.location} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class Function(BaseModel): """Cloud Function resource consumed by GCP checks.""" + id: str name: str project_id: str location: str state: str + environment: str = "GEN_1" + service: Optional[str] = None vpc_connector: Optional[str] = None + publicly_accessible: bool = False diff --git a/prowler/providers/gcp/services/secretmanager/__init__.py b/prowler/providers/gcp/services/secretmanager/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_client.py b/prowler/providers/gcp/services/secretmanager/secretmanager_client.py new file mode 100644 index 0000000000..dc18866cb3 --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + SecretManager, +) + +secretmanager_client = SecretManager(Provider.get_global_provider()) diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.metadata.json b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.metadata.json new file mode 100644 index 0000000000..9f1f4d3381 --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "secretmanager_secret_not_publicly_accessible", + "CheckTitle": "Secret Manager secret is not publicly accessible", + "CheckType": [], + "ServiceName": "secretmanager", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "secretmanager.googleapis.com/Secret", + "Description": "Secret Manager secrets deny access to `allUsers` and `allAuthenticatedUsers`, so only **explicitly authorized identities** can read or use them.\n\nThe evaluation reviews each secret's IAM policy bindings to confirm no public principals are granted access.", + "Risk": "Granting public IAM access to a secret exposes credentials, API keys, certificates, or other sensitive values to any caller on the internet or any authenticated Google account. This compromises **confidentiality**, enables **lateral movement** with leaked credentials, and can trigger regulatory breaches under PCI-DSS, ISO 27001, and GDPR.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/secret-manager/docs/access-control", + "https://cloud.google.com/iam/docs/overview" + ], + "Remediation": { + "Code": { + "CLI": "gcloud secrets remove-iam-policy-binding --member= --role=roles/secretmanager.secretAccessor", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Security > Secret Manager\n2. Select the secret and open the Permissions tab\n3. Remove any binding with allUsers or allAuthenticatedUsers\n4. Grant access only to required service accounts or groups", + "Terraform": "```hcl\nresource \"google_secret_manager_secret_iam_binding\" \"\" {\n secret_id = \"\"\n role = \"roles/secretmanager.secretAccessor\"\n members = [\"serviceAccount:\"] # Critical: never include allUsers or allAuthenticatedUsers\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least privilege** to Secret Manager: grant `roles/secretmanager.secretAccessor` only to the specific service accounts or groups that need to read each secret.\n\nUse **workload identity** for GKE workloads and rotate credentials regularly to limit blast radius when a binding is misconfigured.", + "Url": "https://hub.prowler.com/check/secretmanager_secret_not_publicly_accessible" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "secretmanager_secret_rotation_enabled", + "kms_key_not_publicly_accessible", + "cloudstorage_bucket_public_access" + ], + "Notes": "This check evaluates the secret-level IAM policy. Project-level IAM policies that may indirectly grant public access are not evaluated by this check." +} diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.py b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.py new file mode 100644 index 0000000000..fb52a119fa --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.secretmanager.secretmanager_client import ( + secretmanager_client, +) + + +class secretmanager_secret_not_publicly_accessible(Check): + """Check that Secret Manager secrets do not grant access to all users. + + Verifies that no Secret Manager secret has an IAM binding granting access + to `allUsers` or `allAuthenticatedUsers`. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the public-access check across all Secret Manager secrets. + + Returns: + A list of `Check_Report_GCP` findings, one per secret. Status is + `FAIL` when the secret is accessible to `allUsers` or + `allAuthenticatedUsers` and `PASS` otherwise. + """ + findings = [] + for secret in secretmanager_client.secrets: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=secret, + resource_id=secret.name, + ) + if secret.publicly_accessible: + report.status = "FAIL" + report.status_extended = ( + f"Secret {secret.name} is publicly accessible " + f"(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Secret {secret.name} is not publicly accessible." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/__init__.py b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled.metadata.json b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled.metadata.json new file mode 100644 index 0000000000..09dd158b49 --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "secretmanager_secret_rotation_enabled", + "CheckTitle": "Secret Manager secret is rotated every 90 days or less", + "CheckType": [], + "ServiceName": "secretmanager", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "secretmanager.googleapis.com/Secret", + "Description": "Secret Manager secrets have **automatic rotation** configured with a rotation period of `90` days or less and the next scheduled rotation has not been missed.\n\nThe evaluation reviews each secret's `rotation` settings to confirm both the period and the upcoming rotation time are within bounds.", + "Risk": "Without timely rotation, a leaked or compromised secret remains valid indefinitely, eroding **confidentiality** and widening the **blast radius** of any credential exposure. Stale secrets also bypass periodic re-authorization controls expected by frameworks such as PCI-DSS and ISO 27001.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/secret-manager/docs/rotation-recommendations", + "https://cloud.google.com/secret-manager/docs/reference/rest/v1/projects.secrets" + ], + "Remediation": { + "Code": { + "CLI": "gcloud secrets update --rotation-period=7776000s --next-rotation-time=", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Security > Secret Manager\n2. Select the secret and click Edit secret\n3. Under Rotation, enable Automatic rotation with a period of 90 days or less\n4. Configure a Pub/Sub topic to receive rotation notifications\n5. Click Save", + "Terraform": "```hcl\nresource \"google_secret_manager_secret\" \"\" {\n secret_id = \"\"\n\n replication {\n auto {}\n }\n\n rotation {\n rotation_period = \"7776000s\" # Critical: enables rotation within 90 days\n next_rotation_time = \"\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **automatic rotation** for every Secret Manager secret with a period of `90` days or less.\n\nWire **Pub/Sub notifications** to trigger rotation logic and apply **defense in depth** by versioning each secret update so consumers can roll back without losing availability.", + "Url": "https://hub.prowler.com/check/secretmanager_secret_rotation_enabled" + } + }, + "Categories": [ + "secrets" + ], + "DependsOn": [], + "RelatedTo": [ + "secretmanager_secret_not_publicly_accessible", + "kms_key_rotation_enabled", + "iam_sa_user_managed_key_rotate_90_days" + ], + "Notes": "A secret without a rotationPeriod field has no automatic rotation configured and will be marked as FAIL. Rotation periods exceeding 90 days are also marked as FAIL." +} diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled.py b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled.py new file mode 100644 index 0000000000..84d53e7f3a --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled.py @@ -0,0 +1,83 @@ +import datetime + +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.secretmanager.secretmanager_client import ( + secretmanager_client, +) + + +class secretmanager_secret_rotation_enabled(Check): + """ + Ensure Secret Manager secrets have automatic rotation configured within the max rotation period. + + - PASS: Secret has a rotation period within the maximum (default 90 days) and the next rotation is not overdue. + - FAIL: Secret has no rotation, the period exceeds the maximum, or the next rotation has been missed. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Evaluate every Secret Manager secret's rotation configuration against the maximum rotation period.""" + findings = [] + + max_rotation_days = int( + getattr(secretmanager_client, "audit_config", {}).get( + "secretmanager_max_rotation_days", 90 + ) + ) + + for secret in secretmanager_client.secrets: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=secret, + resource_id=secret.name, + ) + + rotation_seconds = None + if secret.rotation_period: + try: + rotation_seconds = float(secret.rotation_period[:-1]) + except (ValueError, IndexError): + rotation_seconds = None + + rotation_overdue = False + if rotation_seconds is not None and secret.next_rotation_time: + try: + parsed = secret.next_rotation_time.replace("Z", "+00:00") + next_rotation_time = datetime.datetime.fromisoformat(parsed) + rotation_overdue = next_rotation_time < datetime.datetime.now( + datetime.timezone.utc + ) + except (ValueError, AttributeError): + rotation_overdue = True + + max_rotation_seconds = max_rotation_days * 86400 + rotation_days = ( + int(rotation_seconds // 86400) if rotation_seconds is not None else None + ) + + if rotation_seconds is None: + report.status = "FAIL" + report.status_extended = ( + f"Secret {secret.name} does not have automatic rotation enabled." + ) + elif rotation_seconds > max_rotation_seconds: + report.status = "FAIL" + report.status_extended = ( + f"Secret {secret.name} has rotation enabled but the period " + f"({rotation_days} days) exceeds the {max_rotation_days}-day maximum." + ) + elif rotation_overdue: + report.status = "FAIL" + report.status_extended = ( + f"Secret {secret.name} has rotation configured " + f"({rotation_days} days) but the scheduled rotation is overdue." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Secret {secret.name} has automatic rotation enabled " + f"with a period of {rotation_days} days." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_service.py b/prowler/providers/gcp/services/secretmanager/secretmanager_service.py new file mode 100644 index 0000000000..ca01663083 --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_service.py @@ -0,0 +1,94 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS +from prowler.providers.gcp.gcp_provider import GcpProvider +from prowler.providers.gcp.lib.service.service import GCPService + + +class SecretManager(GCPService): + """Secret Manager service client. + + Enumerates Secret Manager secrets across every accessible project using + the `secretmanager.googleapis.com` v1 API and exposes them through the + `secrets` attribute. + """ + + def __init__(self, provider: GcpProvider) -> None: + """Initialize the service and preload secrets with their IAM policies.""" + super().__init__("secretmanager", provider) + self.secrets = [] + self._get_secrets() + self._get_secrets_iam_policy() + + def _get_secrets(self) -> None: + """Fetch Secret Manager secrets for every project.""" + for project_id in self.project_ids: + try: + request = ( + self.client.projects() + .secrets() + .list(parent=f"projects/{project_id}") + ) + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + for secret in response.get("secrets", []): + rotation = secret.get("rotation") or {} + self.secrets.append( + Secret( + id=secret["name"], + name=secret["name"].split("/")[-1], + project_id=project_id, + rotation_period=rotation.get("rotationPeriod"), + next_rotation_time=rotation.get("nextRotationTime"), + ) + ) + request = ( + self.client.projects() + .secrets() + .list_next(previous_request=request, previous_response=response) + ) + except Exception as error: + logger.error( + f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_secrets_iam_policy(self) -> None: + """Fetch IAM policy for every Secret Manager secret in parallel.""" + self.__threading_call__(self._get_secret_iam_policy, self.secrets) + + def _get_secret_iam_policy(self, secret: "Secret") -> None: + """Mark a secret as publicly accessible when bound to `allUsers` or `allAuthenticatedUsers`.""" + try: + response = ( + self.client.projects() + .secrets() + .getIamPolicy(resource=secret.id) + .execute( + http=self.__get_AuthorizedHttp_client__(), + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + ) + for binding in response.get("bindings", []): + members = binding.get("members", []) + if "allUsers" in members or "allAuthenticatedUsers" in members: + secret.publicly_accessible = True + break + except Exception as error: + logger.error( + f"{secret.project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Secret(BaseModel): + """Secret Manager secret resource consumed by GCP checks.""" + + id: str + name: str + project_id: str + location: str = "global" + rotation_period: Optional[str] = None + next_rotation_time: Optional[str] = None + publicly_accessible: bool = False diff --git a/prowler/providers/kubernetes/services/core/core_cpu_limits_set/__init__.py b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.metadata.json b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.metadata.json new file mode 100644 index 0000000000..5831cb2abd --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.metadata.json @@ -0,0 +1,35 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_cpu_limits_set", + "CheckTitle": "Pod containers have CPU limits set", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "KubernetesPod", + "Description": "Ensure CPU limits are set for containers to prevent noisy neighbors and resource exhaustion.", + "Risk": "Missing CPU limits can allow containers to consume unbounded CPU leading to performance issues.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set resources deployment/ -n --containers= --limits=cpu=500m", + "NativeIaC": "# Example: set cpu limits in container resources\nresources:\n limits:\n cpu: \"500m\"", + "Other": "1. Edit the Pod/Deployment manifest and set `resources.limits.cpu` for each container.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Define CPU limits to bound a container's CPU usage and protect node stability.", + "Url": "https://hub.prowler.com/check/core_cpu_limits_set" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Limits should be tuned per workload and monitored." +} diff --git a/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.py b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.py new file mode 100644 index 0000000000..8f22fa6b28 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_cpu_limits_set(Check): + """Check whether regular pod containers have CPU limits configured.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes pod CPU limits check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} regular containers have CPU limits configured." + ) + + for container in (pod.containers or {}).values(): + resources = container.resources or {} + limits = ( + resources.get("limits") if isinstance(resources, dict) else None + ) + cpu = limits.get("cpu") if limits and isinstance(limits, dict) else None + if not cpu: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have a CPU limit configured." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_cpu_requests_set/__init__.py b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.metadata.json b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.metadata.json new file mode 100644 index 0000000000..9f3972e271 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.metadata.json @@ -0,0 +1,35 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_cpu_requests_set", + "CheckTitle": "Pod containers have CPU requests set", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "KubernetesPod", + "Description": "Ensure CPU requests are set for containers to enable proper scheduling and resource guarantees.", + "Risk": "Missing CPU requests can lead to scheduling and resource contention issues.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set resources deployment/ -n --containers= --requests=cpu=100m", + "NativeIaC": "# Example: set cpu requests in container resources\nresources:\n requests:\n cpu: \"100m\"", + "Other": "1. Edit the Pod/Deployment manifest and set `resources.requests.cpu` for each container.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Define sensible CPU requests to enable efficient scheduling and fair resource allocation.", + "Url": "https://hub.prowler.com/check/core_cpu_requests_set" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Requests should be tuned per workload and cluster capacity." +} diff --git a/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.py b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.py new file mode 100644 index 0000000000..6e7f9f8020 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_cpu_requests_set(Check): + """Check whether regular pod containers have CPU requests configured.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes pod CPU requests check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} regular containers have CPU requests configured." + ) + + for container in (pod.containers or {}).values(): + resources = container.resources or {} + requests = ( + resources.get("requests") if isinstance(resources, dict) else None + ) + cpu = ( + requests.get("cpu") + if requests and isinstance(requests, dict) + else None + ) + if not cpu: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have a CPU request configured." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_image_tag_fixed/__init__.py b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.metadata.json b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.metadata.json new file mode 100644 index 0000000000..3a395caad1 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_image_tag_fixed", + "CheckTitle": "Container images use fixed tags", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated for containers using images without a **fixed version tag**, such as `latest` or no tag at all, which results in unpredictable image versions being pulled.", + "Risk": "Using **`latest` or untagged images** breaks reproducibility and introduces risk:\n- Deployments may pull different image versions across nodes (integrity)\n- Rollbacks become unreliable when the exact image is unknown\n- Supply-chain attacks can inject malicious code via mutable tags\nCompromised or unexpected images can lead to **data breaches**, **lateral movement**, and **service disruption**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/containers/images/", + "https://kubernetes.io/docs/concepts/configuration/overview/#container-images" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set image deployment/ =: -n \n# Example: kubectl set image deployment/nginx nginx=nginx:1.25.3 -n default\n# For maximum immutability, use an image digest instead: @sha256:", + "NativeIaC": "", + "Other": "1. Open your Kubernetes UI (e.g., cloud provider console or Dashboard)\n2. Navigate to the failing workload (Deployment/StatefulSet/DaemonSet) or Pod and choose Edit YAML/Manifest\n3. Update the image field for each affected container to include a specific version tag or digest:\n - Example: change `nginx` or `nginx:latest` to `nginx:1.25.3` or `nginx@sha256:`\n4. Save changes (controllers will roll out new Pods). If it is a standalone Pod, delete and recreate it to apply the change", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata { name = \"\" }\n spec {\n container {\n name = \"\"\n image = \"nginx:1.25.3\" # Critical: use a fixed version tag, never 'latest' or untagged\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Pin all container images to a **specific version tag** or **digest** (`@sha256:...`) to ensure reproducible, auditable deployments.\n\n- Avoid `latest` and untagged images in production\n- Use **image digests** for maximum immutability\n- Enforce tag policies with **admission controllers** (e.g., OPA Gatekeeper, Kyverno)\n- Integrate image scanning into CI/CD pipelines for **defense in depth**", + "Url": "https://hub.prowler.com/check/core_image_tag_fixed" + } + }, + "Categories": [ + "container-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Using fixed image tags ensures reproducible deployments and reduces supply-chain attack surface." +} diff --git a/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.py b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.py new file mode 100644 index 0000000000..5167d09f2c --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.py @@ -0,0 +1,50 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +def _has_fixed_image_tag(image: str) -> bool: + if "@" in image: + return True + + image_name = image.rsplit("/", 1)[-1] + if ":" not in image_name: + return False + + tag = image_name.rsplit(":", 1)[-1] + return bool(tag) and tag.lower() != "latest" + + +class core_image_tag_fixed(Check): + """Ensure that image tag is not set to latest or blank.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} has fixed image tags on all containers." + ) + + for containers in ( + pod.containers, + pod.init_containers, + pod.ephemeral_containers, + ): + for container in (containers or {}).values(): + image = container.image + if not _has_fixed_image_tag(image): + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} has container {container.name} with image '{image}' that does not use a fixed tag." + break + if report.status == "FAIL": + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/__init__.py b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.metadata.json b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.metadata.json new file mode 100644 index 0000000000..668551bca4 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_liveness_probe_configured", + "CheckTitle": "Pod containers have liveness probes configured", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "KubernetesPod", + "Description": "Ensure each regular pod container has a liveness probe configured to detect and restart unhealthy containers.", + "Risk": "Without liveness probes, failed containers may remain running causing degraded service or resource waste.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/", + "https://sre.google/workbook/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl patch deployment/ -n --type='json' -p='[{\"op\":\"add\",\"path\":\"/spec/template/spec/containers/0/livenessProbe\",\"value\":{\"httpGet\":{\"path\":\"/healthz\",\"port\":8080},\"initialDelaySeconds\":10,\"periodSeconds\":10}}]'", + "NativeIaC": "# Example: add a livenessProbe to your container spec\nlivenessProbe:\n httpGet:\n path: /healthz\n port: 8080\n initialDelaySeconds: 10\n periodSeconds: 10", + "Other": "1. Edit the Pod/Deployment manifest and add a `livenessProbe` to each container.\n2. Tune `initialDelaySeconds`, `periodSeconds` and failure thresholds to your app.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Add and tune liveness probes for containers to allow Kubernetes to detect and restart unhealthy containers.", + "Url": "https://hub.prowler.com/check/core_liveness_probe_configured" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Presence of a probe is checked; adjust thresholds for app behavior to avoid false positives." +} diff --git a/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.py b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.py new file mode 100644 index 0000000000..0bfbc618d2 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_liveness_probe_configured(Check): + """Check whether regular pod containers have liveness probes configured.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes pod liveness probe check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = f"Pod {pod.name} has liveness probes configured for all regular containers." + + for container in (pod.containers or {}).values(): + if not container.liveness_probe: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have a liveness probe configured." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_memory_limits_set/__init__.py b/prowler/providers/kubernetes/services/core/core_memory_limits_set/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.metadata.json b/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.metadata.json new file mode 100644 index 0000000000..8b9a121bf2 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_memory_limits_set", + "CheckTitle": "Container memory limits are configured", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated for containers without **memory limits** configured in `resources.limits.memory`, indicating unbounded memory consumption is permitted.", + "Risk": "Without **memory limits**, a single container can consume all available node memory, causing:\n- **OOM kills** of other workloads (availability)\n- Node instability and cascading failures\n- Noisy-neighbor problems in multi-tenant clusters\nAttackers exploiting a vulnerability can amplify impact by exhausting node resources, enabling **denial of service** across co-located workloads.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", + "https://kubernetes.io/docs/tasks/administer-cluster/manage-resources/memory-default-namespace/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set resources deployment/ -c --limits=memory= -n \n# Example: kubectl set resources deployment/nginx -c nginx --limits=memory=128Mi -n default", + "NativeIaC": "", + "Other": "1. Open your Kubernetes UI (e.g., cloud provider console or Dashboard)\n2. Navigate to the failing workload (Deployment/StatefulSet/DaemonSet) or Pod and choose Edit YAML/Manifest\n3. Set resources.limits.memory for each affected container:\n - For controllers: spec.template.spec.containers[].resources.limits.memory\n - For standalone Pods: spec.containers[].resources.limits.memory\n4. Save changes (controllers will roll out new Pods). If it is a standalone Pod, delete and recreate it to apply the change", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata { name = \"\" }\n spec {\n container {\n name = \"\"\n image = \"\"\n resources {\n limits = {\n memory = \"128Mi\" # Critical: sets a memory ceiling to prevent unbounded consumption\n }\n }\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set **memory limits** on every container to prevent unbounded consumption and protect node stability.\n\n- Start with observed usage plus headroom; tune with **VPA** recommendations\n- Combine with **LimitRanges** and **ResourceQuotas** at the namespace level for **defense in depth**\n- Monitor memory usage and OOMKill events to right-size limits over time", + "Url": "https://hub.prowler.com/check/core_memory_limits_set" + } + }, + "Categories": [ + "container-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Memory limits prevent containers from consuming unbounded memory and protect node stability." +} diff --git a/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.py b/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.py new file mode 100644 index 0000000000..2fd623c8d5 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_memory_limits_set(Check): + """Ensure that memory limits are set on all containers.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} has memory limits set on all containers." + ) + + for container in (pod.containers or {}).values(): + resources = container.resources or {} + limits = ( + resources.get("limits") if isinstance(resources, dict) else None + ) + memory = ( + limits.get("memory") + if limits and isinstance(limits, dict) + else None + ) + if not memory: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} does not have memory limits set on container {container.name}." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_memory_requests_set/__init__.py b/prowler/providers/kubernetes/services/core/core_memory_requests_set/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.metadata.json b/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.metadata.json new file mode 100644 index 0000000000..1871884621 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_memory_requests_set", + "CheckTitle": "Container memory requests are configured", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated for containers without **memory requests** configured in `resources.requests.memory`, indicating the scheduler cannot guarantee memory allocation.", + "Risk": "Without **memory requests**, the scheduler cannot make informed placement decisions, leading to:\n- Overcommitted nodes that trigger **OOM kills** (availability)\n- Unpredictable performance under load\n- Inability to enforce **ResourceQuotas** effectively\nMissing requests weaken cluster stability and make capacity planning unreliable, increasing blast radius during incidents.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", + "https://kubernetes.io/docs/tasks/administer-cluster/manage-resources/memory-default-namespace/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set resources deployment/ -c --requests=memory= -n \n# Example: kubectl set resources deployment/nginx -c nginx --requests=memory=64Mi -n default", + "NativeIaC": "", + "Other": "1. Open your Kubernetes UI (e.g., cloud provider console or Dashboard)\n2. Navigate to the failing workload (Deployment/StatefulSet/DaemonSet) or Pod and choose Edit YAML/Manifest\n3. Set resources.requests.memory for each affected container:\n - For controllers: spec.template.spec.containers[].resources.requests.memory\n - For standalone Pods: spec.containers[].resources.requests.memory\n4. Save changes (controllers will roll out new Pods). If it is a standalone Pod, delete and recreate it to apply the change", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata { name = \"\" }\n spec {\n container {\n name = \"\"\n image = \"\"\n resources {\n requests = {\n memory = \"64Mi\" # Critical: guarantees memory for the scheduler to make informed placement\n }\n }\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set **memory requests** on every container so the scheduler can guarantee memory allocation and place pods effectively.\n\n- Base requests on observed steady-state usage; use **VPA** for data-driven sizing\n- Enforce minimum requests via **LimitRanges** at the namespace level\n- Combine with **ResourceQuotas** for **defense in depth** against overcommitment", + "Url": "https://hub.prowler.com/check/core_memory_requests_set" + } + }, + "Categories": [ + "container-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Memory requests enable the scheduler to guarantee memory allocation and prevent overcommitment." +} diff --git a/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.py b/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.py new file mode 100644 index 0000000000..a560365e32 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_memory_requests_set(Check): + """Ensure that memory requests are set on all containers.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} has memory requests set on all containers." + ) + + for container in (pod.containers or {}).values(): + resources = container.resources or {} + requests = ( + resources.get("requests") if isinstance(resources, dict) else None + ) + memory = ( + requests.get("memory") + if requests and isinstance(requests, dict) + else None + ) + if not memory: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} does not have memory requests set on container {container.name}." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/__init__.py b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.metadata.json b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.metadata.json new file mode 100644 index 0000000000..5c61381064 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.metadata.json @@ -0,0 +1,35 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_readiness_probe_configured", + "CheckTitle": "Regular pod containers have readiness probes configured", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "KubernetesPod", + "Description": "Ensure each regular pod container has a readiness probe configured to signal when it is ready to serve traffic.", + "Risk": "Without readiness probes, services may receive traffic before containers are ready, causing errors.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl patch deployment/ -n --type='json' -p='[{\"op\":\"add\",\"path\":\"/spec/template/spec/containers/0/readinessProbe\",\"value\":{\"httpGet\":{\"path\":\"/ready\",\"port\":8080},\"initialDelaySeconds\":5,\"periodSeconds\":5}}]'", + "NativeIaC": "# Example readiness probe\nreadinessProbe:\n httpGet:\n path: /ready\n port: 8080\n initialDelaySeconds: 5\n periodSeconds: 5", + "Other": "1. Add a `readinessProbe` to the container spec in Deployments/Pods.\n2. Ensure the probe accurately reflects service readiness.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Add readiness probes to prevent routing traffic to unready containers.", + "Url": "https://hub.prowler.com/check/core_readiness_probe_configured" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Readiness and liveness probes serve different purposes; implement both where appropriate." +} diff --git a/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.py b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.py new file mode 100644 index 0000000000..847f9b4b97 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_readiness_probe_configured(Check): + """Check whether regular pod containers have readiness probes configured.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes pod readiness probe check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = f"Pod {pod.name} has readiness probes configured for all regular containers." + + for container in (pod.containers or {}).values(): + if not container.readiness_probe: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have a readiness probe configured." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_service.py b/prowler/providers/kubernetes/services/core/core_service.py index e685140a87..b53a81779c 100644 --- a/prowler/providers/kubernetes/services/core/core_service.py +++ b/prowler/providers/kubernetes/services/core/core_service.py @@ -27,45 +27,11 @@ class Core(KubernetesService): for namespace in self.namespaces: pods = self.client.list_namespaced_pod(namespace) for pod in pods.items: - pod_containers = {} - containers = pod.spec.containers if pod.spec.containers else [] - init_containers = ( - pod.spec.init_containers if pod.spec.init_containers else [] - ) - ephemeral_containers = ( + containers = self._build_containers(pod.spec.containers) + init_containers = self._build_containers(pod.spec.init_containers) + ephemeral_containers = self._build_containers( pod.spec.ephemeral_containers - if pod.spec.ephemeral_containers - else [] ) - for container in ( - containers + init_containers + ephemeral_containers - ): - pod_containers[container.name] = Container( - name=container.name, - image=container.image, - command=container.command if container.command else None, - ports=( - [ - {"containerPort": port.container_port} - for port in container.ports - ] - if container.ports - else None - ), - env=( - [ - {"name": env.name, "value": env.value} - for env in container.env - ] - if container.env - else None - ), - security_context=( - container.security_context.to_dict() - if container.security_context - else {} - ), - ) self.pods[pod.metadata.uid] = Pod( name=pod.metadata.name, uid=pod.metadata.uid, @@ -85,13 +51,56 @@ class Core(KubernetesService): if pod.spec.security_context else {} ), - containers=pod_containers, + containers=containers, + init_containers=init_containers, + ephemeral_containers=ephemeral_containers, ) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + @staticmethod + def _build_containers(containers) -> dict: + pod_containers = {} + for container in containers or []: + pod_containers[container.name] = Container( + name=container.name, + image=container.image, + command=container.command if container.command else None, + ports=( + [{"containerPort": port.container_port} for port in container.ports] + if container.ports + else None + ), + env=( + [{"name": env.name, "value": env.value} for env in container.env] + if container.env + else None + ), + security_context=( + container.security_context.to_dict() + if container.security_context + else {} + ), + resources=( + container.resources.to_dict() + if getattr(container, "resources", None) + else None + ), + liveness_probe=( + container.liveness_probe.to_dict() + if getattr(container, "liveness_probe", None) + else None + ), + readiness_probe=( + container.readiness_probe.to_dict() + if getattr(container, "readiness_probe", None) + else None + ), + ) + return pod_containers + def _list_config_maps(self): try: response = self.client.list_config_map_for_all_namespaces() @@ -156,6 +165,9 @@ class Container(BaseModel): ports: Optional[List[dict]] env: Optional[List[dict]] security_context: dict + resources: Optional[dict] = None + liveness_probe: Optional[dict] = None + readiness_probe: Optional[dict] = None class Pod(BaseModel): @@ -174,6 +186,8 @@ class Pod(BaseModel): host_network: Optional[bool] security_context: Optional[dict] containers: Optional[dict] + init_containers: Optional[dict] = None + ephemeral_containers: Optional[dict] = None class ConfigMap(BaseModel): diff --git a/prowler/providers/linode/__init__.py b/prowler/providers/linode/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/exceptions/__init__.py b/prowler/providers/linode/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/exceptions/exceptions.py b/prowler/providers/linode/exceptions/exceptions.py new file mode 100644 index 0000000000..6c39f477db --- /dev/null +++ b/prowler/providers/linode/exceptions/exceptions.py @@ -0,0 +1,106 @@ +from prowler.exceptions.exceptions import ProwlerException + + +# Exceptions codes from 18000 to 18099 are reserved for Linode exceptions +class LinodeBaseException(ProwlerException): + """Base class for Linode errors.""" + + LINODE_ERROR_CODES = { + (18000, "LinodeCredentialsError"): { + "message": "Linode credentials not found or invalid", + "remediation": "Provide a valid Personal Access Token for Linode via the LINODE_TOKEN environment variable.", + }, + (18001, "LinodeAuthenticationError"): { + "message": "Linode authentication failed", + "remediation": "Verify the Linode Personal Access Token and ensure it has the required scopes (linodes:read_only, firewalls:read_only, account:read_only).", + }, + (18002, "LinodeSessionError"): { + "message": "Linode session setup failed", + "remediation": "Review the Linode SDK initialization parameters and credentials.", + }, + (18003, "LinodeIdentityError"): { + "message": "Unable to retrieve Linode identity or account information", + "remediation": "Ensure the Personal Access Token allows access to the Linode account and profile APIs.", + }, + (18004, "LinodeMissingPermissionError"): { + "message": "Linode token is missing a required permission scope", + "remediation": "Grant the Personal Access Token the read-only scope required for the affected service (account:read_only, linodes:read_only, firewall:read_only).", + }, + (18005, "LinodeInvalidRegionError"): { + "message": "One or more requested Linode regions are invalid", + "remediation": "Pass a valid Linode region id to --region. See https://www.linode.com/global-infrastructure/ or the API /v4/regions endpoint for the current list.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + provider = "Linode" + error_info = self.LINODE_ERROR_CODES.get((code, self.__class__.__name__)) + if error_info is None: + error_info = { + "message": message or "Unknown Linode error", + "remediation": "Check the Linode API documentation for more details.", + } + elif message: + error_info = error_info.copy() + error_info["message"] = message + super().__init__( + code=code, + source=provider, + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class LinodeCredentialsError(LinodeBaseException): + """Exception for Linode credential errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18000, file=file, original_exception=original_exception, message=message + ) + + +class LinodeAuthenticationError(LinodeBaseException): + """Exception for Linode authentication errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18001, file=file, original_exception=original_exception, message=message + ) + + +class LinodeSessionError(LinodeBaseException): + """Exception for Linode session setup errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18002, file=file, original_exception=original_exception, message=message + ) + + +class LinodeIdentityError(LinodeBaseException): + """Exception for Linode identity errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18003, file=file, original_exception=original_exception, message=message + ) + + +class LinodeMissingPermissionError(LinodeBaseException): + """Exception for Linode missing permission scope errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18004, file=file, original_exception=original_exception, message=message + ) + + +class LinodeInvalidRegionError(LinodeBaseException): + """Exception for invalid Linode region filters.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18005, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/linode/lib/__init__.py b/prowler/providers/linode/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/arguments/__init__.py b/prowler/providers/linode/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/arguments/arguments.py b/prowler/providers/linode/lib/arguments/arguments.py new file mode 100644 index 0000000000..b8af36dba9 --- /dev/null +++ b/prowler/providers/linode/lib/arguments/arguments.py @@ -0,0 +1,24 @@ +def init_parser(self): + """Init the Linode provider CLI parser.""" + linode_parser = self.subparsers.add_parser( + "linode", parents=[self.common_providers_parser], help="Linode Provider" + ) + + # Authentication + # Credentials are read exclusively from the standard Linode environment + # variable (LINODE_TOKEN) to avoid leaking secrets into shell history and + # process listings. There are no credential CLI flags. + + # Regions + regions_subparser = linode_parser.add_argument_group("Regions") + regions_subparser.add_argument( + "--region", + "--filter-region", + "-f", + nargs="+", + default=None, + metavar="REGION", + help="Linode region(s) to scan (e.g. eu-central us-east). Region-less " + "resources (account, networking) are always scanned. If omitted, all " + "regions are scanned.", + ) diff --git a/prowler/providers/linode/lib/mutelist/__init__.py b/prowler/providers/linode/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/mutelist/mutelist.py b/prowler/providers/linode/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..c7da04a58a --- /dev/null +++ b/prowler/providers/linode/lib/mutelist/mutelist.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import CheckReportLinode +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class LinodeMutelist(Mutelist): + """Linode-specific mutelist helper.""" + + def is_finding_muted( + self, + finding: CheckReportLinode, + account_id: str, + ) -> bool: + """ + Check if a Linode finding is muted. + + Args: + finding: CheckReportLinode instance containing check metadata, region, resource info, and tags. + account_id: Linode account identifier. + + Returns: + True if the finding is muted, False otherwise. + """ + return self.is_muted( + account_id, + finding.check_metadata.CheckID, + finding.region or "global", + finding.resource_id or finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/linode/lib/service/__init__.py b/prowler/providers/linode/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/service/service.py b/prowler/providers/linode/lib/service/service.py new file mode 100644 index 0000000000..d1a9de65db --- /dev/null +++ b/prowler/providers/linode/lib/service/service.py @@ -0,0 +1,86 @@ +import os +from concurrent.futures import ThreadPoolExecutor, as_completed + +from prowler.lib.logger import logger +from prowler.providers.linode.exceptions.exceptions import LinodeMissingPermissionError +from prowler.providers.linode.linode_provider import LinodeProvider + +MAX_WORKERS = 10 + + +class LinodeService: + """Base class for Linode services to share provider context.""" + + def __init__(self, service: str, provider: LinodeProvider): + """ + Initialize the Linode service with provider context. + + Args: + service: The Linode service name (e.g., administration, compute, networking). + provider: LinodeProvider instance containing session, audit config, and fixer config. + """ + self.provider = provider + self.client = provider.session.client + self.audit_config = provider.audit_config + self.fixer_config = provider.fixer_config + self.service = service.lower() if not service.islower() else service + + self.thread_pool = ThreadPoolExecutor(max_workers=MAX_WORKERS) + + def _log_fetch_error( + self, resource_label: str, required_scope: str, error: Exception + ) -> None: + """Log a resource-fetch failure, distinguishing an insufficient-scope + (HTTP 401/403) error from a generic API error. + + This never raises: a single service's missing permission must not abort + the rest of the scan. When the token lacks the required scope, the log + names the exact scope to grant via ``LinodeMissingPermissionError``. + + Args: + resource_label: Human-readable resource name (e.g. "firewalls"). + required_scope: The Linode OAuth scope needed (e.g. "firewall:read_only"). + error: The exception raised by the SDK call. + """ + service_name = getattr(self, "service", "linode") + status = getattr(error, "status", None) + if status in (401, 403) or "not authorized to use this endpoint" in str(error): + logger.error( + str( + LinodeMissingPermissionError( + file=os.path.basename(__file__), + message=( + f"{service_name} - unable to list {resource_label}: the Linode " + f"token lacks the '{required_scope}' scope; skipping these checks." + ), + original_exception=error, + ) + ) + ) + else: + logger.error( + f"{service_name} - Error fetching {resource_label}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def __threading_call__(self, call, iterator): + """Execute a function across multiple items using threading.""" + items = list(iterator) if not isinstance(iterator, list) else iterator + + futures = {self.thread_pool.submit(call, item): item for item in items} + results = [] + + for future in as_completed(futures): + try: + result = future.result() + if result is not None: + results.append(result) + except Exception as error: + item = futures[future] + item_id = getattr(item, "id", str(item)) + logger.error( + f"{self.service} - Threading error processing {item_id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return results diff --git a/prowler/providers/linode/linode_provider.py b/prowler/providers/linode/linode_provider.py new file mode 100644 index 0000000000..ab069fab3d --- /dev/null +++ b/prowler/providers/linode/linode_provider.py @@ -0,0 +1,343 @@ +import logging +import os + +from colorama import Fore, Style +from linode_api4 import LinodeClient + +from prowler.config.config import ( + default_config_file_path, + get_default_mute_file_path, + load_and_validate_config_file, +) +from prowler.lib.logger import logger +from prowler.lib.utils.utils import print_boxes +from prowler.providers.common.models import Audit_Metadata, Connection +from prowler.providers.common.provider import Provider +from prowler.providers.linode.exceptions.exceptions import ( + LinodeAuthenticationError, + LinodeCredentialsError, + LinodeIdentityError, + LinodeInvalidRegionError, + LinodeSessionError, +) +from prowler.providers.linode.lib.mutelist.mutelist import LinodeMutelist +from prowler.providers.linode.models import ( + LinodeIdentityInfo, + LinodeSession, +) + + +class LinodeProvider(Provider): + """Linode provider.""" + + _type: str = "linode" + _session: LinodeSession + _identity: LinodeIdentityInfo + _audit_config: dict + _fixer_config: dict + _mutelist: LinodeMutelist + _regions: set + audit_metadata: Audit_Metadata + + def __init__( + self, + config_path: str = None, + config_content: dict | None = None, + fixer_config: dict | None = None, + mutelist_path: str = None, + mutelist_content: dict = None, + token: str = None, + regions: list = None, + ): + """ + Initializes the LinodeProvider instance. + + Args: + config_path (str): Path to the configuration file. + config_content (dict): Audit configuration content. + fixer_config (dict): Fixer configuration. + mutelist_path (str): Path to the mutelist file. + mutelist_content (dict): Mutelist content. + token (str): Linode Personal Access Token (falls back to LINODE_TOKEN env var). + regions (list): Region(s) to scan regional resources in. Region-less + resources are always scanned. ``None`` scans all regions. + + Raises: + LinodeCredentialsError: If no token is provided. + LinodeSessionError: If the Linode session cannot be established. + LinodeIdentityError: If user or account identity cannot be retrieved. + """ + logger.info("Instantiating Linode provider...") + + # Mute noisy HTTP client logs + logging.getLogger("urllib3").setLevel(logging.WARNING) + + if config_content: + self._audit_config = config_content + else: + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + self._session = LinodeProvider.setup_session(token=token) + + # Region filter for regional resources, validated against the live + # Linode regions list. None means scan all regions. + self._regions = LinodeProvider.validate_regions(self._session, regions) + + self._identity = LinodeProvider.setup_identity(self._session) + + self._fixer_config = fixer_config if fixer_config is not None else {} + + if mutelist_content: + self._mutelist = LinodeMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self.type) + self._mutelist = LinodeMutelist(mutelist_path=mutelist_path) + + Provider.set_global_provider(self) + + @property + def type(self): + return self._type + + @property + def session(self): + return self._session + + @property + def identity(self): + return self._identity + + @property + def audit_config(self): + return self._audit_config + + @property + def fixer_config(self): + return self._fixer_config + + @property + def mutelist(self) -> LinodeMutelist: + return self._mutelist + + @property + def regions(self): + """Set of regions to scan for regional resources, or None for all.""" + return self._regions + + def validate_arguments(self) -> None: + """Linode provider has no provider-specific arguments to validate.""" + return None + + @staticmethod + def setup_session(token: str = None) -> LinodeSession: + """Initialize Linode SDK client. + + Credentials can be provided as argument or read from environment variable: + - LINODE_TOKEN (Personal Access Token) + + Args: + token: Linode Personal Access Token (optional, falls back to env var). + + Returns: + LinodeSession: The initialized Linode session. + + Raises: + LinodeCredentialsError: If no credentials are provided. + LinodeSessionError: If session setup fails. + """ + token = token or os.environ.get("LINODE_TOKEN", "") + + if not token: + raise LinodeCredentialsError( + file=os.path.basename(__file__), + message="Linode credentials not found. Set the LINODE_TOKEN environment variable.", + ) + + try: + client = LinodeClient(token) + return LinodeSession(client=client, token=token) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LinodeSessionError( + file=os.path.basename(__file__), + original_exception=error, + ) + + @staticmethod + def validate_regions(session: LinodeSession, regions: list = None): + """Validate the requested regions against the live Linode regions list. + + The ``/v4/regions`` endpoint is public, so this works regardless of the + token's scope. Validating against the live list (instead of a static + file) avoids rejecting newly added Linode regions. + + Args: + session: The Linode session. + regions: The region ids requested via --region (or None). + + Returns: + The validated set of region ids, or None when no filter is given. + + Raises: + LinodeInvalidRegionError: If any requested region id is unknown. + """ + if not regions: + return None + + requested = set(regions) + try: + available = {region.id for region in session.client.regions()} + except Exception as error: + # Do not block a scan if the regions list cannot be fetched. + logger.warning( + f"Unable to validate Linode regions: {error}. " + "Proceeding with the requested regions without validation." + ) + return requested + + invalid = requested - available + if invalid: + raise LinodeInvalidRegionError( + file=os.path.basename(__file__), + message=( + f"Invalid Linode region(s): {', '.join(sorted(invalid))}. " + f"Valid regions are: {', '.join(sorted(available))}." + ), + ) + return requested + + @staticmethod + def setup_identity(session: LinodeSession) -> LinodeIdentityInfo: + """Fetch user and account metadata for Linode. + + The authenticated user's profile is retrieved first to validate the + token. Any valid token can read its own profile, so a failure here + (for example a ``401 Invalid Token``) means the credentials are invalid + and the scan is aborted instead of silently returning empty results. + + Args: + session: The Linode session. + + Returns: + LinodeIdentityInfo: The identity information. + + Raises: + LinodeAuthenticationError: If the token is invalid. + LinodeIdentityError: If identity setup fails unexpectedly. + """ + try: + client = session.client + username = None + email = None + account_id = None + + # Validate the token by reading the authenticated user's profile. + # A failure here means the credentials are invalid, so abort. + try: + profile = client.profile() + username = profile.username + email = profile.email + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LinodeAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + + # The account endpoint requires the account:read_only scope. A + # token without that scope is still valid, so continue without the + # account ID instead of failing the scan. + try: + account = client.account() + account_id = getattr(account, "euuid", None) + except Exception as error: + logger.warning( + f"Unable to retrieve Linode account info: {error}. Continuing without account ID." + ) + + return LinodeIdentityInfo( + username=username, + email=email, + account_id=account_id, + ) + except LinodeAuthenticationError: + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LinodeIdentityError( + file=os.path.basename(__file__), + original_exception=error, + ) + + def print_credentials(self) -> None: + report_title = ( + f"{Style.BRIGHT}Using the Linode credentials below:{Style.RESET_ALL}" + ) + report_lines = [] + + report_lines.append( + f"Authentication: {Fore.YELLOW}Personal Access Token{Style.RESET_ALL}" + ) + + if self.identity.username: + report_lines.append( + f"Username: {Fore.YELLOW}{self.identity.username}{Style.RESET_ALL}" + ) + + if self.identity.email: + report_lines.append( + f"Email: {Fore.YELLOW}{self.identity.email}{Style.RESET_ALL}" + ) + + if self.identity.account_id: + report_lines.append( + f"Account ID: {Fore.YELLOW}{self.identity.account_id}{Style.RESET_ALL}" + ) + + print_boxes(report_lines, report_title) + + @staticmethod + def test_connection( + token: str = None, + raise_on_exception: bool = True, + ) -> Connection: + """Test connection to Linode. + + Args: + token: Linode Personal Access Token. + raise_on_exception: Flag indicating whether to raise an exception if the connection fails. + + Returns: + Connection: Connection object with is_connected status. + """ + try: + session = LinodeProvider.setup_session(token=token) + # Validate by fetching profile + session.client.profile() + return Connection(is_connected=True) + except LinodeCredentialsError as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise + return Connection(is_connected=False, error=error) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise LinodeAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + return Connection(is_connected=False, error=error) diff --git a/prowler/providers/linode/models.py b/prowler/providers/linode/models.py new file mode 100644 index 0000000000..8e41e881a7 --- /dev/null +++ b/prowler/providers/linode/models.py @@ -0,0 +1,38 @@ +from typing import Any, Optional + +from pydantic import BaseModel + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + + +class LinodeSession(BaseModel): + """Linode session information.""" + + client: Any + token: Optional[str] = None + + +class LinodeIdentityInfo(BaseModel): + """Linode identity and scoping information.""" + + username: Optional[str] = None + email: Optional[str] = None + account_id: Optional[str] = None + + +class LinodeOutputOptions(ProviderOutputOptions): + """Customize output filenames for Linode scans.""" + + def __init__(self, arguments, bulk_checks_metadata, identity: LinodeIdentityInfo): + super().__init__(arguments, bulk_checks_metadata) + if ( + not hasattr(arguments, "output_filename") + or arguments.output_filename is None + ): + account_fragment = identity.account_id or identity.username or "linode" + self.output_filename = ( + f"prowler-output-{account_fragment}-{output_file_timestamp}" + ) + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/linode/services/__init__.py b/prowler/providers/linode/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/administration/__init__.py b/prowler/providers/linode/services/administration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/administration/administration_client.py b/prowler/providers/linode/services/administration/administration_client.py new file mode 100644 index 0000000000..99a242e9ee --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.linode.services.administration.administration_service import ( + AdministrationService, +) + +administration_client = AdministrationService(Provider.get_global_provider()) diff --git a/prowler/providers/linode/services/administration/administration_service.py b/prowler/providers/linode/services/administration/administration_service.py new file mode 100644 index 0000000000..7704cf11b2 --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_service.py @@ -0,0 +1,46 @@ +from typing import List + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.linode.lib.service.service import LinodeService + + +class User(BaseModel): + """Model for a Linode account user.""" + + username: str + email: str = "" + tfa_enabled: bool = False + restricted: bool = False + + +class AdministrationService(LinodeService): + """Service to interact with Linode Account Users.""" + + def __init__(self, provider): + super().__init__("administration", provider) + self.users: List[User] = [] + self._describe_users() + + def _describe_users(self): + """Fetch all Linode account users.""" + try: + raw_users = self.client.account.users() + for user in raw_users: + try: + self.users.append( + User( + username=getattr(user, "username", "") or "", + email=getattr(user, "email", "") or "", + tfa_enabled=getattr(user, "tfa_enabled", False) or False, + restricted=getattr(user, "restricted", False) or False, + ) + ) + except Exception as error: + logger.error( + f"account - Error processing user {getattr(user, 'username', 'unknown')}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self._log_fetch_error("account users", "account:read_only", error) diff --git a/prowler/providers/linode/services/administration/administration_user_2fa_enabled/__init__.py b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json new file mode 100644 index 0000000000..c16e26f56d --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "linode", + "CheckID": "administration_user_2fa_enabled", + "CheckTitle": "Linode account user has two-factor authentication enabled", + "CheckType": [], + "ServiceName": "administration", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Linode account users** are assessed for **two-factor authentication (2FA)** enablement. 2FA adds a second verification factor (a TOTP code or security key) on top of the password, so a stolen or guessed password alone is not enough to sign in to the **Linode Cloud Manager**.", + "Risk": "Without **2FA**, a single compromised password — through phishing, credential stuffing, or brute force — grants an attacker full access to the user's Linode account. This can lead to **data exfiltration**, destruction of instances and backups, and resource abuse for **cryptomining**, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-2fa" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to My Profile > Login & Authentication\n3. Under Security Settings, configure all 3 security questions\n4. Enable Two-Factor Authentication", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `2FA` for all Linode account users. Use an authenticator app (`TOTP`) for the second factor and store backup codes securely.", + "Url": "https://hub.prowler.com/check/administration_user_2fa_enabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py new file mode 100644 index 0000000000..2bb2c92729 --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.administration.administration_client import ( + administration_client, +) + + +class administration_user_2fa_enabled(Check): + """Check if Linode account users have two-factor authentication enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the administration_user_2fa_enabled check. + + Iterates over all account users and checks whether two-factor + authentication is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each user. + """ + findings = [] + + for user in administration_client.users: + report = CheckReportLinode( + metadata=self.metadata(), + resource=user, + resource_name=user.username, + resource_id=user.username, + region="global", + ) + + if user.tfa_enabled: + report.status = "PASS" + report.status_extended = ( + f"User '{user.username}' has two-factor authentication enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"User '{user.username}' does not have two-factor authentication enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/__init__.py b/prowler/providers/linode/services/compute/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_client.py b/prowler/providers/linode/services/compute/compute_client.py new file mode 100644 index 0000000000..8dc03fa36d --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.linode.services.compute.compute_service import ComputeService + +compute_client = ComputeService(Provider.get_global_provider()) diff --git a/prowler/providers/linode/services/compute/compute_instance_backups_enabled/__init__.py b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json new file mode 100644 index 0000000000..b425e5c5b0 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "linode", + "CheckID": "compute_instance_backups_enabled", + "CheckTitle": "Linode Instance has the Backup service enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Linode instances** are assessed for enrollment in the **Linode Backup service**, which performs automatic daily and weekly snapshots of an instance's disks. Without it enabled, there is no managed recovery point to restore from after a data-loss event.", + "Risk": "With the **Backup service** disabled, there is no automated recovery point for the instance. Accidental deletion, **ransomware**, disk corruption, or operator error can result in **permanent data loss** and extended **downtime**, directly impacting **availability** and data **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/enable-backups", + "https://techdocs.akamai.com/cloud-computing/docs/create-a-compute-instance#configure-additional-options" + ], + "Remediation": { + "Code": { + "CLI": "linode-cli linodes backups-enable ", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Select the Linode instance\n3. Navigate to the Backups tab\n4. Click 'Enable Backups'", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the `Linode Backup service` on this instance to ensure automated recovery points are available. Consider supplementing it with manual snapshots before critical changes.", + "Url": "https://hub.prowler.com/check/compute_instance_backups_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py new file mode 100644 index 0000000000..c7ae903fbf --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py @@ -0,0 +1,40 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.compute.compute_client import compute_client + + +class compute_instance_backups_enabled(Check): + """Check if Linode instances have the Backup service enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the compute_instance_backups_enabled check. + + Iterates over all Linode instances and checks whether the Backup + service is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each instance. + """ + findings = [] + + for instance in compute_client.instances: + report = CheckReportLinode( + metadata=self.metadata(), + resource=instance, + resource_name=instance.label, + resource_id=str(instance.id), + region=instance.region, + ) + report.resource_tags = instance.tags + + if instance.backups_enabled: + report.status = "PASS" + report.status_extended = ( + f"Instance {instance.label} has the Backup service enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"Instance {instance.label} does not have the Backup service enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/__init__.py b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json new file mode 100644 index 0000000000..878030dc25 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "linode", + "CheckID": "compute_instance_disk_encryption_enabled", + "CheckTitle": "Linode Instance has disk encryption enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Linode instances** are assessed for **disk encryption** status. Disk encryption protects **data at rest** on the instance's underlying disks, keeping stored data unreadable without the encryption keys even if the physical media is accessed.", + "Risk": "Without **disk encryption**, data at rest on the instance's disks is stored unprotected. This increases the risk of **data exposure** through physical access to decommissioned or stolen storage media, or via certain **hypervisor-level** vulnerabilities, compromising the **confidentiality** of sensitive data.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/local-disk-encryption", + "https://techdocs.akamai.com/cloud-computing/docs/create-a-compute-instance#enable-or-disable-disk-encryption" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Disk encryption must be enabled at instance creation time or by rebuilding the instance\n2. Create a new instance with disk_encryption set to 'enabled'\n3. Migrate data from the unencrypted instance to the new encrypted instance", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `disk encryption` for this instance. Because it cannot be toggled on existing instances, rebuild or migrate to a new instance with encryption enabled.", + "Url": "https://hub.prowler.com/check/compute_instance_disk_encryption_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py new file mode 100644 index 0000000000..abe364d0b4 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.compute.compute_client import compute_client + + +class compute_instance_disk_encryption_enabled(Check): + """Check if Linode instances have disk encryption enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the compute_instance_disk_encryption_enabled check. + + Iterates over all Linode instances and checks whether disk encryption + is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each instance. + """ + findings = [] + + for instance in compute_client.instances: + report = CheckReportLinode( + metadata=self.metadata(), + resource=instance, + resource_name=instance.label, + resource_id=str(instance.id), + region=instance.region, + ) + report.resource_tags = instance.tags + + if instance.disk_encryption == "enabled": + report.status = "PASS" + report.status_extended = ( + f"Instance {instance.label} has disk encryption enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Instance {instance.label} does not have disk encryption enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/__init__.py b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json new file mode 100644 index 0000000000..42e3c8f46f --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "linode", + "CheckID": "compute_instance_watchdog_enabled", + "CheckTitle": "Linode Instance has Watchdog (Lassie) enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Linode instances** are assessed for **Watchdog (Lassie)** status. Watchdog is Linode's automatic recovery feature that monitors an instance and reboots it if it powers off unexpectedly, helping maintain availability without manual intervention.", + "Risk": "With **Watchdog (Lassie)** disabled, an instance that crashes or shuts down unexpectedly stays offline until it is manually restarted. This prolongs **downtime**, delays detection of availability incidents, and weakens the **availability** of services running on the instance.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/recover-from-unexpected-shutdowns-with-lassie" + ], + "Remediation": { + "Code": { + "CLI": "linode-cli linodes update --watchdog_enabled true", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Select the Linode instance\n3. Navigate to the Settings tab\n4. Enable the Shutdown Watchdog (Lassie) toggle", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `Watchdog (Lassie)` on this instance to automatically recover from unexpected shutdowns and improve availability protection.", + "Url": "https://hub.prowler.com/check/compute_instance_watchdog_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py new file mode 100644 index 0000000000..498deb970c --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py @@ -0,0 +1,40 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.compute.compute_client import compute_client + + +class compute_instance_watchdog_enabled(Check): + """Check if Linode instances have Watchdog (Lassie) enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the compute_instance_watchdog_enabled check. + + Iterates over all Linode instances and checks whether Watchdog + (Lassie) is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each instance. + """ + findings = [] + + for instance in compute_client.instances: + report = CheckReportLinode( + metadata=self.metadata(), + resource=instance, + resource_name=instance.label, + resource_id=str(instance.id), + region=instance.region, + ) + report.resource_tags = instance.tags + + if instance.watchdog_enabled: + report.status = "PASS" + report.status_extended = ( + f"Instance {instance.label} has Watchdog (Lassie) enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"Instance {instance.label} does not have Watchdog (Lassie) enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/compute_service.py b/prowler/providers/linode/services/compute/compute_service.py new file mode 100644 index 0000000000..51d38aa834 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_service.py @@ -0,0 +1,99 @@ +from typing import List + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.linode.lib.service.service import LinodeService + + +class Instance(BaseModel): + """Model for a Linode Instance.""" + + id: int + label: str + region: str + status: str + backups_enabled: bool = False + disk_encryption: str = "disabled" # "enabled" or "disabled" + watchdog_enabled: bool = False + tags: List[str] = [] + + +class ComputeService(LinodeService): + """Service to interact with Linode Instances.""" + + def __init__(self, provider): + super().__init__("compute", provider) + self.instances: List[Instance] = [] + self._describe_instances() + + def _describe_instances(self): + """Fetch all Linode instances with firewall and IP details.""" + # Optional --region filter. None scans all regions. Region-less services + # do not call this, so they are always scanned. + regions_filter = getattr(getattr(self, "provider", None), "regions", None) + try: + raw_instances = self.client.linode.instances() + for inst in raw_instances: + try: + region = ( + inst.region.id + if hasattr(inst.region, "id") + else str(inst.region) + ) + if regions_filter and region not in regions_filter: + continue + + # Get backup status + backups_enabled = False + try: + backups = getattr(inst, "backups", None) + if backups: + backups_enabled = getattr(backups, "enabled", False) + except Exception as error: + logger.warning( + f"instance - Unable to fetch backup status for instance " + f"{inst.id}: {error}" + ) + + # Get disk encryption status + disk_encryption = "disabled" + try: + de = getattr(inst, "disk_encryption", None) + if de: + disk_encryption = str(de) + except Exception as error: + logger.warning( + f"instance - Unable to fetch disk encryption status for " + f"instance {inst.id}: {error}" + ) + + # Get watchdog status + watchdog_enabled = False + try: + watchdog_enabled = getattr(inst, "watchdog_enabled", False) + except Exception as error: + logger.warning( + f"instance - Unable to fetch watchdog status for instance " + f"{inst.id}: {error}" + ) + + self.instances.append( + Instance( + id=inst.id, + label=inst.label or f"linode-{inst.id}", + region=region, + status=inst.status or "unknown", + backups_enabled=backups_enabled, + disk_encryption=disk_encryption, + watchdog_enabled=watchdog_enabled, + tags=inst.tags or [], + ) + ) + except Exception as error: + logger.error( + f"instance - Error processing instance {inst.id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self._log_fetch_error("instances", "linodes:read_only", error) diff --git a/prowler/providers/linode/services/networking/__init__.py b/prowler/providers/linode/services/networking/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_client.py b/prowler/providers/linode/services/networking/networking_client.py new file mode 100644 index 0000000000..0f0406048b --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.linode.services.networking.networking_service import ( + NetworkingService, +) + +networking_client = NetworkingService(Provider.get_global_provider()) diff --git a/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json new file mode 100644 index 0000000000..475601e5b3 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_assigned_to_devices", + "CheckTitle": "Linode Cloud Firewall is assigned to devices", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** are checked to verify each one is attached to at least one device, such as a **Linode instance** or **NodeBalancer**. A firewall only filters traffic for the resources it is assigned to, so an unassigned firewall enforces no protection at all.", + "Risk": "An **unassigned firewall** provides no protection to running workloads, leaving their network exposure governed only by default behavior. This commonly signals a **misconfigured control** where operators assume traffic is filtered when it is not, raising the risk of **unauthorized network access** to unprotected instances.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/apply-firewall-rules-to-a-service" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. Click on either the Linodes or Nodebalancers tab and add those devices to the firewall", + "Terraform": "" + }, + "Recommendation": { + "Text": "Attach each firewall to applicable Linode devices, such as `Linodes` or `NodeBalancers`, to enforce intended network controls.", + "Url": "https://hub.prowler.com/check/networking_firewall_assigned_to_devices" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_status_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py new file mode 100644 index 0000000000..f245dd168e --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py @@ -0,0 +1,52 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.lib.logger import logger +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_assigned_to_devices(Check): + """Check if Linode Cloud Firewalls are assigned to at least one device.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_assigned_to_devices check. + + Iterates over all Cloud Firewalls and checks whether each one is + assigned to at least one device. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + # When the device count could not be determined (the devices fetch + # failed) skip the firewall instead of reporting a false FAIL. + if fw.attached_devices_count is None: + logger.warning( + f"firewall - Skipping firewall '{fw.label}' ({fw.id}): " + "device assignment could not be determined." + ) + continue + + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.attached_devices_count > 0: + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' is assigned to {fw.attached_devices_count} device(s)." + else: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' is not assigned to any device." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json new file mode 100644 index 0000000000..16f0666f7d --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_default_inbound_policy_drop", + "CheckTitle": "Linode Cloud Firewall default inbound policy is DROP", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** apply a **default inbound policy** to ingress traffic that does not match any explicit rule. This check verifies the default is set to `DROP`, enforcing a **default-deny** posture so that only traffic intentionally permitted by a rule is allowed in.", + "Risk": "When the default inbound policy is `ACCEPT` instead of `DROP`, any traffic not explicitly denied by a rule is permitted. This **default-allow** posture can silently expose services, management ports, and unintended endpoints to the internet, enlarging the attack surface and increasing the risk of **unauthorized access**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. In the Rules tab, set the default inbound policy to DROP", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the default inbound policy to `DROP` and allow only explicitly required inbound traffic.", + "Url": "https://hub.prowler.com/check/networking_firewall_default_inbound_policy_drop" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_inbound_rules_configured" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py new file mode 100644 index 0000000000..2f5c8cb6c3 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_default_inbound_policy_drop(Check): + """Check if Linode Cloud Firewall default inbound policy is DROP.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_default_inbound_policy_drop check. + + Iterates over all Cloud Firewalls and checks whether the default + inbound policy is DROP. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.inbound_policy == "DROP": + report.status = "PASS" + report.status_extended = ( + f"Firewall '{fw.label}' has default inbound policy set to DROP." + ) + else: + report.status = "FAIL" + report.status_extended = f"Firewall '{fw.label}' has default inbound policy set to {fw.inbound_policy}." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json new file mode 100644 index 0000000000..5de3985464 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_default_outbound_policy_drop", + "CheckTitle": "Linode Cloud Firewall default outbound policy is DROP", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** apply a **default outbound policy** to egress traffic that matches no explicit rule. This check verifies the default is set to `DROP`, enforcing **default-deny egress** so that only approved outbound connections are allowed to leave the instance.", + "Risk": "An outbound default of `ACCEPT` permits unrestricted egress from the instance. If a host is compromised, this eases **data exfiltration** and **command-and-control (C2)** communication and lets malware reach arbitrary external endpoints, harming **confidentiality** and enabling lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. In the Rules tab, set the default outbound policy to DROP", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the default outbound policy to `DROP` and allow only explicit outbound destinations and services.", + "Url": "https://hub.prowler.com/check/networking_firewall_default_outbound_policy_drop" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_outbound_rules_configured" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py new file mode 100644 index 0000000000..011693aee9 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_default_outbound_policy_drop(Check): + """Check if Linode Cloud Firewall default outbound policy is DROP.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_default_outbound_policy_drop check. + + Iterates over all Cloud Firewalls and checks whether the default + outbound policy is DROP. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.outbound_policy == "DROP": + report.status = "PASS" + report.status_extended = ( + f"Firewall '{fw.label}' has default outbound policy set to DROP." + ) + else: + report.status = "FAIL" + report.status_extended = f"Firewall '{fw.label}' has default outbound policy set to {fw.outbound_policy}." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json new file mode 100644 index 0000000000..5e54dc6241 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_inbound_rules_configured", + "CheckTitle": "Linode Cloud Firewall inbound rules are configured", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** are checked to determine whether explicit **inbound rules** are configured. Defined ingress rules express the intended allow-list of sources, ports, and protocols instead of relying solely on the firewall's default policy.", + "Risk": "Without explicit **inbound rules**, ingress filtering depends entirely on the default policy and no intentional allow-list is documented. This makes the firewall's behavior ambiguous and harder to audit, raising the chance of **overly permissive access** or gaps in the protection operators expect.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. Click Add an Inbound Rule in the Rules tab", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure explicit `inbound rules` to define allowed ingress sources, ports, and protocols and enforce expected security boundaries.", + "Url": "https://hub.prowler.com/check/networking_firewall_inbound_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_default_inbound_policy_drop" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py new file mode 100644 index 0000000000..9eba9ddeb6 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_inbound_rules_configured(Check): + """Check if Linode Cloud Firewall has inbound rules configured.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_inbound_rules_configured check. + + Iterates over all Cloud Firewalls and checks whether at least one + explicit inbound rule is configured. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if len(fw.inbound_rules) == 0: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' has no inbound rules configured." + ) + else: + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' has {len(fw.inbound_rules)} inbound rule(s) configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json new file mode 100644 index 0000000000..9bedf854cc --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_outbound_rules_configured", + "CheckTitle": "Linode Cloud Firewall outbound rules are configured", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** are checked to determine whether explicit **outbound rules** are configured. Defined egress rules express which destinations, ports, and protocols an instance is permitted to reach, rather than depending only on the firewall's default policy.", + "Risk": "Without explicit **outbound rules**, egress behavior depends solely on the default policy and no intended destination allow-list exists. Unconstrained or undocumented egress complicates auditing and can enable **data exfiltration** or connections to malicious endpoints if a host is compromised.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. Click Add an Outbound Rule in the Rules tab", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure explicit `outbound rules` to control allowed egress destinations, ports, and protocols.", + "Url": "https://hub.prowler.com/check/networking_firewall_outbound_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_default_outbound_policy_drop" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py new file mode 100644 index 0000000000..1d97dca075 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_outbound_rules_configured(Check): + """Check if Linode Cloud Firewall has outbound rules configured.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_outbound_rules_configured check. + + Iterates over all Cloud Firewalls and checks whether at least one + explicit outbound rule is configured. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if len(fw.outbound_rules) == 0: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' has no outbound rules configured." + ) + else: + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' has {len(fw.outbound_rules)} outbound rule(s) configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_status_enabled/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json new file mode 100644 index 0000000000..dd1e760d4c --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_status_enabled", + "CheckTitle": "Linode Cloud Firewall status is enabled", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** can be in an `enabled` or `disabled` state. This check verifies the firewall status is `enabled`, because a firewall must be active for its inbound and outbound policies and rules to actually filter network traffic.", + "Risk": "A `disabled` firewall enforces **none** of its configured rules or default policies, leaving attached instances with no network filtering. This can unexpectedly expose management ports and services to the internet, significantly increasing the risk of **unauthorized access** and exploitation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/update-cloud-firewall-status" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Click on the Enable button from the options corresponding to the firewall whose status you would like to update", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `Linode Cloud Firewalls` so configured network filtering controls are actively enforced.", + "Url": "https://hub.prowler.com/check/networking_firewall_status_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_assigned_to_devices" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py new file mode 100644 index 0000000000..603a088bd0 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_status_enabled(Check): + """Check if Linode Cloud Firewalls are enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_status_enabled check. + + Iterates over all Cloud Firewalls and checks whether each one has + an enabled status. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.status == "enabled": + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' is enabled." + else: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' is not enabled (status: {fw.status})." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_service.py b/prowler/providers/linode/services/networking/networking_service.py new file mode 100644 index 0000000000..97f811a98a --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_service.py @@ -0,0 +1,127 @@ +from typing import List, Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.linode.lib.service.service import LinodeService + + +class FirewallRule(BaseModel): + """Model for a single firewall rule.""" + + protocol: str = "TCP" + ports: str = "" # e.g. "22", "1-65535", "" + addresses_ipv4: List[str] = [] + addresses_ipv6: List[str] = [] + action: str = "ACCEPT" # ACCEPT or DROP + label: str = "" + + +class Firewall(BaseModel): + """Model for a Linode Cloud Firewall.""" + + id: int + label: str + status: str + inbound_rules: List[FirewallRule] = [] + outbound_rules: List[FirewallRule] = [] + inbound_policy: str + outbound_policy: str + # None means the device count could not be determined (fetch failed), as + # opposed to 0 which means the firewall genuinely has no devices attached. + attached_devices_count: Optional[int] = None + tags: List[str] = [] + + +class NetworkingService(LinodeService): + """Service to interact with Linode Cloud Firewalls.""" + + def __init__(self, provider): + super().__init__("networking", provider) + self.firewalls: List[Firewall] = [] + self._describe_firewalls() + + def _describe_firewalls(self): + """Fetch all Linode Cloud Firewalls with their rules.""" + try: + raw_firewalls = self.client.networking.firewalls() + for fw in raw_firewalls: + try: + inbound_rules = [] + outbound_rules = [] + inbound_policy = "" + outbound_policy = "" + attached_devices_count = None + + try: + attached_devices_count = len(fw.devices) + except Exception as error: + logger.warning( + f"firewall - Unable to fetch devices for firewall {fw.id}: {error}" + ) + + try: + # linode_api4 Firewall objects expose rules as a mapped object. + rules = fw.rules + inbound_policy = getattr(rules, "inbound_policy", "") + outbound_policy = getattr(rules, "outbound_policy", "") + inbound = getattr(rules, "inbound", []) + outbound = getattr(rules, "outbound", []) + + for rule in inbound: + addresses = getattr(rule, "addresses", None) + inbound_rules.append( + FirewallRule( + protocol=( + getattr(rule, "protocol", None) or "TCP" + ).upper(), + ports=getattr(rule, "ports", "") or "", + addresses_ipv4=getattr(addresses, "ipv4", []) or [], + addresses_ipv6=getattr(addresses, "ipv6", []) or [], + action=( + getattr(rule, "action", None) or "ACCEPT" + ).upper(), + label=getattr(rule, "label", "") or "", + ) + ) + for rule in outbound: + addresses = getattr(rule, "addresses", None) + outbound_rules.append( + FirewallRule( + protocol=( + getattr(rule, "protocol", None) or "TCP" + ).upper(), + ports=getattr(rule, "ports", "") or "", + addresses_ipv4=getattr(addresses, "ipv4", []) or [], + addresses_ipv6=getattr(addresses, "ipv6", []) or [], + action=( + getattr(rule, "action", None) or "ACCEPT" + ).upper(), + label=getattr(rule, "label", "") or "", + ) + ) + except Exception as error: + logger.warning( + f"firewall - Unable to fetch rules for firewall {fw.id}: {error}" + ) + + self.firewalls.append( + Firewall( + id=fw.id, + label=fw.label or f"firewall-{fw.id}", + status=fw.status or "unknown", + inbound_rules=inbound_rules, + outbound_rules=outbound_rules, + inbound_policy=inbound_policy, + outbound_policy=outbound_policy, + attached_devices_count=attached_devices_count, + tags=fw.tags or [], + ) + ) + except Exception as error: + logger.error( + f"firewall - Error processing firewall {fw.id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self._log_fetch_error("firewalls", "firewall:read_only", error) diff --git a/pyproject.toml b/pyproject.toml index f3cec52678..37bb1b003a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ dependencies = [ "google-auth-httplib2==0.2.0", "jsonschema==4.23.0", "kubernetes==32.0.1", + "linode-api4==5.45.0", "markdown==3.10.2", "microsoft-kiota-abstractions==1.9.9", "numpy==2.2.6", @@ -124,7 +125,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.14" -version = "5.31.0" +version = "5.32.0" [project.scripts] prowler = "prowler.__main__:prowler" @@ -218,6 +219,7 @@ constraint-dependencies = [ "coverage==7.6.12", "darabonba-core==1.0.5", "decorator==5.2.1", + "deprecated==1.3.1", "dill==0.4.1", "distro==1.9.0", "dnspython==2.8.0", @@ -300,6 +302,7 @@ constraint-dependencies = [ "platformdirs==4.9.6", "plotly==6.7.0", "pluggy==1.6.0", + "polling==0.3.2", "prek==0.3.9", "propcache==0.5.2", "proto-plus==1.28.0", diff --git a/scripts/development/dev-local.sh b/scripts/development/dev-local.sh new file mode 100755 index 0000000000..d193b81532 --- /dev/null +++ b/scripts/development/dev-local.sh @@ -0,0 +1,558 @@ +#!/usr/bin/env bash +# +# Local dev for Prowler API + worker. +# Postgres / Valkey / Neo4j run in Docker via docker-compose-dev.yml; +# Django and Celery run natively. +# +# Quick start: +# make dev-setup first-time bootstrap (deps, migrations, fixtures) +# make dev launch api + worker + postgres logs in tmux +# make dev-attach attach to the tmux session in the current terminal pane +# make dev-launch use fixed ports and attach interactive local dev +# make dev-stop stop everything: tmux + stop + remove containers +# make dev-clean remove stopped containers only (data preserved) +# make dev-wipe full nuke: kill + clean + delete ./_data/ +# +# Agent / non-interactive usage: 'make dev' is idempotent, runs +# everything detached, blocks until the API answers HTTP, and ends with +# parseable key=value lines (api_url=, api_log=, worker_log=, +# postgres_log=, attach_cmd=, stop_cmd=). +# Every pane is teed ANSI-stripped to _data/logs/{api,worker,postgres}.log +# (truncated per run), so logs are readable with tail -f, no tmux needed. +# +# 'attach' attaches to tmux inside the current terminal pane. +# +# Inside the tmux session "prowler-dev-" the prefix key is Ctrl+b. After it: +# d detach (everything keeps running; reattach: make dev-attach) +# move between panes +# z zoom current pane (toggle) +# [ scrollback mode (q to exit) +# x kill current pane (asks for confirmation) +# & kill current window +# :kill-session end the whole session +# +# How to stop everything from inside tmux: +# 1) Ctrl+b then d detach back to your shell +# 2) make dev-stop tears down tmux + containers +# Alternative without detaching: open a new tmux window with Ctrl+b then c +# and run make dev-stop there; the session will close itself. +# +# Stop just the python procs (keep containers up to skip a slow neo4j boot next time): +# ./scripts/development/dev-local.sh down +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$REPO_ROOT" + +path_hash() { + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$REPO_ROOT" | shasum | awk '{print substr($1, 1, 8)}' + else + printf '%s' "$REPO_ROOT" | cksum | awk '{print $1}' + fi +} + +compose_project_default() { + local base hash + base="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]+/-/g; s/^-+//; s/-+$//')" + hash="$(path_hash)" + printf '%s-%s' "${base:-prowler}" "$hash" +} + +export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$(compose_project_default)}" + +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a +fi + +export POSTGRES_HOST=localhost +export POSTGRES_PORT="${POSTGRES_PORT:-5432}" +export VALKEY_HOST=localhost +export VALKEY_PORT="${VALKEY_PORT:-6379}" +export VALKEY_SCHEME="${VALKEY_SCHEME:-redis}" +export NEO4J_HOST=localhost +export NEO4J_PORT="${NEO4J_PORT:-7687}" +export NEO4J_HTTP_PORT="${NEO4J_HTTP_PORT:-7474}" +export DJANGO_SETTINGS_MODULE=config.django.devel +export DJANGO_DEBUG="${DJANGO_DEBUG:-True}" +export DJANGO_PORT="${DJANGO_PORT:-8080}" +export DJANGO_LOGGING_FORMATTER="${DJANGO_LOGGING_FORMATTER:-human_readable}" +export DJANGO_LOGGING_LEVEL="${DJANGO_LOGGING_LEVEL:-info}" +export DJANGO_MANAGE_DB_PARTITIONS="${DJANGO_MANAGE_DB_PARTITIONS:-False}" + +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +else + COMPOSE=(docker-compose) +fi +COMPOSE_FILE="docker-compose-dev.yml" + +log() { printf '\033[1;34m→\033[0m %s\n' "$*"; } +ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!\033[0m %s\n' "$*"; } + +require_uv() { + if ! command -v uv >/dev/null 2>&1; then + warn "uv not found in PATH. Install: https://docs.astral.sh/uv/getting-started/installation/" + exit 1 + fi +} + +services_up() { + log "Starting postgres + valkey + neo4j via Docker (waits for healthchecks)..." + "${COMPOSE[@]}" -f "$COMPOSE_FILE" up -d --wait postgres valkey neo4j + ok "Services ready: pg:${POSTGRES_PORT} / valkey:${VALKEY_PORT} / neo4j:${NEO4J_PORT} / neo4j-http:${NEO4J_HTTP_PORT}" +} + +kill_tmux_session() { + command -v tmux >/dev/null 2>&1 || return 0 + if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + log "Killing tmux session '$TMUX_SESSION'" + tmux kill-session -t "$TMUX_SESSION" + fi +} + +tmux_pane_count() { + tmux list-panes -t "$TMUX_SESSION:" 2>/dev/null | wc -l | tr -d ' ' +} + +# Tee a tmux pane's output to _data/logs/.log so non-interactive +# consumers (scripts, agents) can follow the stack without attaching. +# ANSI codes are stripped; the file is truncated on session creation so +# its content always matches the current run. +pipe_pane_log() { + local pane="$1" name="$2" + local logfile="$REPO_ROOT/_data/logs/$name.log" + : > "$logfile" + tmux pipe-pane -t "$pane" -o "perl -pe 'BEGIN{\$|=1} s/\\e\\[[0-9;?]*[A-Za-z]//g' >> '$logfile'" +} + +# Stop native dev processes (api/worker/beat) launched outside tmux. Scoped by +# cwd under REPO_ROOT so other prowler clones running their own stacks are not +# touched. +kill_native_procs() { + command -v pgrep >/dev/null 2>&1 || return 0 + command -v lsof >/dev/null 2>&1 || return 0 + + local pids="" pid pcwd pat + for pat in "manage.py runserver" "celery -A config.celery worker" "celery -A config.celery beat"; do + while IFS= read -r pid; do + [ -n "$pid" ] || continue + pcwd="$(lsof -a -d cwd -p "$pid" 2>/dev/null | awk 'NR>1 {print $NF; exit}')" + case "$pcwd" in + "$REPO_ROOT"|"$REPO_ROOT"/*) pids="$pids $pid" ;; + esac + done < <(pgrep -f "$pat" 2>/dev/null) + done + + pids="$(printf '%s\n' "$pids" | tr ' ' '\n' | sed '/^$/d' | sort -u | xargs)" + [ -z "$pids" ] && return 0 + + log "Stopping native dev procs ($pids) under $REPO_ROOT" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 0.5 + # shellcheck disable=SC2086 + kill -KILL $pids 2>/dev/null || true +} + +services_down() { + kill_tmux_session + kill_native_procs + log "Stopping postgres + valkey + neo4j..." + "${COMPOSE[@]}" -f "$COMPOSE_FILE" stop postgres valkey neo4j +} + +services_status() { + "${COMPOSE[@]}" -f "$COMPOSE_FILE" ps postgres valkey neo4j +} + +deps() { + require_uv + log "uv sync (api/)..." + (cd api && uv sync) +} + +migrate() { + require_uv + log "Applying migrations (admin DB)..." + ( + cd api/src/backend + uv run python manage.py check_and_fix_socialaccount_sites_migration --database admin + uv run python manage.py migrate --database admin + ) + ok "Migrations applied" +} + +fixtures() { + require_uv + log "Loading dev fixtures..." + ( + cd api/src/backend + for fixture in api/fixtures/dev/*.json; do + [ -f "$fixture" ] || continue + echo " loading $(basename "$fixture")" + uv run python manage.py loaddata "$fixture" --database admin + done + ) + ok "Fixtures loaded" +} + +api_run() { + require_uv + log "Starting Django API on [::]:${DJANGO_PORT} (IPv4 + IPv6 dual-stack)" + cd api/src/backend + exec uv run python manage.py runserver "[::]:${DJANGO_PORT}" +} + +worker_run() { + require_uv + log "Starting Celery worker" + cd api/src/backend + exec uv run python -m celery -A config.celery worker \ + -l "${DJANGO_LOGGING_LEVEL}" \ + -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \ + -E +} + +beat_run() { + require_uv + log "Starting Celery beat (DatabaseScheduler)" + cd api/src/backend + exec uv run python -m celery -A config.celery beat \ + -l "${DJANGO_LOGGING_LEVEL}" \ + --scheduler django_celery_beat.schedulers:DatabaseScheduler +} + +TMUX_SESSION="prowler-dev-${COMPOSE_PROJECT_NAME}" +SCRIPT_PATH="$REPO_ROOT/scripts/development/dev-local.sh" + +require_tmux() { + if ! command -v tmux >/dev/null 2>&1; then + warn "tmux not installed. Run: brew install tmux" + exit 1 + fi +} + +remove_repo_compose_containers() { + command -v docker >/dev/null 2>&1 || return 0 + local ids + ids="$( + docker ps -aq 2>/dev/null \ + | while IFS= read -r id; do + docker inspect --format '{{.ID}} {{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$id" 2>/dev/null || true + done \ + | awk -v repo="$REPO_ROOT" '$2 == repo {print $1}' + )" + [ -n "$ids" ] || return 0 + warn "Removing existing Docker containers for this repo before launch" + # shellcheck disable=SC2086 + docker rm -f $ids >/dev/null +} + +remove_docker_containers_on_ports() { + command -v docker >/dev/null 2>&1 || return 0 + local all_ids ids_to_remove="" id port published_ports + all_ids="$(docker ps -aq 2>/dev/null || true)" + [ -n "$all_ids" ] || return 0 + + for id in $all_ids; do + published_ports="$(docker inspect --format '{{range $containerPort, $bindings := .HostConfig.PortBindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}' "$id" 2>/dev/null || true)" + [ -n "$published_ports" ] || continue + for port in "$@"; do + if printf '%s\n' "$published_ports" | grep -qx "$port"; then + ids_to_remove="$ids_to_remove $id" + break + fi + done + done + + ids_to_remove="$(printf '%s\n' "$ids_to_remove" | tr ' ' '\n' | sed '/^$/d' | sort -u | xargs)" + [ -n "$ids_to_remove" ] || return 0 + warn "Removing Docker containers publishing fixed dev ports: $ids_to_remove" + # shellcheck disable=SC2086 + docker rm -f $ids_to_remove >/dev/null +} + +kill_listeners_on_ports() { + command -v lsof >/dev/null 2>&1 || return 0 + local pids="" port port_pids + for port in "$@"; do + port_pids="$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)" + [ -n "$port_pids" ] && pids="$pids $port_pids" + done + + pids="$(printf '%s\n' "$pids" | tr ' ' '\n' | sed '/^$/d' | sort -u | xargs)" + [ -n "$pids" ] || return 0 + warn "Killing local processes listening on fixed dev ports: $pids" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 0.5 + # shellcheck disable=SC2086 + kill -KILL $pids 2>/dev/null || true +} + +clear_dev_port_conflicts() { + local ports=("$DJANGO_PORT" "$POSTGRES_PORT" "$VALKEY_PORT" "$NEO4J_PORT" "$NEO4J_HTTP_PORT") + log "Ensuring fixed dev ports are free: api:${DJANGO_PORT} pg:${POSTGRES_PORT} valkey:${VALKEY_PORT} neo4j:${NEO4J_PORT} neo4j-http:${NEO4J_HTTP_PORT}" + remove_docker_containers_on_ports "${ports[@]}" + kill_listeners_on_ports "${ports[@]}" +} + +# Block until the API answers HTTP on DJANGO_PORT (any status code counts). +wait_for_api() { + local timeout="${1:-90}" waited=0 + log "Waiting for API on :${DJANGO_PORT} (timeout ${timeout}s)..." + until curl -s -o /dev/null --max-time 2 "http://localhost:${DJANGO_PORT}/"; do + waited=$((waited + 1)) + if [ "$waited" -ge "$timeout" ]; then + warn "API not responding after ${timeout}s. Check _data/logs/api.log" + return 1 + fi + sleep 1 + done + ok "API responding on :${DJANGO_PORT}" +} + +needs_db_bootstrap() { + ! "${COMPOSE[@]}" -f "$COMPOSE_FILE" exec -T postgres \ + psql -U prowler -d prowler_db -c 'select 1' >/dev/null 2>&1 +} + +bootstrap_db_if_needed() { + if needs_db_bootstrap; then + warn "App DB user not ready. Bootstrapping (deps + migrate + fixtures)..." + deps + migrate + fixtures + ok "DB bootstrap complete" + fi +} + +all_run() { + require_tmux + require_uv + services_up + bootstrap_db_if_needed + + # Wrap a command so it auto-runs as the pane's foreground process and, on + # exit (e.g. Ctrl+C), drops into the user's login shell instead of closing. + # Avoids the `send-keys` race where keys arrive before zsh+starship are ready. + local user_shell="${SHELL:-/bin/zsh}" + pane_cmd() { + printf 'bash -c %q' "$1; exec ${user_shell}" + } + local api_cmd worker_cmd pg_cmd + api_cmd="$(pane_cmd "$SCRIPT_PATH api")" + worker_cmd="$(pane_cmd "$SCRIPT_PATH worker")" + pg_cmd="$(pane_cmd "${COMPOSE[*]} -f $COMPOSE_FILE logs -f postgres")" + + # If a tmux server is already running (e.g. another project's session), new + # sessions inherit THAT server's env, not the launcher shell's env. Pass our + # overrides explicitly so each pane sees the right project name, ports, etc. + local -a env_args=() + local var + for var in COMPOSE_PROJECT_NAME \ + POSTGRES_HOST POSTGRES_PORT \ + VALKEY_HOST VALKEY_PORT VALKEY_SCHEME \ + NEO4J_HOST NEO4J_PORT NEO4J_HTTP_PORT \ + DJANGO_SETTINGS_MODULE DJANGO_DEBUG DJANGO_PORT \ + DJANGO_LOGGING_FORMATTER DJANGO_LOGGING_LEVEL \ + DJANGO_MANAGE_DB_PARTITIONS; do + env_args+=(-e "${var}=${!var-}") + done + + local expected_panes=3 + + if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + local current_panes + current_panes="$(tmux_pane_count)" + if [ "$current_panes" -lt "$expected_panes" ]; then + warn "Session '$TMUX_SESSION' has $current_panes pane(s), expected $expected_panes. Rebuilding it." + tmux kill-session -t "$TMUX_SESSION" + else + log "Session '$TMUX_SESSION' already exists" + fi + fi + + if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + log "Creating tmux session '$TMUX_SESSION' (api / worker / db logs)" + tmux new-session -d -s "$TMUX_SESSION" -n services -c "$REPO_ROOT" "${env_args[@]}" "$api_cmd" + tmux split-window -t "$TMUX_SESSION:0.0" -v -p 50 -c "$REPO_ROOT" "${env_args[@]}" "$worker_cmd" + tmux split-window -t "$TMUX_SESSION:0.1" -h -p 50 -c "$REPO_ROOT" "${env_args[@]}" "$pg_cmd" + tmux select-pane -t "$TMUX_SESSION:0.0" + tmux set-option -t "$TMUX_SESSION" -g mouse on >/dev/null + tmux set-option -t "$TMUX_SESSION" -g bell-action none >/dev/null + tmux set-option -t "$TMUX_SESSION" -g visual-bell off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g monitor-bell off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g monitor-activity off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g visual-activity off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g activity-action none >/dev/null + tmux set-option -t "$TMUX_SESSION" -g silence-action none >/dev/null + tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy" 2>/dev/null + tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy" 2>/dev/null + tmux set-option -t "$TMUX_SESSION" -g status-right " API:${DJANGO_PORT} | DB:${POSTGRES_PORT} | Broker:${VALKEY_PORT} " >/dev/null + + mkdir -p "$REPO_ROOT/_data/logs" + pipe_pane_log "$TMUX_SESSION:0.0" api + pipe_pane_log "$TMUX_SESSION:0.1" worker + pipe_pane_log "$TMUX_SESSION:0.2" postgres + fi + + wait_for_api 90 + + ok "Dev stack ready (detached)" + cat </dev/null; then + warn "No session '$TMUX_SESSION'. Starting it with: $0 all" + all_run + else + local current_panes + current_panes="$(tmux_pane_count)" + if [ "$current_panes" -lt 3 ]; then + warn "Session '$TMUX_SESSION' only has $current_panes pane(s). Rebuilding with db logs." + tmux kill-session -t "$TMUX_SESSION" + all_run + fi + fi + if [ -n "${TMUX:-}" ]; then + tmux switch-client -t "$TMUX_SESSION" + else + tmux attach-session -t "$TMUX_SESSION" + fi +} + +clean_run() { + log "Removing stopped containers from the compose project..." + "${COMPOSE[@]}" -f "$COMPOSE_FILE" rm -f + ok "Stopped containers removed (data volumes under ./_data/ untouched)" +} + +wipe_run() { + warn "DESTRUCTIVE: this will tear down everything AND delete ./_data/" + warn " postgres database, valkey state, neo4j graph, api jwt keys - all gone." + warn " Next start will require 'make dev-setup' from scratch." + kill_run + if [ -d ./_data ]; then + log "Removing ./_data/ ..." + rm -rf ./_data + fi + ok "Wipe complete. Run 'make dev-setup' for a fresh environment." +} + +kill_run() { + kill_tmux_session + services_down + clean_run + ok "Dev stack stopped (tmux killed, containers stopped + removed)" +} + +launch_run() { + require_uv + kill_tmux_session + remove_repo_compose_containers + clear_dev_port_conflicts + + log "Launching dev stack in tmux inside the current terminal" + log " api:${DJANGO_PORT} pg:${POSTGRES_PORT} valkey:${VALKEY_PORT} neo4j:${NEO4J_PORT} neo4j-http:${NEO4J_HTTP_PORT}" + all_run + attach_run +} + +setup() { + services_up + deps + migrate + fixtures + ok "Setup complete." + cat < + +One-window dev (tmux inside the terminal): + all api + worker + postgres logs in 3 panes (detached, + blocks until API responds, + ends with parseable api_url= / *_log= / attach_cmd= / stop_cmd= lines) + attach Reattach to the existing dev session + Attaches tmux inside the current terminal pane. + Pane output is also written to _data/logs/{api,worker,postgres}.log + (ANSI-stripped, truncated per run) - usable without attaching + kill Stop tmux + stop containers + remove them (full teardown) + launch Use fixed ports, clear conflicts, then run 'all' and attach tmux + +Platform support: + macOS Supported + Linux Should work when Docker, tmux, and uv are available + Windows Requires script changes before it can be supported + +State (containers postgres + valkey + neo4j): + up Start postgres + valkey + neo4j (waits for healthchecks) + down Stop tmux + postgres + valkey + neo4j (keeps containers around) + status Show container status + clean Remove all stopped containers in the project (data volumes preserved) + wipe Full nuke: kill + clean + delete ./_data/ + +Python (native, foreground - usually launched via 'all'): + api Run Django API (runserver) + worker Run Celery worker + beat Run Celery beat scheduler + +One-shots: + setup up + deps + migrate + fixtures (first-time / fresh DB) + deps uv sync inside api/ + migrate Apply migrations to admin DB + fixtures Load dev fixtures + +Typical flow: + make dev-setup # first time only + make dev # daily dev + make dev-stop # when done +EOF +} + +case "${1:-help}" in + all) shift; if [ "$#" -gt 0 ]; then warn "'all $*' is not supported. Use: $0 all"; exit 1; fi; all_run ;; + attach) attach_run ;; + kill) kill_run ;; + launch) launch_run ;; + clean) clean_run ;; + wipe) wipe_run ;; + up) services_up ;; + down) services_down ;; + status) services_status ;; + api) api_run ;; + worker) worker_run ;; + beat) beat_run ;; + setup) setup ;; + deps) deps ;; + migrate) migrate ;; + fixtures) fixtures ;; + help|-h|--help|*) usage ;; +esac diff --git a/tests/config/schema/__init__.py b/tests/config/schema/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/config/schema/aws_schema_test.py b/tests/config/schema/aws_schema_test.py new file mode 100644 index 0000000000..ad08e84e3b --- /dev/null +++ b/tests/config/schema/aws_schema_test.py @@ -0,0 +1,222 @@ +"""AWS-specific schema coverage — the biggest provider, with the richest +constraint surface (CIDRs, account IDs, port ranges, enums, thresholds).""" + +import pytest + +from prowler.config.schema.aws import AWSProviderConfig +from prowler.config.schema.validator import validate_provider_config + + +def _validate(raw): + return validate_provider_config("aws", raw, AWSProviderConfig) + + +class Test_AWS_Threat_Detection_Thresholds: + """All threat detection thresholds are documented as fractions in 0..1. + The biggest risk of mistyping them is silently disabling the check.""" + + @pytest.mark.parametrize( + "key", + [ + "threat_detection_privilege_escalation_threshold", + "threat_detection_enumeration_threshold", + "threat_detection_llm_jacking_threshold", + ], + ) + def test_valid_boundary_values(self, key): + assert _validate({key: 0.0}) == {key: 0.0} + assert _validate({key: 1.0}) == {key: 1.0} + assert _validate({key: 0.5}) == {key: 0.5} + + @pytest.mark.parametrize( + "key", + [ + "threat_detection_privilege_escalation_threshold", + "threat_detection_enumeration_threshold", + "threat_detection_llm_jacking_threshold", + ], + ) + def test_invalid_values_are_dropped(self, key): + # 20 instead of 0.2 — would never trigger + assert _validate({key: 20}) == {} + # negative + assert _validate({key: -0.1}) == {} + # string + assert _validate({key: "high"}) == {} + + +class Test_AWS_Trusted_Account_Ids: + def test_valid_twelve_digit_ids(self): + ids = ["123456789012", "098765432109"] + assert _validate({"trusted_account_ids": ids}) == {"trusted_account_ids": ids} + + def test_empty_list_is_valid(self): + assert _validate({"trusted_account_ids": []}) == {"trusted_account_ids": []} + + def test_short_id_is_dropped(self): + assert _validate({"trusted_account_ids": ["12345"]}) == {} + + def test_non_numeric_id_is_dropped(self): + assert _validate({"trusted_account_ids": ["1234abcd5678"]}) == {} + + def test_id_with_dashes_is_dropped(self): + # Some users format account IDs as "1234-5678-9012" + assert _validate({"trusted_account_ids": ["1234-5678-9012"]}) == {} + + +class Test_AWS_Trusted_Ips: + def test_single_ipv4_address(self): + assert _validate({"trusted_ips": ["1.2.3.4"]}) == {"trusted_ips": ["1.2.3.4"]} + + def test_ipv4_cidr(self): + assert _validate({"trusted_ips": ["10.0.0.0/8"]}) == { + "trusted_ips": ["10.0.0.0/8"] + } + + def test_ipv6_address(self): + assert _validate({"trusted_ips": ["2001:db8::1"]}) == { + "trusted_ips": ["2001:db8::1"] + } + + def test_ipv6_cidr(self): + assert _validate({"trusted_ips": ["2001:db8::/32"]}) == { + "trusted_ips": ["2001:db8::/32"] + } + + def test_mixed_list(self): + ips = ["1.2.3.4", "10.0.0.0/8", "2001:db8::1"] + assert _validate({"trusted_ips": ips}) == {"trusted_ips": ips} + + def test_garbage_entry_is_dropped(self): + assert _validate({"trusted_ips": ["definitely-not-an-ip"]}) == {} + + def test_cidr_with_host_bits_is_accepted(self): + # We use strict=False so "10.0.0.5/8" is accepted. This matches the + # behaviour of most security tools and avoids surprising users who + # paste real-world allowlists with non-canonical CIDR notation. + assert _validate({"trusted_ips": ["10.0.0.5/8"]}) == { + "trusted_ips": ["10.0.0.5/8"] + } + + +class Test_AWS_Ports: + def test_valid_ports_in_range(self): + ports = [25, 80, 443, 65535, 1] + assert _validate({"ec2_high_risk_ports": ports}) == { + "ec2_high_risk_ports": ports + } + + def test_port_zero_is_dropped(self): + # Port 0 is reserved and not a valid security signal. + assert _validate({"ec2_high_risk_ports": [0]}) == {} + + def test_out_of_range_port_is_dropped(self): + assert _validate({"ec2_high_risk_ports": [70000]}) == {} + + def test_negative_port_is_dropped(self): + assert _validate({"ec2_high_risk_ports": [-1]}) == {} + + +class Test_AWS_Enums: + @pytest.mark.parametrize("level", ["CRITICAL", "HIGH", "MEDIUM", "LOW"]) + def test_valid_severity_levels(self, level): + assert _validate({"ecr_repository_vulnerability_minimum_severity": level}) == { + "ecr_repository_vulnerability_minimum_severity": level + } + + @pytest.mark.parametrize("level", ["critical", "Medium", "ANY", "", "X"]) + def test_invalid_severity_levels_are_dropped(self, level): + assert _validate({"ecr_repository_vulnerability_minimum_severity": level}) == {} + + +class Test_AWS_Detect_Secrets_Plugins: + def test_plugin_without_limit(self): + out = _validate({"detect_secrets_plugins": [{"name": "AWSKeyDetector"}]}) + assert out == {"detect_secrets_plugins": [{"name": "AWSKeyDetector"}]} + + def test_plugin_with_limit(self): + out = _validate( + { + "detect_secrets_plugins": [ + {"name": "Base64HighEntropyString", "limit": 6.0} + ] + } + ) + assert out == { + "detect_secrets_plugins": [ + {"name": "Base64HighEntropyString", "limit": 6.0} + ] + } + + def test_plugin_missing_name_drops_whole_field(self): + # ``name`` is required by the upstream library. + out = _validate({"detect_secrets_plugins": [{"limit": 6.0}]}) + assert out == {} + + def test_extra_plugin_kwargs_pass_through(self): + # Plugins can have arbitrary extra params (extra="allow" on the + # nested model). They must round-trip. + out = _validate( + { + "detect_secrets_plugins": [ + {"name": "Custom", "my_param": "abc", "other": 42} + ] + } + ) + assert out == { + "detect_secrets_plugins": [ + {"name": "Custom", "my_param": "abc", "other": 42} + ] + } + + +class Test_AWS_Booleans: + @pytest.mark.parametrize( + "key", + [ + "mute_non_default_regions", + "verify_premium_support_plans", + "check_rds_instance_replicas", + ], + ) + def test_true_and_false_round_trip(self, key): + assert _validate({key: True}) == {key: True} + assert _validate({key: False}) == {key: False} + + def test_yaml_style_boolean_coercion(self): + # YAML can produce Python str "true"/"yes" if the user quoted it. + # Pydantic v2 deterministically coerces "yes"/"no"/"true"/"false" to a + # real bool in lax mode, so the value is normalized rather than passed + # through as a string (which would be dangerous for + # verify_premium_support_plans). + out = _validate({"verify_premium_support_plans": "yes"}) + assert "verify_premium_support_plans" in out + assert isinstance(out["verify_premium_support_plans"], bool) + assert out["verify_premium_support_plans"] is True + + +class Test_AWS_Full_Default_Config_Round_Trips: + """Loading the real shipped defaults through the schema must produce + exactly the same dict. This is the regression sentinel for backwards + compatibility.""" + + def test_full_default_config_round_trip(self): + # Subset that mirrors the shipped config.yaml semantics. + raw = { + "mute_non_default_regions": False, + "disallowed_regions": ["me-south-1", "me-central-1"], + "max_unused_access_keys_days": 45, + "max_ec2_instance_age_in_days": 180, + "trusted_account_ids": [], + "trusted_ips": [], + "ecr_repository_vulnerability_minimum_severity": "MEDIUM", + "threat_detection_privilege_escalation_threshold": 0.2, + "threat_detection_enumeration_threshold": 0.3, + "threat_detection_llm_jacking_threshold": 0.4, + "ec2_high_risk_ports": [25, 110, 8088], + "detect_secrets_plugins": [ + {"name": "AWSKeyDetector"}, + {"name": "Base64HighEntropyString", "limit": 6.0}, + ], + } + assert _validate(raw) == raw diff --git a/tests/config/schema/bounds_test.py b/tests/config/schema/bounds_test.py new file mode 100644 index 0000000000..0e5cad6056 --- /dev/null +++ b/tests/config/schema/bounds_test.py @@ -0,0 +1,398 @@ +"""Boundary tests for the safety bounds added on top of the upstream schemas. + +Each parametrised case checks (a) the min and max values are accepted and +(b) one step outside the range is rejected. Custom validators (semver, +EKS minor, dotted version, port range, account IDs, IPs) get focused +positive/negative tests. + +Tests use the public adapter ``prowler.config.scan_config_schema``: a +schema violation surfaces as a list of ``{"path", "message"}`` entries. +This keeps the contract the Prowler App backend depends on under test. +""" + +import pytest + +from prowler.config.scan_config_schema import validate_scan_config + + +def _has_error_for(errors: list[dict], path_substr: str) -> bool: + return any(path_substr in e["path"] for e in errors) + + +# Each tuple: (provider, key, min_allowed, max_allowed) +INT_BOUND_CASES = [ + # AWS + ("aws", "max_unused_access_keys_days", 30, 180), + ("aws", "max_console_access_days", 30, 180), + ("aws", "max_unused_sagemaker_access_days", 7, 180), + ("aws", "max_security_group_rules", 1, 1000), + ("aws", "max_ec2_instance_age_in_days", 1, 1095), + ("aws", "recommended_cdk_bootstrap_version", 1, 100), + ("aws", "max_idle_disconnect_timeout_in_seconds", 60, 1800), + ("aws", "max_disconnect_timeout_in_seconds", 60, 3600), + ("aws", "max_session_duration_seconds", 600, 86400), + ("aws", "lambda_min_azs", 1, 6), + ("aws", "threat_detection_privilege_escalation_minutes", 5, 43200), + ("aws", "threat_detection_enumeration_minutes", 5, 43200), + ("aws", "threat_detection_llm_jacking_minutes", 5, 43200), + ("aws", "days_to_expire_threshold", 7, 365), + ("aws", "elb_min_azs", 1, 6), + ("aws", "elbv2_min_azs", 1, 6), + ("aws", "minimum_snapshot_retention_period", 1, 35), + ("aws", "max_days_secret_unused", 7, 365), + ("aws", "max_days_secret_unrotated", 1, 180), + ("aws", "min_kinesis_stream_retention_hours", 24, 8760), + # Azure + ("azure", "vm_backup_min_daily_retention_days", 7, 9999), + ("azure", "apim_threat_detection_llm_jacking_minutes", 5, 43200), + # GCP + ("gcp", "mig_min_zones", 1, 5), + ("gcp", "max_snapshot_age_days", 1, 1095), + ("gcp", "max_unused_account_days", 30, 365), + ("gcp", "storage_min_retention_days", 1, 3650), + # Kubernetes + ("kubernetes", "audit_log_maxbackup", 2, 1000), + ("kubernetes", "audit_log_maxsize", 10, 10000), + ("kubernetes", "audit_log_maxage", 7, 3650), + # M365 + ("m365", "sign_in_frequency", 1, 168), + ("m365", "recommended_mailtips_large_audience_threshold", 5, 10000), + ("m365", "audit_log_age", 30, 3650), + # GitHub + ("github", "inactive_not_archived_days_threshold", 30, 3650), + # MongoDB Atlas + ("mongodbatlas", "max_service_account_secret_validity_hours", 1, 720), + # Cloudflare + ("cloudflare", "max_retries", 0, 10), + # Vercel + ("vercel", "days_to_expire_threshold", 7, 365), + ("vercel", "stale_token_threshold_days", 30, 3650), + ("vercel", "stale_invitation_threshold_days", 7, 365), + ("vercel", "max_owner_percentage", 1, 50), + ("vercel", "max_owners", 1, 1000), +] + + +FLOAT_THRESHOLD_FIELDS = [ + ("aws", "threat_detection_privilege_escalation_threshold"), + ("aws", "threat_detection_enumeration_threshold"), + ("aws", "threat_detection_llm_jacking_threshold"), + ("azure", "apim_threat_detection_llm_jacking_threshold"), +] + + +class TestIntegerBounds: + """Each int field accepts both ends of its range and rejects ±1 outside.""" + + @pytest.mark.parametrize("provider, key, lo, hi", INT_BOUND_CASES) + def test_min_accepted(self, provider, key, lo, hi): + assert validate_scan_config({provider: {key: lo}}) == [] + + @pytest.mark.parametrize("provider, key, lo, hi", INT_BOUND_CASES) + def test_max_accepted(self, provider, key, lo, hi): + assert validate_scan_config({provider: {key: hi}}) == [] + + @pytest.mark.parametrize("provider, key, lo, hi", INT_BOUND_CASES) + def test_below_min_rejected(self, provider, key, lo, hi): + errors = validate_scan_config({provider: {key: lo - 1}}) + assert _has_error_for(errors, f"{provider}.{key}"), errors + + @pytest.mark.parametrize("provider, key, lo, hi", INT_BOUND_CASES) + def test_above_max_rejected(self, provider, key, lo, hi): + errors = validate_scan_config({provider: {key: hi + 1}}) + assert _has_error_for(errors, f"{provider}.{key}"), errors + + +class TestFloatThresholds: + """Threshold floats must stay within 0..1 inclusive.""" + + @pytest.mark.parametrize("provider, key", FLOAT_THRESHOLD_FIELDS) + def test_zero_and_one_accepted(self, provider, key): + assert validate_scan_config({provider: {key: 0.0}}) == [] + assert validate_scan_config({provider: {key: 1.0}}) == [] + assert validate_scan_config({provider: {key: 0.5}}) == [] + + @pytest.mark.parametrize("provider, key", FLOAT_THRESHOLD_FIELDS) + def test_negative_rejected(self, provider, key): + errors = validate_scan_config({provider: {key: -0.01}}) + assert _has_error_for(errors, f"{provider}.{key}") + + @pytest.mark.parametrize("provider, key", FLOAT_THRESHOLD_FIELDS) + def test_above_one_rejected(self, provider, key): + errors = validate_scan_config({provider: {key: 1.01}}) + assert _has_error_for(errors, f"{provider}.{key}") + + +class TestCloudWatchRetention: + """`log_group_retention_days` only accepts the AWS-approved enum values.""" + + @pytest.mark.parametrize("value", [1, 7, 30, 365, 731, 3653]) + def test_valid_values_accepted(self, value): + assert validate_scan_config({"aws": {"log_group_retention_days": value}}) == [] + + @pytest.mark.parametrize("value", [0, 2, 42, 500, 999, 4000]) + def test_invalid_values_rejected(self, value): + errors = validate_scan_config({"aws": {"log_group_retention_days": value}}) + assert _has_error_for(errors, "aws.log_group_retention_days") + + +class TestSemverValidator: + """AWS Fargate platform versions: X.Y.Z.""" + + @pytest.mark.parametrize("value", ["1.4.0", "1.0.0", "0.0.1", "10.20.30"]) + def test_accepts_semver(self, value): + assert ( + validate_scan_config({"aws": {"fargate_linux_latest_version": value}}) == [] + ) + + @pytest.mark.parametrize("value", ["1.4", "1", "v1.4.0", "1.4.0-beta", "a.b.c", ""]) + def test_rejects_non_semver(self, value): + errors = validate_scan_config({"aws": {"fargate_linux_latest_version": value}}) + assert _has_error_for(errors, "aws.fargate_linux_latest_version") + + +class TestEksVersionValidator: + """`eks_cluster_oldest_version_supported` expects MAJOR.MINOR.""" + + @pytest.mark.parametrize("value", ["1.28", "1.29", "1.30", "2.0"]) + def test_accepts_minor(self, value): + assert ( + validate_scan_config( + {"aws": {"eks_cluster_oldest_version_supported": value}} + ) + == [] + ) + + @pytest.mark.parametrize("value", ["1.28.0", "v1.28", "1", "1.x", ""]) + def test_rejects_invalid(self, value): + errors = validate_scan_config( + {"aws": {"eks_cluster_oldest_version_supported": value}} + ) + assert _has_error_for(errors, "aws.eks_cluster_oldest_version_supported") + + +class TestEksLogTypesEnum: + """Only the documented log types are accepted.""" + + def test_full_enum_accepted(self): + assert ( + validate_scan_config( + { + "aws": { + "eks_required_log_types": [ + "api", + "audit", + "authenticator", + "controllerManager", + "scheduler", + ] + } + } + ) + == [] + ) + + def test_unknown_type_rejected(self): + errors = validate_scan_config( + {"aws": {"eks_required_log_types": ["api", "telemetry"]}} + ) + assert _has_error_for(errors, "aws.eks_required_log_types") + + +class TestAzureDottedVersion: + """App Service versions accept 'X' and 'X.Y' but not 'X.Y.Z' or junk.""" + + @pytest.mark.parametrize("value", ["8.2", "3.12", "17"]) + def test_accepts(self, value): + assert validate_scan_config({"azure": {"php_latest_version": value}}) == [] + assert validate_scan_config({"azure": {"python_latest_version": value}}) == [] + assert validate_scan_config({"azure": {"java_latest_version": value}}) == [] + + @pytest.mark.parametrize("value", ["8.2.0", "v8", "8.x", ""]) + def test_rejects(self, value): + errors = validate_scan_config({"azure": {"php_latest_version": value}}) + assert _has_error_for(errors, "azure.php_latest_version") + + +class TestAzureTlsLiteralEnum: + """Only TLS 1.2 and 1.3 are tolerated by the recommended list.""" + + def test_accepted_versions(self): + assert ( + validate_scan_config( + {"azure": {"recommended_minimal_tls_versions": ["1.2", "1.3"]}} + ) + == [] + ) + + @pytest.mark.parametrize("value", ["1.0", "1.1", "2.0", ""]) + def test_unknown_version_rejected(self, value): + errors = validate_scan_config( + {"azure": {"recommended_minimal_tls_versions": [value]}} + ) + assert _has_error_for(errors, "azure.recommended_minimal_tls_versions") + + +class TestAzureRiskLevelLiteral: + """Defender attack-path risk level is a closed enum.""" + + @pytest.mark.parametrize("value", ["Low", "Medium", "High", "Critical"]) + def test_accepted(self, value): + assert ( + validate_scan_config( + {"azure": {"defender_attack_path_minimal_risk_level": value}} + ) + == [] + ) + + @pytest.mark.parametrize("value", ["low", "CRITICAL", "Severe", ""]) + def test_rejected(self, value): + errors = validate_scan_config( + {"azure": {"defender_attack_path_minimal_risk_level": value}} + ) + assert _has_error_for(errors, "azure.defender_attack_path_minimal_risk_level") + + +class TestECRSeverityLiteral: + """ECR severity is a closed enum (with INFORMATIONAL allowed).""" + + @pytest.mark.parametrize( + "value", + ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"], + ) + def test_accepted(self, value): + assert ( + validate_scan_config( + {"aws": {"ecr_repository_vulnerability_minimum_severity": value}} + ) + == [] + ) + + @pytest.mark.parametrize("value", ["URGENT", "low", "Crit", ""]) + def test_rejected(self, value): + errors = validate_scan_config( + {"aws": {"ecr_repository_vulnerability_minimum_severity": value}} + ) + assert _has_error_for( + errors, "aws.ecr_repository_vulnerability_minimum_severity" + ) + + +class TestPortRangeValidator: + """Each entry of `ec2_high_risk_ports` must be 1..65535 (0 is reserved).""" + + def test_valid_ports(self): + assert ( + validate_scan_config({"aws": {"ec2_high_risk_ports": [1, 22, 8080, 65535]}}) + == [] + ) + + @pytest.mark.parametrize("value", [-1, 0, 65536, 99999]) + def test_invalid_port_rejected(self, value): + errors = validate_scan_config({"aws": {"ec2_high_risk_ports": [80, value]}}) + assert _has_error_for(errors, "aws.ec2_high_risk_ports") + + +class TestAccountIdsValidator: + """AWS account IDs are 12-digit strings.""" + + def test_valid(self): + assert ( + validate_scan_config( + {"aws": {"trusted_account_ids": ["123456789012", "098765432109"]}} + ) + == [] + ) + + @pytest.mark.parametrize( + "value", ["12345", "12345678901", "1234567890123", "12345678901a"] + ) + def test_invalid_rejected(self, value): + errors = validate_scan_config({"aws": {"trusted_account_ids": [value]}}) + assert _has_error_for(errors, "aws.trusted_account_ids") + + +class TestTrustedIpsValidator: + """Trusted IPs accept IPv4, IPv6, and CIDR; reject junk.""" + + @pytest.mark.parametrize( + "value", + ["1.2.3.4", "10.0.0.0/8", "2001:db8::1", "2001:db8::/32"], + ) + def test_valid(self, value): + assert validate_scan_config({"aws": {"trusted_ips": [value]}}) == [] + + @pytest.mark.parametrize( + "value", ["not.an.ip", "1.2.3.300", "10.0.0.0/40", "::ffff:::"] + ) + def test_invalid_rejected(self, value): + errors = validate_scan_config({"aws": {"trusted_ips": [value]}}) + assert _has_error_for(errors, "aws.trusted_ips") + + +class TestDetectSecretsEntropyBound: + """`detect_secrets_plugins[].limit` is Shannon entropy: 0..10.""" + + @pytest.mark.parametrize("value", [0.0, 3.5, 4.5, 8.0, 10.0]) + def test_valid(self, value): + assert ( + validate_scan_config( + { + "aws": { + "detect_secrets_plugins": [ + {"name": "Base64HighEntropyString", "limit": value} + ] + } + } + ) + == [] + ) + + @pytest.mark.parametrize("value", [-0.1, 10.01, 50]) + def test_invalid(self, value): + errors = validate_scan_config( + { + "aws": { + "detect_secrets_plugins": [ + {"name": "Base64HighEntropyString", "limit": value} + ] + } + } + ) + assert _has_error_for(errors, "aws.detect_secrets_plugins") + + +class TestAdapterRobustness: + """Top-level adapter behaviour the Prowler App backend depends on.""" + + def test_non_dict_payload(self): + errors = validate_scan_config([1, 2, 3]) + assert len(errors) == 1 + assert errors[0]["path"] == "" + + def test_unknown_provider_section_tolerated(self): + # additionalProperties: True at the root level by design. + assert validate_scan_config({"newprovider": {"foo": "bar"}}) == [] + + def test_unknown_key_tolerated_by_pydantic_extra_allow(self): + # ProviderConfigBase has extra="allow" for forward compatibility. + assert validate_scan_config({"aws": {"completely_new_knob": 1}}) == [] + + def test_provider_section_must_be_mapping(self): + errors = validate_scan_config({"aws": "not a mapping"}) + assert _has_error_for(errors, "aws") + + def test_multiple_errors_surfaced(self): + errors = validate_scan_config( + { + "aws": { + "max_unused_access_keys_days": 5, # below min 30 + "max_security_group_rules": 99999, # above max 1000 + "ec2_high_risk_ports": [80, 70000], # port out of range + } + } + ) + # All three should surface independently. + assert _has_error_for(errors, "aws.max_unused_access_keys_days") + assert _has_error_for(errors, "aws.max_security_group_rules") + assert _has_error_for(errors, "aws.ec2_high_risk_ports") diff --git a/tests/config/schema/loader_integration_test.py b/tests/config/schema/loader_integration_test.py new file mode 100644 index 0000000000..fa995fb9df --- /dev/null +++ b/tests/config/schema/loader_integration_test.py @@ -0,0 +1,124 @@ +"""End-to-end tests that exercise the real ``load_and_validate_config_file`` +through a temp YAML file. Anything that breaks here would break the actual +``prowler aws -c …`` code path.""" + +import logging +import os +import pathlib +from typing import Callable + +import pytest + +from prowler.config.config import load_and_validate_config_file + + +@pytest.fixture +def write_config(tmp_path: pathlib.Path) -> Callable[[str], str]: + def _write(content: str) -> str: + path = tmp_path / "config.yaml" + path.write_text(content) + return str(path) + + return _write + + +class Test_Loader_With_Schema_Integration: + def test_shipped_default_config_loads_without_warnings(self, caplog): + """The default ``prowler/config/config.yaml`` must round-trip every + provider WITHOUT emitting any schema warnings. If this fails, + someone added a key to the YAML without updating the schema.""" + repo_root = pathlib.Path(os.path.dirname(os.path.realpath(__file__))).parents[2] + shipped = repo_root / "prowler" / "config" / "config.yaml" + with caplog.at_level(logging.WARNING, logger="prowler"): + for provider in [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "mongodbatlas", + "cloudflare", + "vercel", + ]: + cfg = load_and_validate_config_file(provider, str(shipped)) + # Provider always exists in the shipped file → non-empty. + assert cfg, f"{provider} returned an empty config" + + offending = [ + r.getMessage() + for r in caplog.records + if "prowler.config[" in r.getMessage() + ] + assert not offending, ( + "Shipped config.yaml triggered schema warnings — schema or YAML out of sync:\n" + + "\n".join(offending) + ) + + def test_user_config_with_bad_threshold_falls_back(self, write_config, caplog): + path = write_config( + "aws:\n" + " threat_detection_privilege_escalation_threshold: 5.0\n" + " lambda_min_azs: 2\n" + ) + with caplog.at_level(logging.WARNING, logger="prowler"): + cfg = load_and_validate_config_file("aws", path) + assert cfg == {"lambda_min_azs": 2} + assert any( + "threat_detection_privilege_escalation_threshold" in r.getMessage() + for r in caplog.records + ) + + def test_old_format_config_still_works(self, write_config): + # Old format = flat keys, no provider header. + path = write_config( + "max_ec2_instance_age_in_days: 90\n" + "ecr_repository_vulnerability_minimum_severity: HIGH\n" + ) + cfg = load_and_validate_config_file("aws", path) + assert cfg == { + "max_ec2_instance_age_in_days": 90, + "ecr_repository_vulnerability_minimum_severity": "HIGH", + } + + def test_unknown_keys_pass_through_via_loader(self, write_config): + path = write_config( + "aws:\n" " third_party_plugin_setting: hello\n" " lambda_min_azs: 2\n" + ) + cfg = load_and_validate_config_file("aws", path) + assert cfg == { + "third_party_plugin_setting": "hello", + "lambda_min_azs": 2, + } + + def test_quoted_numeric_is_coerced_via_loader(self, write_config): + # YAML quotes the number: ``"180"`` arrives as a Python str. + # The schema must coerce it to int so downstream comparisons work. + path = write_config('aws:\n max_ec2_instance_age_in_days: "180"\n') + cfg = load_and_validate_config_file("aws", path) + assert cfg == {"max_ec2_instance_age_in_days": 180} + assert isinstance(cfg["max_ec2_instance_age_in_days"], int) + + def test_invalid_yaml_shape_list_as_string_drops_key(self, write_config, caplog): + path = write_config( + "aws:\n" + " disallowed_regions: me-south-1\n" # forgot list dashes + " lambda_min_azs: 2\n" + ) + with caplog.at_level(logging.WARNING, logger="prowler"): + cfg = load_and_validate_config_file("aws", path) + assert cfg == {"lambda_min_azs": 2} + assert any("disallowed_regions" in r.getMessage() for r in caplog.records) + + def test_other_providers_unaffected_by_aws_block(self, write_config): + path = write_config( + "aws:\n max_ec2_instance_age_in_days: 90\n" "gcp:\n mig_min_zones: 5\n" + ) + assert load_and_validate_config_file("aws", path) == { + "max_ec2_instance_age_in_days": 90 + } + assert load_and_validate_config_file("gcp", path) == {"mig_min_zones": 5} + + def test_missing_provider_block_returns_empty(self, write_config): + path = write_config("aws:\n max_ec2_instance_age_in_days: 90\n") + assert load_and_validate_config_file("azure", path) == {} diff --git a/tests/config/schema/other_providers_schema_test.py b/tests/config/schema/other_providers_schema_test.py new file mode 100644 index 0000000000..f4dc5184ab --- /dev/null +++ b/tests/config/schema/other_providers_schema_test.py @@ -0,0 +1,152 @@ +"""Smaller-provider schema coverage. One happy path + one invalid path +per field is enough to lock in the contract; the validator behaviour +itself is covered exhaustively in validator_test.py.""" + +import pytest + +from prowler.config.schema.registry import SCHEMAS +from prowler.config.schema.validator import validate_provider_config + + +def _validate(provider, raw): + return validate_provider_config(provider, raw, SCHEMAS[provider]) + + +class Test_Azure_Schema: + @pytest.mark.parametrize("level", ["Low", "Medium", "High", "Critical"]) + def test_defender_risk_level_valid_values(self, level): + assert _validate( + "azure", {"defender_attack_path_minimal_risk_level": level} + ) == {"defender_attack_path_minimal_risk_level": level} + + def test_defender_risk_level_lowercase_dropped(self): + # Case matters: the matching check uses Title-case comparison. + assert ( + _validate("azure", {"defender_attack_path_minimal_risk_level": "high"}) + == {} + ) + + def test_apim_threshold_in_range(self): + out = _validate("azure", {"apim_threat_detection_llm_jacking_threshold": 0.1}) + assert out == {"apim_threat_detection_llm_jacking_threshold": 0.1} + + def test_apim_threshold_out_of_range(self): + out = _validate("azure", {"apim_threat_detection_llm_jacking_threshold": 1.5}) + assert out == {} + + def test_vm_backup_retention_must_be_positive(self): + assert _validate("azure", {"vm_backup_min_daily_retention_days": 7}) == { + "vm_backup_min_daily_retention_days": 7 + } + assert _validate("azure", {"vm_backup_min_daily_retention_days": 0}) == {} + assert _validate("azure", {"vm_backup_min_daily_retention_days": -1}) == {} + + +class Test_GCP_Schema: + def test_valid_values_round_trip(self): + raw = { + "mig_min_zones": 2, + "max_snapshot_age_days": 90, + "max_unused_account_days": 180, + "storage_min_retention_days": 90, + } + assert _validate("gcp", raw) == raw + + def test_zero_zone_count_dropped(self): + assert _validate("gcp", {"mig_min_zones": 0}) == {} + + +class Test_Kubernetes_Schema: + def test_valid_values_round_trip(self): + raw = { + "audit_log_maxbackup": 10, + "audit_log_maxsize": 100, + "audit_log_maxage": 30, + } + assert _validate("kubernetes", raw) == raw + + def test_negative_audit_log_dropped(self): + assert _validate("kubernetes", {"audit_log_maxage": -1}) == {} + + +class Test_M365_Schema: + def test_valid_values_round_trip(self): + raw = { + "sign_in_frequency": 4, + "recommended_mailtips_large_audience_threshold": 25, + "audit_log_age": 90, + } + assert _validate("m365", raw) == raw + + def test_negative_audit_log_age_dropped(self): + assert _validate("m365", {"audit_log_age": -10}) == {} + + +class Test_GitHub_Schema: + def test_valid_threshold(self): + assert _validate("github", {"inactive_not_archived_days_threshold": 180}) == { + "inactive_not_archived_days_threshold": 180 + } + + def test_zero_threshold_dropped(self): + assert _validate("github", {"inactive_not_archived_days_threshold": 0}) == {} + + +class Test_MongoDBAtlas_Schema: + def test_valid(self): + assert _validate( + "mongodbatlas", {"max_service_account_secret_validity_hours": 8} + ) == {"max_service_account_secret_validity_hours": 8} + + def test_invalid_negative(self): + assert ( + _validate("mongodbatlas", {"max_service_account_secret_validity_hours": -1}) + == {} + ) + + +class Test_Cloudflare_Schema: + def test_zero_retries_allowed(self): + # 0 is explicitly documented as "disable retries" in config.yaml. + assert _validate("cloudflare", {"max_retries": 0}) == {"max_retries": 0} + + def test_positive_retries_allowed(self): + assert _validate("cloudflare", {"max_retries": 3}) == {"max_retries": 3} + + def test_negative_retries_dropped(self): + assert _validate("cloudflare", {"max_retries": -1}) == {} + + +class Test_Vercel_Schema: + def test_owner_percentage_in_range(self): + assert _validate("vercel", {"max_owner_percentage": 20}) == { + "max_owner_percentage": 20 + } + assert _validate("vercel", {"max_owner_percentage": 1}) == { + "max_owner_percentage": 1 + } + assert _validate("vercel", {"max_owner_percentage": 50}) == { + "max_owner_percentage": 50 + } + + def test_owner_percentage_over_max_dropped(self): + # Tightened to 1..50 — anything above (incl. previous 100) is dropped. + assert _validate("vercel", {"max_owner_percentage": 51}) == {} + assert _validate("vercel", {"max_owner_percentage": 150}) == {} + + def test_owner_percentage_zero_or_negative_dropped(self): + # 0 is no longer a valid configuration (defeats PoLP signal). + assert _validate("vercel", {"max_owner_percentage": 0}) == {} + assert _validate("vercel", {"max_owner_percentage": -1}) == {} + + def test_full_default_config_round_trip(self): + raw = { + "stable_branches": ["main", "master"], + "days_to_expire_threshold": 7, + "stale_token_threshold_days": 90, + "stale_invitation_threshold_days": 30, + "max_owner_percentage": 20, + "max_owners": 3, + "secret_suffixes": ["_KEY", "_SECRET", "_TOKEN"], + } + assert _validate("vercel", raw) == raw diff --git a/tests/config/schema/validator_test.py b/tests/config/schema/validator_test.py new file mode 100644 index 0000000000..398a0d10e8 --- /dev/null +++ b/tests/config/schema/validator_test.py @@ -0,0 +1,175 @@ +"""Behavioural tests for ``validate_provider_config``. + +The validator is the gatekeeper for every provider schema: its job is to +keep backwards-compatible behaviour (no exceptions, drop only the bad +keys) while loudly logging type mistakes. +""" + +import logging + +import pytest + +from prowler.config.schema.aws import AWSProviderConfig +from prowler.config.schema.registry import SCHEMAS +from prowler.config.schema.validator import validate_provider_config + + +class Test_Validate_Provider_Config_Contract: + """Generic invariants that must hold for any schema.""" + + def test_returns_empty_dict_when_raw_is_not_a_dict(self): + assert validate_provider_config("aws", None, AWSProviderConfig) == {} + assert validate_provider_config("aws", "string", AWSProviderConfig) == {} + assert validate_provider_config("aws", 42, AWSProviderConfig) == {} + assert validate_provider_config("aws", [], AWSProviderConfig) == {} + + def test_returns_raw_unchanged_when_no_schema_registered(self): + raw = {"anything": "goes", "even": [1, 2, 3]} + assert validate_provider_config("mystery_provider", raw, None) == raw + + def test_unknown_keys_pass_through_for_plugin_compatibility(self): + # Third-party plugins inject arbitrary keys; the schema must NOT + # filter them. This is the contract that lets the plugin ecosystem + # keep working when we add validation. + raw = {"plugin_custom_key": "foo", "lambda_min_azs": 2} + assert validate_provider_config("aws", raw, AWSProviderConfig) == { + "plugin_custom_key": "foo", + "lambda_min_azs": 2, + } + + def test_empty_dict_returns_empty_dict(self): + assert validate_provider_config("aws", {}, AWSProviderConfig) == {} + + def test_known_valid_value_passes_through_unchanged(self): + raw = {"max_ec2_instance_age_in_days": 180} + assert validate_provider_config("aws", raw, AWSProviderConfig) == { + "max_ec2_instance_age_in_days": 180 + } + + +class Test_Validate_Provider_Config_Coercion: + """Pydantic v2 coerces common type-mistakes automatically. We want to + keep that behaviour so quoted numerics in user configs ``Just Work``.""" + + def test_string_numeric_is_coerced_to_int(self): + out = validate_provider_config( + "aws", {"max_ec2_instance_age_in_days": "180"}, AWSProviderConfig + ) + assert out == {"max_ec2_instance_age_in_days": 180} + assert isinstance(out["max_ec2_instance_age_in_days"], int) + + def test_string_numeric_is_coerced_to_float(self): + out = validate_provider_config( + "aws", + {"threat_detection_privilege_escalation_threshold": "0.4"}, + AWSProviderConfig, + ) + assert out == {"threat_detection_privilege_escalation_threshold": 0.4} + + +class Test_Validate_Provider_Config_Drops_Invalid_Keys: + """When a field fails validation, only that key is dropped from the + returned dict. The rest of the user's config is preserved so the + consumer's ``audit_config.get(key, default)`` falls back to its own + built-in default for the offending field and uses user values for + everything else.""" + + def test_out_of_range_threshold_is_dropped(self, caplog): + with caplog.at_level(logging.WARNING): + out = validate_provider_config( + "aws", + { + "threat_detection_privilege_escalation_threshold": 2.0, + "lambda_min_azs": 2, + }, + AWSProviderConfig, + ) + assert out == {"lambda_min_azs": 2} + assert any( + "threat_detection_privilege_escalation_threshold" in r.getMessage() + for r in caplog.records + ) + + def test_invalid_enum_is_dropped(self): + out = validate_provider_config( + "aws", + {"ecr_repository_vulnerability_minimum_severity": "medum"}, + AWSProviderConfig, + ) + assert out == {} + + def test_wrong_shape_list_as_string_is_dropped(self): + # Classic YAML mistake: ``disallowed_regions: me-south-1`` without dashes. + # Pydantic refuses to silently treat a str as a single-element list, + # which is exactly the safety guarantee we want. + out = validate_provider_config( + "aws", + {"disallowed_regions": "me-south-1", "lambda_min_azs": 2}, + AWSProviderConfig, + ) + assert out == {"lambda_min_azs": 2} + + def test_negative_positive_int_is_dropped(self): + out = validate_provider_config( + "aws", {"max_ec2_instance_age_in_days": -1}, AWSProviderConfig + ) + assert out == {} + + def test_zero_is_dropped_for_strictly_positive_field(self): + # max_ec2_instance_age_in_days is gt=0. Zero would silently cause every + # instance to FAIL the age check. + out = validate_provider_config( + "aws", {"max_ec2_instance_age_in_days": 0}, AWSProviderConfig + ) + assert out == {} + + def test_multiple_invalid_keys_yield_multiple_warnings(self, caplog): + with caplog.at_level(logging.WARNING): + out = validate_provider_config( + "aws", + { + "max_ec2_instance_age_in_days": "nope", + "ecr_repository_vulnerability_minimum_severity": "medum", + "valid_extra_key": "kept", + }, + AWSProviderConfig, + ) + assert out == {"valid_extra_key": "kept"} + messages = " ".join(r.getMessage() for r in caplog.records) + assert "max_ec2_instance_age_in_days" in messages + assert "ecr_repository_vulnerability_minimum_severity" in messages + + def test_warning_message_includes_provider_and_field(self, caplog): + with caplog.at_level(logging.WARNING): + validate_provider_config( + "aws", + {"threat_detection_privilege_escalation_threshold": 5.0}, + AWSProviderConfig, + ) + assert any( + "prowler.config[aws.threat_detection_privilege_escalation_threshold]" + in r.getMessage() + for r in caplog.records + ) + + +class Test_Schemas_Registry: + """Every provider mentioned in the YAML config must have a schema.""" + + @pytest.mark.parametrize( + "provider", + [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "mongodbatlas", + "cloudflare", + "vercel", + ], + ) + def test_schema_registered_for_provider(self, provider): + assert provider in SCHEMAS + assert SCHEMAS[provider] is not None diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index cd602a3a61..94b6ad2b4d 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -17,7 +17,7 @@ prowler_command = "prowler" # capsys # https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html -prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm} ..." +prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,dashboard,iac,image,llm} ..." def mock_get_available_providers(): @@ -39,6 +39,7 @@ def mock_get_available_providers(): "cloudflare", "openstack", "stackit", + "linode", ] diff --git a/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py b/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py new file mode 100644 index 0000000000..6382dc1d2c --- /dev/null +++ b/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py @@ -0,0 +1,132 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight import ( + get_asd_essential_eight_table, +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="ASD-Essential-Eight"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestASDEssentialEightTable: + """Test cases verifying multi-section counting and provider-column attribution for the ASD Essential Eight compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched ASD-Essential-Eight + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/c5/__init__.py b/tests/lib/outputs/compliance/c5/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/c5/c5_table_test.py b/tests/lib/outputs/compliance/c5/c5_table_test.py new file mode 100644 index 0000000000..c423f5af0b --- /dev/null +++ b/tests/lib/outputs/compliance/c5/c5_table_test.py @@ -0,0 +1,130 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.c5.c5 import get_c5_table + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="C5"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestC5Table: + """Verify multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched C5 compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/ccc/ccc_table_test.py b/tests/lib/outputs/compliance/ccc/ccc_table_test.py new file mode 100644 index 0000000000..f647aff3d0 --- /dev/null +++ b/tests/lib/outputs/compliance/ccc/ccc_table_test.py @@ -0,0 +1,130 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.ccc.ccc import get_ccc_table + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="CCC"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestCCCTable: + """Test cases verifying multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched CCC compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/cis/cis_table_test.py b/tests/lib/outputs/compliance/cis/cis_table_test.py new file mode 100644 index 0000000000..c47e1387a0 --- /dev/null +++ b/tests/lib/outputs/compliance/cis/cis_table_test.py @@ -0,0 +1,162 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.cis.cis import get_cis_table + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _attr(section, profile="Level 1"): + return SimpleNamespace(Section=section, Profile=profile) + + +def _make_compliance(provider, attributes, version="1.4", framework="CIS"): + """Build a per-check CIS compliance with the given (section, profile) attrs.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=attributes)], + ) + + +def _make_compliance_multi_req(provider, attributes, version="1.4", framework="CIS"): + """Build a per-check CIS compliance where each attr is its own requirement, + simulating a check that appears in several requirements.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=[attr]) for attr in attributes], + ) + + +class TestCISTable: + """Verify multi-section counting and provider-column attribution for the CIS compliance table.""" + + def test_muted_multi_section_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several sections must increment the + per-section Muted column for every section, not only the first seen. + + CIS counts FAIL/PASS through Level 1/Level 2 buckets, so only the Muted + per-section count was affected by the undercount bug. + """ + bulk_metadata = { + # check_a is muted and belongs to two sections at once. + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM"), _attr("2 Logging")]) + ] + ), + # A real (non-muted) finding so the table is rendered. + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", [_attr("1 IAM")])] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both section rows must carry a Muted count of 1 in their last cell. + # Before the fix only the first section seen got incremented. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_same_section_level_not_double_counted(self, capsys, tmp_path): + """A single finding whose check maps to several requirements that share + the same section and profile must count once for that section/level, + not once per requirement (FAIL(1), never FAIL(2)).""" + bulk_metadata = { + # check_a is a single FAIL mapped to two requirements, both in the + # same section "1 IAM" and the same profile "Level 1". + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance_multi_req("aws", [_attr("1 IAM"), _attr("1 IAM")]) + ] + ), + # A second finding in another section so the table renders. + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", [_attr("2 Logging")])] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The "1 IAM" row must show FAIL(1) for Level 1, never FAIL(2). + assert "FAIL(1)" in plain + assert "FAIL(2)" not in plain + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched CIS compliance, not + from a different framework that trails it in the compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM")]), + _make_compliance( + "gcp", [_attr("Other")], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM")]), + _make_compliance( + "gcp", [_attr("Other")], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The trailing unrelated framework's provider must not leak in. + assert "gcp" not in captured.out diff --git a/tests/lib/outputs/compliance/ens/ens_table_test.py b/tests/lib/outputs/compliance/ens/ens_table_test.py new file mode 100644 index 0000000000..2373885dbc --- /dev/null +++ b/tests/lib/outputs/compliance/ens/ens_table_test.py @@ -0,0 +1,235 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.ens.ens import get_ens_table + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _attr(marco, categoria, tipo="requisito", nivel="alto"): + return SimpleNamespace(Marco=marco, Categoria=categoria, Tipo=tipo, Nivel=nivel) + + +def _make_compliance(provider, attributes, framework="ENS"): + """Build a per-check ENS compliance with the given marco/categoria attrs.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=attributes)], + ) + + +class TestENSTable: + """Test cases for ENS compliance table rendering. + + Verify multi-marco counting and provider-column attribution for the + compliance table. + """ + + def test_no_cumple_marked_in_every_marco(self, capsys, tmp_path): + """A single failing finding mapped to several marcos must mark every + one of them as NO CUMPLE, not only the first marco seen.""" + bulk_metadata = { + # check_a fails and belongs to two distinct marcos/categorias. + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr("operacional", "control de acceso"), + _attr("organizativo", "politica de seguridad"), + ], + ) + ] + ), + # A passing finding so the overview total reaches 2. + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "control de acceso")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both marco rows the failing finding maps to must read NO CUMPLE. + # Before the fix only the first marco was marked, the second stayed + # CUMPLE. Anchor the assertion to the actual marco rows (not the + # overview header line which also mentions NO CUMPLE). + op_row = [ + line + for line in plain.splitlines() + if "operacional/control de acceso" in line + ] + org_row = [ + line + for line in plain.splitlines() + if "organizativo/politica de seguridad" in line + ] + assert len(op_row) == 1 and "NO CUMPLE" in op_row[0] + assert len(org_row) == 1 and "NO CUMPLE" in org_row[0] + + def test_recomendacion_does_not_set_no_cumple(self, capsys, tmp_path): + """A FAIL on a 'recomendacion' attribute must not flip a marco to + NO CUMPLE (this path is intentionally excluded from the fix).""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr( + "operacional", "control de acceso", tipo="recomendacion" + ) + ], + ) + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("organizativo", "politica")]) + ] + ), + # A regular (non-recomendacion) check so the results table renders + # at least one marco row and the assertion below is not vacuous. + "check_c": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "continuidad")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + _make_finding("check_c", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The recomendacion FAIL must not appear as a NO CUMPLE marco row in the + # results table (the overview header line is allowed to mention it). + marco_rows = [ + line + for line in plain.splitlines() + if "operacional" in line or "organizativo" in line + ] + # Guard against a vacuous pass: the table must actually render rows. + assert marco_rows + assert all("NO CUMPLE" not in line for line in marco_rows) + + def test_muted_multi_marco_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several marcos must increment the + per-marco Muted column for every marco, not only the first seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr("operacional", "control de acceso"), + _attr("organizativo", "politica de seguridad"), + ], + ) + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "control de acceso")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both marco rows the muted finding maps to must report a Muted count of + # 1 in their last cell. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Proveedor column must come from the matched ENS compliance, not + from a different framework that trails it in the compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", [_attr("operacional", "control de acceso")] + ), + _make_compliance( + "gcp", [_attr("x", "y")], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", [_attr("operacional", "control de acceso")] + ), + _make_compliance( + "gcp", [_attr("x", "y")], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + assert "gcp" not in captured.out diff --git a/tests/lib/outputs/compliance/kisa_ismsp/__init__.py b/tests/lib/outputs/compliance/kisa_ismsp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py new file mode 100644 index 0000000000..be0fe25ad5 --- /dev/null +++ b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py @@ -0,0 +1,137 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp import get_kisa_ismsp_table + +# The generator matches a compliance when its Framework starts with "KISA" and +# its Version is contained in the compliance_framework argument. +COMPLIANCE_FRAMEWORK = "kisa-isms-p-2023_aws" + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance( + provider, sections, framework="KISA-ISMS-P", version="kisa-isms-p-2023" +): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestKISAISMSPTable: + """Verify multi-section counting and provider-column attribution for the KISA ISMS-P compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched KISA compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/mitre_attack/__init__.py b/tests/lib/outputs/compliance/mitre_attack/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py new file mode 100644 index 0000000000..3bd69b44e8 --- /dev/null +++ b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py @@ -0,0 +1,140 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.mitre_attack.mitre_attack import ( + get_mitre_attack_table, +) + +# The generator matches a compliance when "MITRE-ATTACK" is in its Framework and +# its Version is contained in the compliance_framework argument. +COMPLIANCE_FRAMEWORK = "mitre_attack_aws" + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance( + provider, tactics, framework="MITRE-ATTACK", version="mitre_attack" +): + """Build a per-check compliance covering the given tactics.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Tactics=tactics)], + ) + + +class TestMitreAttackTable: + """Test multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_tactic_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several tactics must show FAIL(1) in + every tactic, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence", "Execution"])] + ), + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence"])] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both Persistence and Execution must report FAIL(1); before the fix + # Execution was undercounted because the per-tactic count was gated by + # the global dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_tactic_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several tactics must increase the + per-tactic Muted count in every tactic, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence", "Execution"])] + ), + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence"])] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A second finding is needed so the table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both Persistence and Execution, so the + # Muted column must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched MITRE-ATTACK + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["Persistence"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["Persistence"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/__init__.py b/tests/lib/outputs/compliance/okta_idaas_stig/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py new file mode 100644 index 0000000000..3017ea4f92 --- /dev/null +++ b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py @@ -0,0 +1,136 @@ +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import ( + get_okta_idaas_stig_table, +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="Okta-IDaaS-STIG"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestOktaIDaaSSTIGTable: + """Test cases for Okta IDaaS STIG compliance table rendering.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + # check_a belongs to two sections at once. + "check_a": SimpleNamespace( + Compliance=[_make_compliance("okta", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("okta", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging + # was undercounted and rendered as plain PASS. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("okta", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("okta", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. Before the fix only the first section seen + # was incremented, leaving the second at 0. + # Strip ANSI color codes before counting the bare values per row. + import re + + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # Each section row ends with its Muted value in its own cell; both rows + # must carry a Muted count of 1. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched Okta-IDaaS-STIG + compliance, never from a different framework that happens to be the + last entry in the check's compliance list.""" + # check_a maps to Okta-IDaaS-STIG (provider "okta") but its compliance + # list ends with a *different* framework whose provider is "aws". With + # the bug the leaked loop variable made the table render "aws". + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("okta", ["IAM"]), + _make_compliance("aws", ["Other"], framework="OtherFramework"), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("okta", ["IAM"]), + _make_compliance("aws", ["Other"], framework="OtherFramework"), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "okta" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "aws" not in captured.out diff --git a/tests/lib/outputs/compliance/prowler_threatscore/__init__.py b/tests/lib/outputs/compliance/prowler_threatscore/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py new file mode 100644 index 0000000000..d754395dc3 --- /dev/null +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py @@ -0,0 +1,147 @@ +import re +from types import SimpleNamespace +from unittest import mock + +from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore import ( + get_prowler_threatscore_table, +) + +# Patch target for the Compliance.get_bulk lookup used to render pillars without +# findings; the tests don't exercise that path so it returns nothing. +COMPLIANCE_PATH = ( + "prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore.Compliance" +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, pillars, framework="ProwlerThreatScore"): + """Build a per-check compliance covering the given pillars (Section).""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace( + Attributes=[SimpleNamespace(Section=pillar, LevelOfRisk=5, Weight=100)] + ) + for pillar in pillars + ], + ) + + +class TestProwlerThreatScoreTable: + """Verify multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_pillar_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several pillars must show FAIL(1) in + every pillar, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Encryption"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Encryption must report FAIL(1); before the fix Encryption + # was undercounted because the per-pillar count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_pillar_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several pillars must increase the + per-pillar Muted count in every pillar, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Encryption"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Encryption, so the Muted + # column must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched ProwlerThreatScore + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/universal/universal_table_test.py b/tests/lib/outputs/compliance/universal/universal_table_test.py index 7598c43c8b..abe3d0c3d0 100644 --- a/tests/lib/outputs/compliance/universal/universal_table_test.py +++ b/tests/lib/outputs/compliance/universal/universal_table_test.py @@ -1,3 +1,4 @@ +import re from types import SimpleNamespace from unittest.mock import MagicMock @@ -26,6 +27,10 @@ def _make_finding(check_id, status="PASS", muted=False): return finding +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + def _make_framework(requirements, table_config, provider="AWS"): return ComplianceFramework( framework="TestFW", @@ -39,6 +44,8 @@ def _make_framework(requirements, table_config, provider="AWS"): class TestBuildRequirementCheckMap: + """Test cases for building the requirement-to-check map of a framework.""" + def test_basic(self): reqs = [ UniversalComplianceRequirement( @@ -103,6 +110,8 @@ class TestBuildRequirementCheckMap: class TestGetGroupKey: + """Test cases for resolving the group key of a requirement.""" + def test_normal_field(self): req = UniversalComplianceRequirement( id="1.1", @@ -124,7 +133,9 @@ class TestGetGroupKey: class TestGroupedMode: - def test_grouped_rendering(self, capsys): + """Test cases for grouped-mode universal compliance table rendering.""" + + def test_grouped_rendering(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -156,7 +167,7 @@ class TestGroupedMode: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -167,9 +178,118 @@ class TestGroupedMode: assert "PASS" in captured.out assert "FAIL" in captured.out + def test_grouped_multi_section_no_undercount(self, capsys, tmp_path): + """A single check mapped to several sections must be counted in + every section it belongs to, not only the first one seen.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = _make_framework(reqs, tc) + + # check_a (FAIL) belongs to both IAM and Logging sections; check_b + # (PASS, IAM only) is added so the overview total reaches 2 and the + # results table is rendered. + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both the IAM and Logging rows must report FAIL(1). Before the fix the + # second section seen (Logging) was undercounted to FAIL(0) and rendered + # as PASS. Anchor each occurrence to its own table row so an unrelated + # "FAIL(1)" elsewhere cannot mask an undercount. + iam_row = [ + line for line in plain.splitlines() if "IAM" in line and "FAIL(1)" in line + ] + logging_row = [ + line + for line in plain.splitlines() + if "Logging" in line and "FAIL(1)" in line + ] + assert len(iam_row) == 1 + assert len(logging_row) == 1 + + def test_grouped_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several groups must be counted in + the per-group Muted column of every group it belongs to.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = _make_framework(reqs, tc) + + # check_a is MUTED and belongs to both IAM and Logging; check_b is a + # plain FAIL so the overview total reaches 2 and the table is rendered. + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The muted finding belongs to both sections, so both the IAM row and + # the Logging row must carry a Muted count of 1 in their last cell. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + class TestSplitMode: - def test_split_rendering(self, capsys): + """Test cases for split-mode universal compliance table rendering.""" + + def test_split_rendering(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -204,7 +324,7 @@ class TestSplitMode: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -214,9 +334,119 @@ class TestSplitMode: assert "Level 1" in captured.out assert "Level 2" in captured.out + def test_split_muted_multi_section_not_undercounted(self, capsys, tmp_path): + """In split mode a single MUTED finding mapped to several groups must + be counted in the Muted column of every group it belongs to.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + fw = _make_framework(reqs, tc) + + # check_a is MUTED and belongs to both Storage and Logging; check_b is a + # plain FAIL so the table is rendered. + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both section rows must carry a Muted count of 1 (last cell). Before the + # fix only the first group seen incremented Muted, leaving the other 0. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_split_same_group_value_not_double_counted(self, capsys, tmp_path): + """A single finding whose check maps to several requirements that share + the same group and split value must count once for that group/split, + not once per requirement (FAIL(1), never FAIL(2)).""" + reqs = [ + # check_a appears in two requirements, both Storage / Level 1. + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + # A second group so the table renders with more than one finding. + UniversalComplianceRequirement( + id="2.1", + description="test3", + attributes={"Section": "Logging", "Profile": "Level 1"}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + fw = _make_framework(reqs, tc) + + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The Storage row must show FAIL(1) for Level 1, never FAIL(2). + assert "FAIL(1)" in plain + assert "FAIL(2)" not in plain + class TestScoredMode: - def test_scored_rendering(self, capsys): + """Test cases for scored-mode universal compliance table rendering.""" + + def test_scored_rendering(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -251,7 +481,7 @@ class TestScoredMode: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -261,9 +491,68 @@ class TestScoredMode: assert "Score" in captured.out assert "Threat Score" in captured.out + def test_scored_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """In scored mode a single FAIL finding mapped to several groups must + show FAIL(1) in every group it belongs to, not only the first one.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM", "LevelOfRisk": 5, "Weight": 100}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging", "LevelOfRisk": 3, "Weight": 50}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig( + group_by="Section", + scoring=ScoringConfig(risk_field="LevelOfRisk", weight_field="Weight"), + ) + fw = _make_framework(reqs, tc) + + # check_a (FAIL) belongs to both IAM and Logging; check_b (PASS, IAM + # only) raises the overview total to 2 so the table is rendered. + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + iam_row = [ + line for line in plain.splitlines() if "IAM" in line and "FAIL(1)" in line + ] + logging_row = [ + line + for line in plain.splitlines() + if "Logging" in line and "FAIL(1)" in line + ] + assert len(iam_row) == 1 + assert len(logging_row) == 1 + class TestCustomLabels: - def test_ens_spanish_labels(self, capsys): + """Test cases for custom-label universal compliance table rendering.""" + + def test_ens_spanish_labels(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -300,7 +589,7 @@ class TestCustomLabels: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -311,7 +600,9 @@ class TestCustomLabels: class TestMultiProviderDictChecks: - def test_only_aws_checks_matched(self, capsys): + """Test cases for multi-provider dict checks in the universal table.""" + + def test_only_aws_checks_matched(self, capsys, tmp_path): """With dict checks and provider='aws', only AWS checks match findings.""" reqs = [ UniversalComplianceRequirement( @@ -352,7 +643,7 @@ class TestMultiProviderDictChecks: bulk_metadata, "multi_cloud", "output", - "/tmp", + str(tmp_path), False, framework=fw, provider="aws", @@ -366,7 +657,9 @@ class TestMultiProviderDictChecks: class TestNoTableConfig: - def test_returns_early_without_table_config(self, capsys): + """Test cases for the universal table when no table config is present.""" + + def test_returns_early_without_table_config(self, capsys, tmp_path): fw = ComplianceFramework( framework="TestFW", name="Test", @@ -374,11 +667,11 @@ class TestNoTableConfig: description="Test", requirements=[], ) - get_universal_table([], {}, "test", "out", "/tmp", False, framework=fw) + get_universal_table([], {}, "test", "out", str(tmp_path), False, framework=fw) captured = capsys.readouterr() assert captured.out == "" - def test_returns_early_without_framework(self, capsys): - get_universal_table([], {}, "test", "out", "/tmp", False, framework=None) + def test_returns_early_without_framework(self, capsys, tmp_path): + get_universal_table([], {}, "test", "out", str(tmp_path), False, framework=None) captured = capsys.readouterr() assert captured.out == "" diff --git a/tests/lib/outputs/finding_test.py b/tests/lib/outputs/finding_test.py index 0f1cb14e25..f843cb8f00 100644 --- a/tests/lib/outputs/finding_test.py +++ b/tests/lib/outputs/finding_test.py @@ -761,6 +761,87 @@ class TestFinding: assert finding_output.metadata.Severity == Severity.high assert finding_output.metadata.ResourceType == "mock_resource_type" + def _build_linode_check_output(self): + check_output = MagicMock() + check_output.resource_id = "12345" + check_output.resource_name = "test-instance" + check_output.resource_details = "" + check_output.resource_tags = {} + check_output.region = "us-east" + check_output.status = Status.PASS + check_output.status_extended = "Instance is compliant" + check_output.muted = False + check_output.check_metadata = mock_check_metadata(provider="linode") + check_output.resource = {} + check_output.compliance = {} + return check_output + + def test_generate_output_linode(self): + """Test Linode output generation when the account ID is available.""" + from prowler.providers.linode.models import LinodeIdentityInfo + + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username="admin", + email="admin@example.com", + account_id="E1AF1B6C-1111-2222-3333-444455556666", + ) + + check_output = self._build_linode_check_output() + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert finding_output.provider == "linode" + assert finding_output.auth_method == "api_token" + assert finding_output.account_uid == "E1AF1B6C-1111-2222-3333-444455556666" + assert finding_output.account_name == "admin" + + def test_generate_output_linode_without_account_id_falls_back_to_username(self): + """account_uid is required; when account_id is None it must fall back to + the username so findings are never silently dropped.""" + from prowler.providers.linode.models import LinodeIdentityInfo + + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username="admin", + email="admin@example.com", + account_id=None, + ) + + check_output = self._build_linode_check_output() + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + # Must not raise a ValidationError and must use the username fallback + assert finding_output.account_uid == "admin" + + def test_generate_output_linode_without_account_id_or_username(self): + """When neither account_id nor username/email is available, account_uid + falls back to the literal provider name.""" + from prowler.providers.linode.models import LinodeIdentityInfo + + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username=None, + email=None, + account_id=None, + ) + + check_output = self._build_linode_check_output() + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert finding_output.account_uid == "linode" + def test_generate_output_iac_remote(self): # Mock provider provider = MagicMock() diff --git a/tests/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm_test.py b/tests/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm_test.py new file mode 100644 index 0000000000..1685115755 --- /dev/null +++ b/tests/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm_test.py @@ -0,0 +1,157 @@ +from unittest import mock + +from prowler.providers.aws.services.acmpca.acmpca_service import CertificateAuthority +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CA_ID = "12345678-1234-1234-1234-123456789012" +CA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{CA_ID}" + + +def _build_client(certificate_authorities, audit_config=None): + acmpca_client = mock.MagicMock() + acmpca_client.certificate_authorities = certificate_authorities + acmpca_client.audit_config = audit_config or {} + return acmpca_client + + +def _ca(key_algorithm: str, status: str = "ACTIVE"): + return CertificateAuthority( + arn=CA_ARN, + id=CA_ID, + region=AWS_REGION_US_EAST_1, + status=status, + type="SUBORDINATE", + usage_mode="GENERAL_PURPOSE", + key_algorithm=key_algorithm, + signing_algorithm="ML_DSA_65" if "ML_DSA" in key_algorithm else "SHA256WITHRSA", + ) + + +class Test_acmpca_certificate_authority_pqc_key_algorithm: + def test_no_cas(self): + acmpca_client = _build_client({}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + assert len(result) == 0 + + def test_ml_dsa_65(self): + acmpca_client = _build_client({CA_ARN: _ca("ML_DSA_65")}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "ML_DSA_65" in result[0].status_extended + assert result[0].resource_id == CA_ID + assert result[0].resource_arn == CA_ARN + + def test_rsa_2048_fails(self): + acmpca_client = _build_client({CA_ARN: _ca("RSA_2048")}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "RSA_2048" in result[0].status_extended + + def test_deleted_ca_skipped(self): + acmpca_client = _build_client({CA_ARN: _ca("RSA_2048", status="DELETED")}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 0 + + def test_configurable_allowlist(self): + acmpca_client = _build_client( + {CA_ARN: _ca("RSA_2048")}, + audit_config={"acmpca_pqc_key_algorithms": ["ML_DSA_65", "RSA_2048"]}, + ) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/acmpca/acmpca_service_test.py b/tests/providers/aws/services/acmpca/acmpca_service_test.py new file mode 100644 index 0000000000..024df5e5a2 --- /dev/null +++ b/tests/providers/aws/services/acmpca/acmpca_service_test.py @@ -0,0 +1,65 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from prowler.providers.aws.services.acmpca.acmpca_service import ( + ACMPCA, + CertificateAuthority, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CA_ID = "12345678-1234-1234-1234-123456789012" +CA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{CA_ID}" + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListCertificateAuthorities": + return { + "CertificateAuthorities": [ + { + "Arn": CA_ARN, + "Status": "ACTIVE", + "Type": "SUBORDINATE", + "UsageMode": "GENERAL_PURPOSE", + "CertificateAuthorityConfiguration": { + "KeyAlgorithm": "ML_DSA_65", + "SigningAlgorithm": "ML_DSA_65", + }, + } + ] + } + if operation_name == "ListTags": + assert kwarg["CertificateAuthorityArn"] == CA_ARN + return {"Tags": [{"Key": "Environment", "Value": "test"}]} + return make_api_call(self, operation_name, kwarg) + + +class Test_ACMPCA_Service: + @mock_aws + def test_service(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + acmpca = ACMPCA(aws_provider) + assert acmpca.service == "acm-pca" + + @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + @mock_aws + def test_list_certificate_authorities(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + acmpca = ACMPCA(aws_provider) + assert len(acmpca.certificate_authorities) == 1 + ca = acmpca.certificate_authorities[CA_ARN] + assert isinstance(ca, CertificateAuthority) + assert ca.id == CA_ID + assert ca.region == AWS_REGION_US_EAST_1 + assert ca.status == "ACTIVE" + assert ca.type == "SUBORDINATE" + assert ca.key_algorithm == "ML_DSA_65" + assert ca.signing_algorithm == "ML_DSA_65" + assert ca.tags == [{"Key": "Environment", "Value": "test"}] diff --git a/tests/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled_test.py b/tests/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled_test.py new file mode 100644 index 0000000000..6d460be32e --- /dev/null +++ b/tests/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled_test.py @@ -0,0 +1,243 @@ +from unittest import mock + +from moto import mock_aws + +from prowler.providers.aws.services.apigateway.apigateway_service import DomainName +from tests.providers.aws.utils import ( + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +DOMAIN_NAME = "api.example.com" +DOMAIN_ARN = f"arn:aws:apigateway:{AWS_REGION_US_EAST_1}::/domainnames/{DOMAIN_NAME}" + + +def _build_client(security_policy: str): + apigw_client = mock.MagicMock() + apigw_client.audit_config = {} + apigw_client.domain_names = [ + DomainName( + name=DOMAIN_NAME, + arn=DOMAIN_ARN, + region=AWS_REGION_US_EAST_1, + security_policy=security_policy, + ) + ] + return apigw_client + + +class Test_apigateway_domain_name_pqc_tls_enabled: + @mock_aws + def test_no_domains(self): + apigw_client = mock.MagicMock() + apigw_client.audit_config = {} + apigw_client.domain_names = [] + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_tls13_only_policy_fails_by_default(self): + apigw_client = _build_client("SecurityPolicy_TLS13_1_3_2025_09") + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "SecurityPolicy_TLS13_1_3_2025_09" in result[0].status_extended + assert "not in the post-quantum allowlist" in result[0].status_extended + assert result[0].resource_id == DOMAIN_NAME + assert result[0].resource_arn == DOMAIN_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_alternate_pq_policy(self): + apigw_client = _build_client("SecurityPolicy_TLS13_1_2_PQ_2025_09") + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "SecurityPolicy_TLS13_1_2_PQ_2025_09" in result[0].status_extended + ) + + @mock_aws + def test_legacy_tls_1_2(self): + apigw_client = _build_client("TLS_1_2") + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TLS_1_2" in result[0].status_extended + assert "not in the post-quantum allowlist" in result[0].status_extended + + @mock_aws + def test_missing_security_policy(self): + apigw_client = _build_client("") + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "" in result[0].status_extended + + @mock_aws + def test_configurable_allowlist(self): + apigw_client = _build_client("TLS_1_2") + apigw_client.audit_config = { + "apigateway_pqc_tls_allowed_policies": [ + "TLS_1_2", + ] + } + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_null_config_uses_default_allowlist(self): + apigw_client = _build_client("SecurityPolicy_TLS13_1_2_PQ_2025_09") + apigw_client.audit_config = { + "apigateway_pqc_tls_allowed_policies": None, + } + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_non_iterable_config_uses_default_allowlist(self): + apigw_client = _build_client("SecurityPolicy_TLS13_1_2_PQ_2025_09") + apigw_client.audit_config = { + "apigateway_pqc_tls_allowed_policies": 123, + } + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/apigateway/apigateway_service_test.py b/tests/providers/aws/services/apigateway/apigateway_service_test.py index 9396693ec3..c153af2871 100644 --- a/tests/providers/aws/services/apigateway/apigateway_service_test.py +++ b/tests/providers/aws/services/apigateway/apigateway_service_test.py @@ -206,3 +206,26 @@ class Test_APIGateway_Service: assert list(apigateway.rest_apis[0].resources[1].resource_methods.values()) == [ "AWS_IAM" ] + + # Test APIGateway _get_domain_names + @mock_aws + def test_get_domain_names(self): + apigateway_client = client("apigateway", region_name=AWS_REGION_US_EAST_1) + + apigateway_client.create_domain_name( + domainName="api.example.com", + securityPolicy="SecurityPolicy_TLS13_1_3_2025_09", + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + apigateway = APIGateway(aws_provider) + + assert len(apigateway.domain_names) == 1 + domain = apigateway.domain_names[0] + assert domain.name == "api.example.com" + assert domain.region == AWS_REGION_US_EAST_1 + assert domain.security_policy == "SecurityPolicy_TLS13_1_3_2025_09" + assert ( + domain.arn + == f"arn:aws:apigateway:{AWS_REGION_US_EAST_1}::/domainnames/api.example.com" + ) diff --git a/tests/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled_test.py b/tests/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled_test.py new file mode 100644 index 0000000000..5170d5538d --- /dev/null +++ b/tests/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled_test.py @@ -0,0 +1,158 @@ +import sys +from unittest import mock + +from prowler.providers.aws.services.cloudfront.cloudfront_service import ( + Distribution, + Origin, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +DISTRIBUTION_ID = "E27LVI50CSW06W" +DISTRIBUTION_ARN = ( + f"arn:aws:cloudfront::{AWS_ACCOUNT_NUMBER}:distribution/{DISTRIBUTION_ID}" +) +REGION = "us-east-1" +CHECK_MODULE = "prowler.providers.aws.services.cloudfront.cloudfront_distributions_pqc_tls_enabled.cloudfront_distributions_pqc_tls_enabled" +CLIENT_MODULE = "prowler.providers.aws.services.cloudfront.cloudfront_client" + + +def _clear_cloudfront_modules(): + sys.modules.pop(CHECK_MODULE, None) + sys.modules.pop(CLIENT_MODULE, None) + + +def _build_distribution( + *, + minimum_protocol_version: str, + default_certificate: bool = False, +): + return Distribution( + arn=DISTRIBUTION_ARN, + id=DISTRIBUTION_ID, + region=REGION, + origins=[ + Origin( + id="o1", + domain_name="origin.example.com", + origin_protocol_policy="https-only", + origin_ssl_protocols=["TLSv1.2"], + ) + ], + origin_failover=False, + minimum_protocol_version=minimum_protocol_version, + default_certificate=default_certificate, + ) + + +def _build_client(distributions: dict, audit_config: dict | None = None): + cloudfront_client = mock.MagicMock() + cloudfront_client.distributions = distributions + cloudfront_client.audit_config = audit_config or {} + return cloudfront_client + + +def _execute_check(cloudfront_client): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + _clear_cloudfront_modules() + + try: + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudfront.cloudfront_service.CloudFront", + return_value=cloudfront_client, + ), + ): + from prowler.providers.aws.services.cloudfront.cloudfront_distributions_pqc_tls_enabled.cloudfront_distributions_pqc_tls_enabled import ( + cloudfront_distributions_pqc_tls_enabled, + ) + + check = cloudfront_distributions_pqc_tls_enabled() + return check.execute() + finally: + _clear_cloudfront_modules() + + +class Test_cloudfront_distributions_pqc_tls_enabled: + def test_no_distributions(self): + cloudfront_client = _build_client({}) + + result = _execute_check(cloudfront_client) + + assert len(result) == 0 + + def test_pq_policy_tls13_2025(self): + cloudfront_client = _build_client( + { + DISTRIBUTION_ID: _build_distribution( + minimum_protocol_version="TLSv1.3_2025" + ) + } + ) + + result = _execute_check(cloudfront_client) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "TLSv1.3_2025" in result[0].status_extended + assert result[0].resource_id == DISTRIBUTION_ID + assert result[0].resource_arn == DISTRIBUTION_ARN + + def test_classical_tls12_2021(self): + cloudfront_client = _build_client( + { + DISTRIBUTION_ID: _build_distribution( + minimum_protocol_version="TLSv1.2_2021" + ) + } + ) + + result = _execute_check(cloudfront_client) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TLSv1.2_2021" in result[0].status_extended + assert "not in the post-quantum allowlist" in result[0].status_extended + + def test_default_cloudfront_certificate(self): + cloudfront_client = _build_client( + { + DISTRIBUTION_ID: _build_distribution( + minimum_protocol_version="TLSv1", + default_certificate=True, + ) + } + ) + + result = _execute_check(cloudfront_client) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "default CloudFront certificate" in result[0].status_extended + + def test_configurable_allowlist(self): + cloudfront_client = _build_client( + { + DISTRIBUTION_ID: _build_distribution( + minimum_protocol_version="TLSv1.2_2021" + ) + }, + audit_config={ + "cloudfront_pqc_min_protocol_versions": [ + "TLSv1.3_2025", + "TLSv1.2_2021", + ] + }, + ) + + result = _execute_check(cloudfront_client) + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/cloudfront/cloudfront_service_test.py b/tests/providers/aws/services/cloudfront/cloudfront_service_test.py index 4c0a9b349e..5dd6f712a7 100644 --- a/tests/providers/aws/services/cloudfront/cloudfront_service_test.py +++ b/tests/providers/aws/services/cloudfront/cloudfront_service_test.py @@ -64,6 +64,7 @@ def example_distribution_config(ref): "ViewerCertificate": { "SSLSupportMethod": "static-ip", "Certificate": "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012", + "MinimumProtocolVersion": "TLSv1.3_2025", }, "Comment": "an optional comment that's not actually optional", "Enabled": False, @@ -234,6 +235,7 @@ class Test_CloudFront_Service: ] SSL_SUPPORT_METHOD = SSLSupportMethod.sni_only CERTIFICATE = "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012" + MINIMUM_PROTOCOL_VERSION = "TLSv1.3_2025" cloudfront = mock.MagicMock cloudfront.distributions = { @@ -249,6 +251,7 @@ class Test_CloudFront_Service: tags=TAGS, ssl_support_method=SSL_SUPPORT_METHOD, certificate=CERTIFICATE, + minimum_protocol_version=MINIMUM_PROTOCOL_VERSION, ) } @@ -288,6 +291,10 @@ class Test_CloudFront_Service: == DEFAULT_CACHE_CONFIG.field_level_encryption_id ) assert cloudfront.distributions[DISTRIBUTION_ID].tags == TAGS + assert ( + cloudfront.distributions[DISTRIBUTION_ID].minimum_protocol_version + == MINIMUM_PROTOCOL_VERSION + ) def test_get_log_delivery_sources_with_active_delivery(self): from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER diff --git a/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py b/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py new file mode 100644 index 0000000000..d21d4e20b7 --- /dev/null +++ b/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py @@ -0,0 +1,92 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import ( + RolesAnywhere, + TrustAnchor, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +TA_ID = "11111111-2222-3333-4444-555555555555" +TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}" +PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/abc" + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListTrustAnchors": + return { + "trustAnchors": [ + { + "trustAnchorArn": TA_ARN, + "trustAnchorId": TA_ID, + "name": "pqc-trust", + "enabled": True, + "source": { + "sourceType": "AWS_ACM_PCA", + "sourceData": {"acmPcaArn": PCA_ARN}, + }, + } + ] + } + if operation_name == "ListTagsForResource": + return {"tags": [{"key": "Environment", "value": "test"}]} + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_tags_failure(self, operation_name, kwarg): + if operation_name == "ListTagsForResource": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "Access denied", + } + }, + operation_name, + ) + return mock_make_api_call(self, operation_name, kwarg) + + +class Test_RolesAnywhere_Service: + @mock_aws + def test_service(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + rolesanywhere = RolesAnywhere(aws_provider) + assert rolesanywhere.service == "rolesanywhere" + + @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + @mock_aws + def test_list_trust_anchors(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + rolesanywhere = RolesAnywhere(aws_provider) + assert len(rolesanywhere.trust_anchors) == 1 + ta = rolesanywhere.trust_anchors[TA_ARN] + assert isinstance(ta, TrustAnchor) + assert ta.id == TA_ID + assert ta.name == "pqc-trust" + assert ta.enabled is True + assert ta.source_type == "AWS_ACM_PCA" + assert ta.acm_pca_arn == PCA_ARN + assert ta.region == AWS_REGION_US_EAST_1 + assert ta.tags == [{"key": "Environment", "value": "test"}] + + @patch( + "botocore.client.BaseClient._make_api_call", new=mock_make_api_call_tags_failure + ) + @mock_aws + def test_list_trust_anchors_continues_when_tags_fail(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + rolesanywhere = RolesAnywhere(aws_provider) + assert len(rolesanywhere.trust_anchors) == 1 + ta = rolesanywhere.trust_anchors[TA_ARN] + assert isinstance(ta, TrustAnchor) + assert ta.id == TA_ID + assert ta.tags == [] diff --git a/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py b/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py new file mode 100644 index 0000000000..2038fdde57 --- /dev/null +++ b/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py @@ -0,0 +1,203 @@ +from unittest import mock + +from prowler.providers.aws.services.acmpca.acmpca_service import CertificateAuthority +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import ( + TrustAnchor, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +TA_ID = "11111111-2222-3333-4444-555555555555" +TA_NAME = "pqc-trust" +TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}" +PCA_ID = "12345678-1234-1234-1234-123456789012" +PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{PCA_ID}" + + +def _trust_anchor(*, source_type: str, acm_pca_arn: str = ""): + return TrustAnchor( + arn=TA_ARN, + id=TA_ID, + name=TA_NAME, + region=AWS_REGION_US_EAST_1, + enabled=True, + source_type=source_type, + acm_pca_arn=acm_pca_arn, + ) + + +def _ca(key_algorithm: str, status: str = "ACTIVE"): + return CertificateAuthority( + arn=PCA_ARN, + id=PCA_ID, + region=AWS_REGION_US_EAST_1, + status=status, + type="SUBORDINATE", + usage_mode="GENERAL_PURPOSE", + key_algorithm=key_algorithm, + signing_algorithm=( + key_algorithm if "ML_DSA" in key_algorithm else "SHA256WITHRSA" + ), + ) + + +def _build_clients(trust_anchors, certificate_authorities=None, audit_config=None): + ra_client = mock.MagicMock() + ra_client.trust_anchors = trust_anchors + ra_client.audit_config = audit_config or {} + pca_client = mock.MagicMock() + pca_client.certificate_authorities = certificate_authorities or {} + return ra_client, pca_client + + +def _patched(ra_client, pca_client): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + return [ + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_client", + new=ra_client, + ), + mock.patch( + "prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki.acmpca_client", + new=pca_client, + ), + ] + + +def _enter(patches): + from contextlib import ExitStack + + stack = ExitStack() + for p in patches: + stack.enter_context(p) + return stack + + +class Test_rolesanywhere_trust_anchor_pqc_pki: + def test_no_trust_anchors(self): + ra_client, pca_client = _build_clients({}) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 0 + + def test_pca_backed_pqc(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("ML_DSA_65")}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "ML_DSA_65" in result[0].status_extended + assert result[0].resource_id == TA_ID + assert result[0].resource_arn == TA_ARN + + def test_pca_backed_custom_allowlist_pqc(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("CUSTOM_PQC")}, + audit_config={"rolesanywhere_pqc_pca_key_algorithms": ["CUSTOM_PQC"]}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "CUSTOM_PQC" in result[0].status_extended + assert result[0].resource_id == TA_ID + assert result[0].resource_arn == TA_ARN + + def test_custom_allowlist_replaces_default(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("ML_DSA_65")}, + audit_config={"rolesanywhere_pqc_pca_key_algorithms": ["ML_DSA_87"]}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ML_DSA_65" in result[0].status_extended + assert "not post-quantum (ML-DSA)" in result[0].status_extended + + def test_pca_backed_rsa(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("RSA_2048")}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "RSA_2048" in result[0].status_extended + + def test_pca_backed_inactive_pqc_ca(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("ML_DSA_65", status="DISABLED")}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "DISABLED status" in result[0].status_extended + + def test_pca_not_in_inventory(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "could not be inspected" in result[0].status_extended + + def test_certificate_bundle_source(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="CERTIFICATE_BUNDLE")}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "CERTIFICATE_BUNDLE" in result[0].status_extended diff --git a/tests/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled_test.py b/tests/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled_test.py new file mode 100644 index 0000000000..d1a098f0ad --- /dev/null +++ b/tests/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled_test.py @@ -0,0 +1,215 @@ +from unittest import mock +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +SERVER_ID = "s-01234567890abcdef" +SERVER_ARN = ( + f"arn:aws:transfer:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:server/{SERVER_ID}" +) + +make_api_call = botocore.client.BaseClient._make_api_call + + +def _make_describe_server_mock(security_policy_name: str): + def _mock(self, operation_name, kwarg): + if operation_name == "ListServers": + return { + "Servers": [ + { + "Arn": SERVER_ARN, + "ServerId": SERVER_ID, + } + ] + } + if operation_name == "DescribeServer": + return { + "Server": { + "Arn": SERVER_ARN, + "ServerId": SERVER_ID, + "Protocols": ["SFTP"], + "SecurityPolicyName": security_policy_name, + } + } + return make_api_call(self, operation_name, kwarg) + + return _mock + + +mock_pqc = _make_describe_server_mock("TransferSecurityPolicy-2025-03") +mock_fips_pqc = _make_describe_server_mock("TransferSecurityPolicy-FIPS-2025-03") +mock_classical = _make_describe_server_mock("TransferSecurityPolicy-2024-01") +mock_no_policy = _make_describe_server_mock("") + + +class Test_transfer_server_pqc_ssh_kex_enabled: + @mock_aws + def test_no_servers(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 0 + + @patch("botocore.client.BaseClient._make_api_call", new=mock_pqc) + @mock_aws + def test_pq_policy(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "TransferSecurityPolicy-2025-03" in result[0].status_extended + assert result[0].resource_id == SERVER_ID + assert result[0].resource_arn == SERVER_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @patch("botocore.client.BaseClient._make_api_call", new=mock_fips_pqc) + @mock_aws + def test_fips_pq_policy(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "FIPS-2025-03" in result[0].status_extended + + @patch("botocore.client.BaseClient._make_api_call", new=mock_classical) + @mock_aws + def test_classical_policy(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TransferSecurityPolicy-2024-01" in result[0].status_extended + assert "does not enable post-quantum" in result[0].status_extended + + @patch("botocore.client.BaseClient._make_api_call", new=mock_no_policy) + @mock_aws + def test_missing_policy(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "" in result[0].status_extended + + @patch("botocore.client.BaseClient._make_api_call", new=mock_classical) + @mock_aws + def test_configurable_allowlist(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], + audit_config={ + "transfer_pqc_ssh_allowed_policies": [ + "TransferSecurityPolicy-2025-03", + "TransferSecurityPolicy-2024-01", + ] + }, + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/transfer/transfer_service_test.py b/tests/providers/aws/services/transfer/transfer_service_test.py index 61a963ab09..7a08e66c92 100644 --- a/tests/providers/aws/services/transfer/transfer_service_test.py +++ b/tests/providers/aws/services/transfer/transfer_service_test.py @@ -35,6 +35,7 @@ def mock_make_api_call(self, operation_name, kwarg): "Arn": SERVER_ARN, "ServerId": SERVER_ID, "Protocols": ["SFTP"], + "SecurityPolicyName": "TransferSecurityPolicy-2025-03", "Tags": [{"Key": "key", "Value": "value"}], } } @@ -83,3 +84,7 @@ class Test_transfer_service: assert transfer.servers[SERVER_ARN].region == "us-east-1" assert transfer.servers[SERVER_ARN].tags == [{"Key": "key", "Value": "value"}] assert transfer.servers[SERVER_ARN].protocols[0] == Protocol.SFTP + assert ( + transfer.servers[SERVER_ARN].security_policy_name + == "TransferSecurityPolicy-2025-03" + ) diff --git a/tests/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled_test.py b/tests/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled_test.py new file mode 100644 index 0000000000..a209a50199 --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled_test.py @@ -0,0 +1,121 @@ +from importlib import import_module +from unittest import mock +from uuid import uuid4 + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +CHECK_MODULE = ( + "prowler.providers.azure.services.aks.aks_cluster_azure_monitor_enabled." + "aks_cluster_azure_monitor_enabled" +) +CHECK_CLIENT_PATCH = f"{CHECK_MODULE}.aks_client" + + +def get_check_class(): + return import_module(CHECK_MODULE).aks_cluster_azure_monitor_enabled + + +def build_cluster(azure_monitor_enabled): + return Cluster( + id=str(uuid4()), + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + azure_monitor_enabled=azure_monitor_enabled, + ) + + +class Test_aks_cluster_azure_monitor_enabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_azure_monitor_enabled = get_check_class() + + aks_client.clusters = {} + + check = aks_cluster_azure_monitor_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_azure_monitor_enabled = get_check_class() + + cluster = build_cluster(azure_monitor_enabled=True) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_azure_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has Azure Monitor managed Prometheus metrics enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + @pytest.mark.parametrize("azure_monitor_enabled", [False, None]) + def test_fail(self, azure_monitor_enabled): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_azure_monitor_enabled = get_check_class() + + cluster = build_cluster(azure_monitor_enabled=azure_monitor_enabled) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_azure_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' does not have Azure Monitor managed Prometheus metrics enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py b/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py new file mode 100644 index 0000000000..0575b67a4a --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py @@ -0,0 +1,122 @@ +from importlib import import_module +from unittest import mock +from uuid import uuid4 + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +CHECK_MODULE = ( + "prowler.providers.azure.services.aks.aks_cluster_defender_enabled." + "aks_cluster_defender_enabled" +) +CHECK_CLIENT_PATCH = f"{CHECK_MODULE}.aks_client" + + +def get_check_class(): + return import_module(CHECK_MODULE).aks_cluster_defender_enabled + + +def build_cluster(defender_enabled): + return Cluster( + id=str(uuid4()), + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + defender_enabled=defender_enabled, + ) + + +class Test_aks_cluster_defender_enabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + aks_client.clusters = {} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + cluster = build_cluster(defender_enabled=True) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has Defender for Containers enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + @pytest.mark.parametrize("defender_enabled", [False, None, "true"]) + def test_fail(self, defender_enabled): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + cluster = build_cluster(defender_enabled=defender_enabled) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' does not have Defender for " + "Containers enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled_test.py b/tests/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled_test.py new file mode 100644 index 0000000000..a4c63dc172 --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled_test.py @@ -0,0 +1,121 @@ +from importlib import import_module +from unittest import mock +from uuid import uuid4 + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +CHECK_MODULE = ( + "prowler.providers.azure.services.aks.aks_cluster_local_accounts_disabled." + "aks_cluster_local_accounts_disabled" +) +CHECK_CLIENT_PATCH = f"{CHECK_MODULE}.aks_client" + + +def get_check_class(): + return import_module(CHECK_MODULE).aks_cluster_local_accounts_disabled + + +def build_cluster(local_accounts_disabled): + return Cluster( + id=str(uuid4()), + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + local_accounts_disabled=local_accounts_disabled, + ) + + +class Test_aks_cluster_local_accounts_disabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_local_accounts_disabled = get_check_class() + + aks_client.clusters = {} + + check = aks_cluster_local_accounts_disabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_local_accounts_disabled = get_check_class() + + cluster = build_cluster(local_accounts_disabled=True) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_local_accounts_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has local accounts disabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + @pytest.mark.parametrize("local_accounts_disabled", [False, None]) + def test_fail(self, local_accounts_disabled): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_local_accounts_disabled = get_check_class() + + cluster = build_cluster(local_accounts_disabled=local_accounts_disabled) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_local_accounts_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has local accounts enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/databricks/databricks_service_test.py b/tests/providers/azure/services/databricks/databricks_service_test.py index 5c743d1891..f663d81fe2 100644 --- a/tests/providers/azure/services/databricks/databricks_service_test.py +++ b/tests/providers/azure/services/databricks/databricks_service_test.py @@ -19,6 +19,7 @@ def mock_databricks_get_workspaces(_): name="test-workspace", location="eastus", public_network_access="Disabled", + no_public_ip_enabled=True, custom_managed_vnet_id="test-vnet-id", managed_disk_encryption=ManagedDiskEncryption( key_name="test-key", @@ -55,6 +56,7 @@ class Test_Databricks_Service: assert workspace.name == "test-workspace" assert workspace.location == "eastus" assert workspace.public_network_access == "Disabled" + assert workspace.no_public_ip_enabled is True assert workspace.custom_managed_vnet_id == "test-vnet-id" assert ( workspace.managed_disk_encryption.__class__.__name__ diff --git a/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py b/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py new file mode 100644 index 0000000000..a7903770c6 --- /dev/null +++ b/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py @@ -0,0 +1,174 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.databricks.databricks_service import ( + DatabricksWorkspace, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_databricks_workspace_no_public_ip_enabled: + def test_databricks_no_workspaces(self): + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 0 + + def test_databricks_workspace_no_public_ip_disabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=False, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have secure cluster connectivity (no public IP) enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_no_public_ip_enabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=True, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has secure cluster connectivity (no public IP) enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_no_public_ip_not_reported(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not expose secure cluster connectivity (no public IP) settings (for example, serverless workspaces have no public-IP cluster nodes); verify the network configuration manually." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py new file mode 100644 index 0000000000..b81d10d31d --- /dev/null +++ b/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py @@ -0,0 +1,117 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.defender.defender_service import Pricing +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_defender_ensure_defender_cspm_is_on: + def test_defender_no_cspm(self): + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 0 + + def test_defender_cspm_pricing_tier_not_standard(self): + resource_id = str(uuid4()) + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = { + AZURE_SUBSCRIPTION_ID: { + "CloudPosture": Pricing( + resource_id=resource_id, + resource_name="Defender plan CSPM", + pricing_tier="Free", + free_trial_remaining_time=0, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Defender plan CSPM from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "Defender plan CSPM" + assert result[0].resource_id == resource_id + + def test_defender_cspm_pricing_tier_standard(self): + resource_id = str(uuid4()) + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = { + AZURE_SUBSCRIPTION_ID: { + "CloudPosture": Pricing( + resource_id=resource_id, + resource_name="Defender plan CSPM", + pricing_tier="Standard", + free_trial_remaining_time=0, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Defender plan CSPM from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "Defender plan CSPM" + assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired_test.py b/tests/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired_test.py new file mode 100644 index 0000000000..89fa5bac16 --- /dev/null +++ b/tests/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired_test.py @@ -0,0 +1,269 @@ +from datetime import datetime, timezone, timedelta +from unittest import mock +from uuid import uuid4 + +from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider + + +class Test_entra_app_registration_credential_not_expired: + def test_entra_no_tenants(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + + entra_client.app_registrations = {} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 0 + + def test_entra_app_no_credentials(self): + entra_client = mock.MagicMock + app_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppRegistration, + ) + + app = AppRegistration(id=app_id, name="no-creds-app", credentials=[]) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 0 + + def test_entra_app_credential_expired(self): + entra_client = mock.MagicMock + app_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="expired-app", + credentials=[ + AppCredential( + display_name="old-secret", + credential_type="password", + end_date_time=datetime.now(timezone.utc) - timedelta(days=30), + ) + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "expired" in result[0].status_extended + + def test_entra_app_credential_expiring_soon(self): + entra_client = mock.MagicMock + app_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="expiring-soon-app", + credentials=[ + AppCredential( + display_name="expiring-cert", + credential_type="certificate", + end_date_time=datetime.now(timezone.utc) + timedelta(days=15), + ) + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "expiring in" in result[0].status_extended + + def test_entra_app_credential_no_expiration(self): + entra_client = mock.MagicMock + app_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="no-expiry-app", + credentials=[ + AppCredential( + display_name="forever-secret", + credential_type="password", + end_date_time=None, + ) + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration" in result[0].status_extended + + def test_entra_app_credential_valid(self): + entra_client = mock.MagicMock + app_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="healthy-app", + credentials=[ + AppCredential( + display_name="good-secret", + credential_type="password", + end_date_time=datetime.now(timezone.utc) + timedelta(days=180), + ) + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "more days" in result[0].status_extended + + def test_entra_app_multiple_credentials_mixed(self): + entra_client = mock.MagicMock + app_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="mixed-app", + credentials=[ + AppCredential( + display_name="expired-one", + credential_type="password", + end_date_time=datetime.now(timezone.utc) - timedelta(days=10), + ), + AppCredential( + display_name="valid-one", + credential_type="certificate", + end_date_time=datetime.now(timezone.utc) + timedelta(days=200), + ), + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 2 + statuses = {r.status for r in result} + assert "FAIL" in statuses + assert "PASS" in statuses diff --git a/tests/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced_test.py b/tests/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced_test.py new file mode 100644 index 0000000000..e3102bf3e9 --- /dev/null +++ b/tests/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced_test.py @@ -0,0 +1,205 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider + +CHECK_MODULE = ( + "prowler.providers.azure.services.entra." + "entra_authentication_methods_policy_strong_auth_enforced." + "entra_authentication_methods_policy_strong_auth_enforced" +) + + +class Test_entra_authentication_methods_policy_strong_auth_enforced: + def test_entra_no_tenants(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + + entra_client.authentication_methods_policy = {} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 0 + + def test_entra_policy_none(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + + entra_client.authentication_methods_policy = {DOMAIN: None} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 0 + + def test_entra_registration_enabled_strong_method_enabled(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AuthMethodConfig, + AuthMethodsPolicy, + ) + + policy = AuthMethodsPolicy( + id="authMethodsPolicy", + registration_enforcement_state="enabled", + method_configurations=[ + AuthMethodConfig( + id="MicrosoftAuthenticator", + method_name="microsoftAuthenticator", + state="enabled", + ), + AuthMethodConfig(id="Sms", method_name="sms", state="enabled"), + ], + ) + entra_client.authentication_methods_policy = {DOMAIN: policy} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Authentication Methods Policy" + assert result[0].resource_id == "authMethodsPolicy" + assert "is enforced" in result[0].status_extended + assert "microsoftAuthenticator" in result[0].status_extended + + def test_entra_registration_disabled_no_strong_methods(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AuthMethodConfig, + AuthMethodsPolicy, + ) + + policy = AuthMethodsPolicy( + id="authMethodsPolicy", + registration_enforcement_state="disabled", + method_configurations=[ + AuthMethodConfig(id="Sms", method_name="sms", state="enabled"), + AuthMethodConfig(id="Voice", method_name="voice", state="enabled"), + ], + ) + entra_client.authentication_methods_policy = {DOMAIN: policy} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is not enforced" in result[0].status_extended + assert "registration campaign is not enabled" in result[0].status_extended + assert "no strong authentication methods" in result[0].status_extended + + def test_entra_registration_disabled_strong_method_enabled(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AuthMethodConfig, + AuthMethodsPolicy, + ) + + policy = AuthMethodsPolicy( + id="authMethodsPolicy", + registration_enforcement_state="disabled", + method_configurations=[ + AuthMethodConfig(id="Fido2", method_name="fido2", state="enabled"), + ], + ) + entra_client.authentication_methods_policy = {DOMAIN: policy} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + # Strong method present but registration campaign off -> not enforced + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "registration campaign is not enabled" in result[0].status_extended + assert "no strong authentication methods" not in result[0].status_extended + + def test_entra_multiple_strong_methods(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AuthMethodConfig, + AuthMethodsPolicy, + ) + + policy = AuthMethodsPolicy( + id="authMethodsPolicy", + registration_enforcement_state="enabled", + method_configurations=[ + AuthMethodConfig( + id="MicrosoftAuthenticator", + method_name="microsoftAuthenticator", + state="enabled", + ), + AuthMethodConfig(id="Fido2", method_name="fido2", state="enabled"), + AuthMethodConfig( + id="x509", method_name="x509Certificate", state="enabled" + ), + ], + ) + entra_client.authentication_methods_policy = {DOMAIN: policy} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "microsoftAuthenticator" in result[0].status_extended + assert "fido2" in result[0].status_extended diff --git a/tests/providers/azure/services/entra/entra_service_test.py b/tests/providers/azure/services/entra/entra_service_test.py index 75ef4f98c4..ebd2b790ab 100644 --- a/tests/providers/azure/services/entra/entra_service_test.py +++ b/tests/providers/azure/services/entra/entra_service_test.py @@ -294,6 +294,7 @@ def test_azure_entra__get_users_handles_pagination(): "id", "displayName", "accountEnabled", + "signInActivity", ] with_url_mock.assert_called_once_with("next-link") registration_details_builder.get.assert_awaited() diff --git a/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py b/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py new file mode 100644 index 0000000000..f940cf232c --- /dev/null +++ b/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py @@ -0,0 +1,321 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock +from uuid import uuid4 + +from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider + + +class Test_entra_user_with_recent_sign_in: + def test_entra_no_tenants(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + entra_client.users = {} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 0 + + def test_entra_user_disabled(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="disabled-user", + account_enabled=False, + last_sign_in=None, + ) + + entra_client.users = {DOMAIN: {f"disabled-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 0 + + def test_entra_user_never_signed_in(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="never-signed-in", + account_enabled=True, + last_sign_in=None, + ) + + entra_client.users = {DOMAIN: {f"never-signed-in@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "No sign-in activity data available" in result[0].status_extended + + def test_entra_single_user_no_sign_in_data_reports_telemetry_gap(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="single-user", + account_enabled=True, + last_sign_in=None, + ) + + entra_client.users = {DOMAIN: {f"single-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "No sign-in activity data available" in result[0].status_extended + assert "1 enabled user" in result[0].status_extended + + def test_entra_user_stale_sign_in(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="stale-user", + account_enabled=True, + last_sign_in=datetime.now(timezone.utc) - timedelta(days=120), + ) + + entra_client.users = {DOMAIN: {f"stale-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "120 days" in result[0].status_extended + + def test_entra_user_recent_sign_in(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="active-user", + account_enabled=True, + last_sign_in=datetime.now(timezone.utc) - timedelta(days=10), + ) + + entra_client.users = {DOMAIN: {f"active-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "10 days ago" in result[0].status_extended + + def test_entra_all_users_no_sign_in_data_license_issue(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + # Multiple enabled users, ALL with no sign-in data = license issue + users = {} + for i in range(5): + uid = str(uuid4()) + users[f"user{i}@{DOMAIN}"] = User( + id=uid, + name=f"user{i}", + account_enabled=True, + last_sign_in=None, + ) + + entra_client.users = {DOMAIN: users} + + check = entra_user_with_recent_sign_in() + result = check.execute() + # Should produce 1 finding (license warning), not 5 individual FAILs + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Entra ID P1/P2 licensing" in result[0].status_extended + assert "5 enabled users" in result[0].status_extended + + def test_entra_user_never_signed_in_when_telemetry_exists_for_tenant(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + active_user = User( + id=str(uuid4()), + name="active-user", + account_enabled=True, + last_sign_in=datetime.now(timezone.utc) - timedelta(days=5), + ) + never_user = User( + id=str(uuid4()), + name="never-user", + account_enabled=True, + last_sign_in=None, + ) + + entra_client.users = { + DOMAIN: { + f"active-user@{DOMAIN}": active_user, + f"never-user@{DOMAIN}": never_user, + } + } + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 2 + assert any( + r.status == "PASS" and "5 days ago" in r.status_extended for r in result + ) + assert any( + r.status == "FAIL" and "never signed in" in r.status_extended + for r in result + ) + + def test_entra_user_boundary_90_days(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="boundary-user", + account_enabled=True, + last_sign_in=datetime.now(timezone.utc) - timedelta(days=90), + ) + + entra_client.users = {DOMAIN: {f"boundary-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "90 days ago" in result[0].status_extended diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py new file mode 100644 index 0000000000..c5e1f27de2 --- /dev/null +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py @@ -0,0 +1,125 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.mysql.mysql_service import FlexibleServer +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_mysql_flexible_server_geo_redundant_backup_enabled: + def test_mysql_no_subscriptions(self): + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_mysql_geo_redundant_backup_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + geo_redundant_backup="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Geo-redundant backup is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_mysql_geo_redundant_backup_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + geo_redundant_backup="Enabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Geo-redundant backup is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled_test.py new file mode 100644 index 0000000000..c1084ec47b --- /dev/null +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled_test.py @@ -0,0 +1,166 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.mysql.mysql_service import FlexibleServer +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_mysql_flexible_server_high_availability_enabled: + def test_mysql_no_subscriptions(self): + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_mysql_high_availability_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + high_availability_mode="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"High availability is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_mysql_high_availability_not_set(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + high_availability_mode=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"High availability is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + + def test_mysql_high_availability_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + high_availability_mode="ZoneRedundant", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"High availability is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated_test.py b/tests/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated_test.py new file mode 100644 index 0000000000..4166c6f968 --- /dev/null +++ b/tests/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated_test.py @@ -0,0 +1,188 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +VNET_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/test-vnet" +SUBNET_ID = f"{VNET_ID}/subnets/test-subnet" +NSG_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.Network/networkSecurityGroups/test-nsg" + + +class Test_network_subnet_nsg_associated: + def test_no_subscriptions(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + network_client.virtual_networks = {} + + check = network_subnet_nsg_associated() + result = check.execute() + assert len(result) == 0 + + def test_subnet_with_nsg(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + VNetSubnet, + ) + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + subnets=[VNetSubnet(id=SUBNET_ID, name="test-subnet", nsg_id=NSG_ID)], + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_subnet_nsg_associated() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_subnet_without_nsg(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + VNetSubnet, + ) + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + subnets=[VNetSubnet(id=SUBNET_ID, name="app-subnet", nsg_id=None)], + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_subnet_nsg_associated() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_gateway_subnet_excluded(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + VNetSubnet, + ) + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + subnets=[ + VNetSubnet( + id=f"{VNET_ID}/subnets/GatewaySubnet", + name="GatewaySubnet", + nsg_id=None, + ) + ], + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_subnet_nsg_associated() + result = check.execute() + # GatewaySubnet should be excluded + assert len(result) == 0 + + def test_mixed_subnets(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + VNetSubnet, + ) + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + subnets=[ + VNetSubnet(id=f"{VNET_ID}/subnets/app", name="app", nsg_id=NSG_ID), + VNetSubnet(id=f"{VNET_ID}/subnets/db", name="db", nsg_id=None), + VNetSubnet( + id=f"{VNET_ID}/subnets/GatewaySubnet", + name="GatewaySubnet", + nsg_id=None, + ), + ], + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_subnet_nsg_associated() + result = check.execute() + # 2 results: app (PASS) + db (FAIL), GatewaySubnet excluded + assert len(result) == 2 + statuses = {r.status for r in result} + assert "PASS" in statuses + assert "FAIL" in statuses diff --git a/tests/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled_test.py b/tests/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled_test.py new file mode 100644 index 0000000000..f37d3eb781 --- /dev/null +++ b/tests/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled_test.py @@ -0,0 +1,99 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +VNET_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/test-vnet" + + +class Test_network_vnet_ddos_protection_enabled: + def test_no_subscriptions(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled import ( + network_vnet_ddos_protection_enabled, + ) + + network_client.virtual_networks = {} + + check = network_vnet_ddos_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_ddos_enabled(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled import ( + network_vnet_ddos_protection_enabled, + ) + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + enable_ddos_protection=True, + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_vnet_ddos_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_ddos_disabled(self): + network_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled import ( + network_vnet_ddos_protection_enabled, + ) + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + enable_ddos_protection=False, + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_vnet_ddos_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py new file mode 100644 index 0000000000..db0291c989 --- /dev/null +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py @@ -0,0 +1,132 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.postgresql.postgresql_service import Server +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +def _make_server(server_id, server_name, geo_redundant_backup): + return Server( + id=server_id, + name=server_name, + resource_group="resource_group", + location="eastus", + require_secure_transport="ON", + active_directory_auth="Enabled", + entra_id_admins=[], + log_checkpoints="ON", + log_connections="ON", + log_disconnections="ON", + connection_throttling="ON", + log_retention_days="3", + firewall=[], + geo_redundant_backup=geo_redundant_backup, + ) + + +class Test_postgresql_flexible_server_geo_redundant_backup_enabled: + def test_no_postgresql_flexible_servers(self): + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled import ( + postgresql_flexible_server_geo_redundant_backup_enabled, + ) + + check = postgresql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_postgresql_geo_redundant_backup_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, "Disabled")] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled import ( + postgresql_flexible_server_geo_redundant_backup_enabled, + ) + + check = postgresql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have geo-redundant backup enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_postgresql_geo_redundant_backup_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, "Enabled")] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled import ( + postgresql_flexible_server_geo_redundant_backup_enabled, + ) + + check = postgresql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has geo-redundant backup enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled_test.py new file mode 100644 index 0000000000..190c8629ce --- /dev/null +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled_test.py @@ -0,0 +1,168 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.postgresql.postgresql_service import Server +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +def _make_server(server_id, server_name, high_availability_mode): + return Server( + id=server_id, + name=server_name, + resource_group="resource_group", + location="eastus", + require_secure_transport="ON", + active_directory_auth="Enabled", + entra_id_admins=[], + log_checkpoints="ON", + log_connections="ON", + log_disconnections="ON", + connection_throttling="ON", + log_retention_days="3", + firewall=[], + high_availability_mode=high_availability_mode, + ) + + +class Test_postgresql_flexible_server_high_availability_enabled: + def test_no_postgresql_flexible_servers(self): + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled import ( + postgresql_flexible_server_high_availability_enabled, + ) + + check = postgresql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_postgresql_high_availability_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, "Disabled")] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled import ( + postgresql_flexible_server_high_availability_enabled, + ) + + check = postgresql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have high availability enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_postgresql_high_availability_not_set(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, None)] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled import ( + postgresql_flexible_server_high_availability_enabled, + ) + + check = postgresql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have high availability enabled." + ) + + def test_postgresql_high_availability_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [ + _make_server(server_id, server_name, "ZoneRedundant") + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled import ( + postgresql_flexible_server_high_availability_enabled, + ) + + check = postgresql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has high availability enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/postgresql/postgresql_service_test.py b/tests/providers/azure/services/postgresql/postgresql_service_test.py index f36b5675ec..c0b8bca6c4 100644 --- a/tests/providers/azure/services/postgresql/postgresql_service_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.postgresql.postgresql_service import ( EntraIdAdmin, @@ -115,6 +115,44 @@ class Test_SqlServer_Service: == "ON" ) + def test_get_connection_throttling_missing_parameter_returns_none(self): + # PostgreSQL v18 removed the "connection_throttle.enable" parameter; the + # service must degrade gracefully (quiet None) instead of raising and + # aborting the whole subscription's server inventory. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.configurations.get.side_effect = Exception( + "The configuration 'connection_throttle.enable' does not exist for " + "server version 18." + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_connection_throttling( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result is None + mock_logger.error.assert_not_called() + + def test_get_connection_throttling_unexpected_error_logs_error(self): + # Any other failure (permissions, throttling, transient API errors) must + # still be logged as an error, while keeping the scan resilient (None). + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.configurations.get.side_effect = Exception( + "Some unexpected failure" + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_connection_throttling( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result is None + mock_logger.error.assert_called_once() + def test_get_log_retention_days(self): postgesql = PostgreSQL(set_mocked_azure_provider()) assert ( @@ -138,6 +176,45 @@ class Test_SqlServer_Service: assert admins[0].principal_name == "Test Admin User" assert admins[0].object_id == "11111111-1111-1111-1111-111111111111" + def test_get_entra_id_admins_aad_not_enabled_logs_warning(self): + # A server using PostgreSQL authentication only (Entra/Azure AD auth + # disabled) is an expected state; it should be logged as a warning, not + # an error, and return an empty admin list. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.administrators.list_by_server.side_effect = Exception( + "Azure AD authentication is not enabled for the given server" + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_entra_id_admins( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result == [] + mock_logger.warning.assert_called_once() + mock_logger.error.assert_not_called() + + def test_get_entra_id_admins_unexpected_error_logs_error(self): + # Any other failure (permissions, throttling, transient API errors) is a + # genuine problem and must still be logged as an error. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.administrators.list_by_server.side_effect = Exception( + "Some unexpected failure" + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_entra_id_admins( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result == [] + mock_logger.error.assert_called_once() + mock_logger.warning.assert_not_called() + def test_get_firewall(self): postgesql = PostgreSQL(set_mocked_azure_provider()) assert ( diff --git a/tests/providers/azure/services/recovery/recovery_service_test.py b/tests/providers/azure/services/recovery/recovery_service_test.py new file mode 100644 index 0000000000..93dcad1e38 --- /dev/null +++ b/tests/providers/azure/services/recovery/recovery_service_test.py @@ -0,0 +1,57 @@ +from types import SimpleNamespace +from unittest import mock + +from prowler.providers.azure.services.recovery.recovery_service import ( + BackupVault, + RecoveryBackup, +) +from tests.providers.azure.azure_fixtures import AZURE_SUBSCRIPTION_ID + +VAULT_ID = ( + f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/" + "providers/Microsoft.RecoveryServices/vaults/test-vault" +) +POLICY_ID = f"{VAULT_ID}/backupPolicies/ShortPolicy" + + +class BackupClientFake: + def __init__(self, policies): + self.backup_policies = mock.MagicMock() + self.backup_policies.list.return_value = policies + + +class Test_RecoveryBackup_Service: + def test_get_backup_policies_lists_unprotected_vault_policies(self): + policy = SimpleNamespace( + id=POLICY_ID, + name="ShortPolicy", + properties=SimpleNamespace( + retention_policy=SimpleNamespace( + daily_schedule=SimpleNamespace( + retention_duration=SimpleNamespace(count=7) + ) + ) + ), + ) + client = BackupClientFake(policies=[policy]) + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_protected_items={}, + ) + recovery_backup = object.__new__(RecoveryBackup) + recovery_backup.clients = {AZURE_SUBSCRIPTION_ID: client} + + backup_policies = recovery_backup._get_backup_policies( + subscription_id=AZURE_SUBSCRIPTION_ID, + vault=vault, + ) + + client.backup_policies.list.assert_called_once_with( + vault_name="test-vault", + resource_group_name="rg1", + ) + assert list(backup_policies) == [POLICY_ID] + assert backup_policies[POLICY_ID].name == "ShortPolicy" + assert backup_policies[POLICY_ID].retention_days == 7 diff --git a/tests/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate_test.py b/tests/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate_test.py new file mode 100644 index 0000000000..56c7e3f5da --- /dev/null +++ b/tests/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate_test.py @@ -0,0 +1,191 @@ +from unittest import mock +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +VAULT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.RecoveryServices/vaults/test-vault" +POLICY_ID = f"{VAULT_ID}/backupPolicies/DefaultPolicy" + + +class Test_recovery_vault_backup_policy_retention_adequate: + def test_no_subscriptions(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + + recovery_client.vaults = {} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 0 + + def test_vault_no_policies(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_policies={}, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has no backup policies configured" in result[0].status_extended + + def test_policy_adequate_retention(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupPolicy, + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_policies={ + POLICY_ID: BackupPolicy( + id=POLICY_ID, + name="DefaultPolicy", + retention_days=90, + ) + }, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "90-day" in result[0].status_extended + + def test_policy_insufficient_retention(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupPolicy, + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_policies={ + POLICY_ID: BackupPolicy( + id=POLICY_ID, + name="ShortPolicy", + retention_days=7, + ) + }, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "7-day" in result[0].status_extended + assert "minimum: 30" in result[0].status_extended + + def test_policy_no_retention_configured(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupPolicy, + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_policies={ + POLICY_ID: BackupPolicy( + id=POLICY_ID, + name="NoRetentionPolicy", + retention_days=None, + ) + }, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no daily retention" in result[0].status_extended diff --git a/tests/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items_test.py b/tests/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items_test.py new file mode 100644 index 0000000000..c9f8aa27b4 --- /dev/null +++ b/tests/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items_test.py @@ -0,0 +1,116 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +VAULT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.RecoveryServices/vaults/test-vault" + + +class Test_recovery_vault_has_protected_items: + def test_no_subscriptions(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items import ( + recovery_vault_has_protected_items, + ) + + recovery_client.vaults = {} + + check = recovery_vault_has_protected_items() + result = check.execute() + assert len(result) == 0 + + def test_vault_with_protected_items(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items import ( + recovery_vault_has_protected_items, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupItem, + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_protected_items={ + "item1": BackupItem( + id="item1", + name="vm-backup", + workload_type=None, + ) + }, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_has_protected_items() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "1 protected items" in result[0].status_extended + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_id == VAULT_ID + assert result[0].resource_name == "test-vault" + assert result[0].location == "eastus" + + def test_vault_empty(self): + recovery_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items import ( + recovery_vault_has_protected_items, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="empty-vault", + location="westeurope", + backup_protected_items={}, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_has_protected_items() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no protected items" in result[0].status_extended + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_id == VAULT_ID + assert result[0].resource_name == "empty-vault" + assert result[0].location == "westeurope" diff --git a/tests/providers/gcp/gcp_provider_test.py b/tests/providers/gcp/gcp_provider_test.py index 7b30b828da..2e80ac7899 100644 --- a/tests/providers/gcp/gcp_provider_test.py +++ b/tests/providers/gcp/gcp_provider_test.py @@ -92,6 +92,7 @@ class TestGCPProvider: "shodan_api_key": None, "max_unused_account_days": 180, "storage_min_retention_days": 90, + "secretmanager_max_rotation_days": 90, "mig_min_zones": 2, "max_snapshot_age_days": 90, } diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py index b764760538..8428e56d86 100644 --- a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py @@ -13,6 +13,13 @@ _CHECK_PATH = ( _CLIENT_PATH = f"{_CHECK_PATH}.cloudfunction_client" +def _function_id(name: str) -> str: + return ( + f"projects/{GCP_PROJECT_ID}/locations/{GCP_US_CENTER1_LOCATION}" + f"/functions/{name}" + ) + + class Test_cloudfunction_function_inside_vpc: def test_no_functions(self): cloudfunction_client = mock.MagicMock() @@ -63,6 +70,7 @@ class Test_cloudfunction_function_inside_vpc: ) cloudfunction_client.functions = [ Function( + id=_function_id("fn-vpc"), name="fn-vpc", project_id=GCP_PROJECT_ID, location=GCP_US_CENTER1_LOCATION, @@ -106,6 +114,7 @@ class Test_cloudfunction_function_inside_vpc: cloudfunction_client.functions = [ Function( + id=_function_id("fn-public"), name="fn-public", project_id=GCP_PROJECT_ID, location=GCP_US_CENTER1_LOCATION, @@ -149,6 +158,7 @@ class Test_cloudfunction_function_inside_vpc: cloudfunction_client.functions = [ Function( + id=_function_id("fn-empty"), name="fn-empty", project_id=GCP_PROJECT_ID, location=GCP_US_CENTER1_LOCATION, @@ -184,6 +194,7 @@ class Test_cloudfunction_function_inside_vpc: cloudfunction_client.functions = [ Function( + id=_function_id("fn-deploy"), name="fn-deploy", project_id=GCP_PROJECT_ID, location=GCP_US_CENTER1_LOCATION, diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/__init__.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible_test.py new file mode 100644 index 0000000000..615890bd58 --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible_test.py @@ -0,0 +1,216 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.cloudfunction." + "cloudfunction_function_not_publicly_accessible." + "cloudfunction_function_not_publicly_accessible" +) +_CLIENT_PATH = f"{_CHECK_PATH}.cloudfunction_client" + + +def _function_id(name: str) -> str: + return ( + f"projects/{GCP_PROJECT_ID}/locations/{GCP_US_CENTER1_LOCATION}" + f"/functions/{name}" + ) + + +class Test_cloudfunction_function_not_publicly_accessible: + def test_no_functions(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + + cloudfunction_client.functions = [] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 0 + + def test_function_private(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-private"), + name="fn-private", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + publicly_accessible=False, + ) + ] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cloud Function fn-private is not publicly accessible." + ) + assert result[0].resource_id == "fn-private" + assert result[0].resource_name == "fn-private" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_publicly_accessible(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-public"), + name="fn-public", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + publicly_accessible=True, + ) + ] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Cloud Function fn-public is publicly invocable " + "(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + assert result[0].resource_id == "fn-public" + assert result[0].resource_name == "fn-public" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_multiple_functions_mixed(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-private"), + name="fn-private", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + publicly_accessible=False, + ), + Function( + id=_function_id("fn-public"), + name="fn-public", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + publicly_accessible=True, + ), + ] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 2 + + by_id = {r.resource_id: r for r in result} + assert by_id["fn-private"].status == "PASS" + assert by_id["fn-public"].status == "FAIL" + + def test_inactive_function_skipped(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-deleting"), + name="fn-deleting", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="DELETING", + publicly_accessible=True, + ) + ] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py index 82f77a939f..d97b80336b 100644 --- a/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py @@ -11,12 +11,18 @@ from tests.providers.gcp.gcp_fixtures import ( _LOCATION_ID = "us-central1" _FUNCTION_NAME = "my-function" +_FUNCTION_ID = ( + f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/{_FUNCTION_NAME}" +) +_RUN_SERVICE = ( + f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/services/{_FUNCTION_NAME}" +) _CONNECTOR = ( f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/connectors/my-connector" ) -def _make_cloudfunction_client(functions_list): +def _make_cloudfunction_client(functions_list, iam_bindings=None): """Return a mock GCP API client for the Cloud Functions v2 service.""" client = MagicMock() @@ -30,6 +36,29 @@ def _make_cloudfunction_client(functions_list): } client.projects().locations().functions().list_next.return_value = None + iam_response = {"bindings": iam_bindings or []} + + def mock_get_iam_policy(resource): + rv = MagicMock() + rv.execute.return_value = iam_response + return rv + + client.projects().locations().functions().getIamPolicy = mock_get_iam_policy + + return client + + +def _make_run_client(iam_bindings=None): + """Return a mock Cloud Run v2 client for gen2 IAM policy lookups.""" + client = MagicMock() + iam_response = {"bindings": iam_bindings or []} + + def mock_get_iam_policy(resource): + rv = MagicMock() + rv.execute.return_value = iam_response + return rv + + client.projects().locations().services().getIamPolicy = mock_get_iam_policy return client @@ -39,9 +68,11 @@ class TestCloudFunctionService: return _make_cloudfunction_client( functions_list=[ { - "name": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/{_FUNCTION_NAME}", + "name": _FUNCTION_ID, "state": "ACTIVE", + "environment": "GEN_2", "serviceConfig": { + "service": _RUN_SERVICE, "vpcConnector": _CONNECTOR, }, } @@ -57,6 +88,10 @@ class TestCloudFunctionService: "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", new=mock_api_client, ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=_make_run_client(), + ), ): cf_client = CloudFunction( set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) @@ -64,11 +99,15 @@ class TestCloudFunctionService: assert len(cf_client.functions) == 1 fn = cf_client.functions[0] + assert fn.id == _FUNCTION_ID assert fn.name == _FUNCTION_NAME assert fn.project_id == GCP_PROJECT_ID assert fn.location == _LOCATION_ID assert fn.state == "ACTIVE" + assert fn.environment == "GEN_2" + assert fn.service == _RUN_SERVICE assert fn.vpc_connector == _CONNECTOR + assert fn.publicly_accessible is False def test_get_functions_without_vpc_connector(self): def mock_api_client(*args, **kwargs): @@ -77,11 +116,190 @@ class TestCloudFunctionService: { "name": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/no-vpc-func", "state": "ACTIVE", - "serviceConfig": {}, + "environment": "GEN_2", + "serviceConfig": { + "service": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/services/no-vpc-func", + }, } ] ) + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=_make_run_client(), + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + fn = cf_client.functions[0] + assert fn.name == "no-vpc-func" + assert fn.vpc_connector is None + assert fn.publicly_accessible is False + + def test_get_functions_iam_policy_gen2_all_users(self): + """Gen2 functions: allUsers binding lives on the Cloud Run service.""" + + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": {"service": _RUN_SERVICE}, + } + ] + ) + + run_client = _make_run_client( + iam_bindings=[ + { + "role": "roles/run.invoker", + "members": ["allUsers"], + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=run_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + assert cf_client.functions[0].publicly_accessible is True + + def test_get_functions_iam_policy_gen2_all_authenticated_users(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": {"service": _RUN_SERVICE}, + } + ] + ) + + run_client = _make_run_client( + iam_bindings=[ + { + "role": "roles/run.invoker", + "members": ["allAuthenticatedUsers"], + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=run_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + assert cf_client.functions[0].publicly_accessible is True + + def test_get_functions_iam_policy_gen2_not_public(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": {"service": _RUN_SERVICE}, + } + ] + ) + + run_client = _make_run_client( + iam_bindings=[ + { + "role": "roles/run.invoker", + "members": ["serviceAccount:sa@project.iam.gserviceaccount.com"], + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=run_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + assert cf_client.functions[0].publicly_accessible is False + + def test_get_functions_iam_policy_gen1_all_users(self): + """Gen1 functions: IAM binding lives on the Cloud Functions resource itself.""" + + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_1", + "serviceConfig": {}, + } + ], + iam_bindings=[ + { + "role": "roles/cloudfunctions.invoker", + "members": ["allUsers"], + } + ], + ) + with ( patch( "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", @@ -97,6 +315,5 @@ class TestCloudFunctionService: ) assert len(cf_client.functions) == 1 - fn = cf_client.functions[0] - assert fn.name == "no-vpc-func" - assert fn.vpc_connector is None + assert cf_client.functions[0].environment == "GEN_1" + assert cf_client.functions[0].publicly_accessible is True diff --git a/tests/providers/gcp/services/secretmanager/__init__.py b/tests/providers/gcp/services/secretmanager/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py b/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible_test.py b/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible_test.py new file mode 100644 index 0000000000..f925966f3c --- /dev/null +++ b/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible_test.py @@ -0,0 +1,169 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.secretmanager." + "secretmanager_secret_not_publicly_accessible." + "secretmanager_secret_not_publicly_accessible" +) +_CLIENT_PATH = f"{_CHECK_PATH}.secretmanager_client" + + +def _secret_id(name: str) -> str: + return f"projects/{GCP_PROJECT_ID}/secrets/{name}" + + +class Test_secretmanager_secret_not_publicly_accessible: + def test_no_secrets(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + + secretmanager_client.secrets = [] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 0 + + def test_secret_private(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-private"), + name="secret-private", + project_id=GCP_PROJECT_ID, + publicly_accessible=False, + ) + ] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Secret secret-private is not publicly accessible." + ) + assert result[0].resource_id == "secret-private" + assert result[0].resource_name == "secret-private" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_secret_publicly_accessible(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-public"), + name="secret-public", + project_id=GCP_PROJECT_ID, + publicly_accessible=True, + ) + ] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Secret secret-public is publicly accessible " + "(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + assert result[0].resource_id == "secret-public" + assert result[0].resource_name == "secret-public" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_multiple_secrets_mixed(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-private"), + name="secret-private", + project_id=GCP_PROJECT_ID, + publicly_accessible=False, + ), + Secret( + id=_secret_id("secret-public"), + name="secret-public", + project_id=GCP_PROJECT_ID, + publicly_accessible=True, + ), + ] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 2 + + by_id = {r.resource_id: r for r in result} + assert by_id["secret-private"].status == "PASS" + assert by_id["secret-public"].status == "FAIL" diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/__init__.py b/tests/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled_test.py b/tests/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled_test.py new file mode 100644 index 0000000000..497dd7428b --- /dev/null +++ b/tests/providers/gcp/services/secretmanager/secretmanager_secret_rotation_enabled/secretmanager_secret_rotation_enabled_test.py @@ -0,0 +1,385 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.secretmanager." + "secretmanager_secret_rotation_enabled." + "secretmanager_secret_rotation_enabled" +) +_CLIENT_PATH = f"{_CHECK_PATH}.secretmanager_client" + + +def _secret_id(name: str) -> str: + return f"projects/{GCP_PROJECT_ID}/secrets/{name}" + + +class Test_secretmanager_secret_rotation_enabled: + def test_no_secrets(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + + secretmanager_client.secrets = [] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 0 + + def test_rotation_within_90_days_pass(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-rotated"), + name="secret-rotated", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Secret secret-rotated has automatic rotation enabled with a period of 90 days." + ) + assert result[0].resource_id == "secret-rotated" + assert result[0].resource_name == "secret-rotated" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_rotation_period_exceeds_max_fail(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-stale"), + name="secret-stale", + project_id=GCP_PROJECT_ID, + rotation_period="9504000s", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "exceeds the 90-day maximum" in result[0].status_extended + assert result[0].resource_id == "secret-stale" + + def test_rotation_period_one_second_over_max_fail(self): + """90 days + 1 second must fail — comparison is on seconds, not floored days.""" + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + # 90 days = 7_776_000 seconds. Add 1 second. + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-90d-plus-1s"), + name="secret-90d-plus-1s", + project_id=GCP_PROJECT_ID, + rotation_period="7776001s", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "exceeds the 90-day maximum" in result[0].status_extended + + def test_no_rotation_fail(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-no-rotation"), + name="secret-no-rotation", + project_id=GCP_PROJECT_ID, + rotation_period=None, + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Secret secret-no-rotation does not have automatic rotation enabled." + ) + + def test_fractional_seconds_period_pass(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-fractional"), + name="secret-fractional", + project_id=GCP_PROJECT_ID, + rotation_period="2592000.500000000s", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_sub_day_rotation_period_pass(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-sub-day"), + name="secret-sub-day", + project_id=GCP_PROJECT_ID, + rotation_period="3600s", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_overdue_next_rotation_fail(self): + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-overdue"), + name="secret-overdue", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + next_rotation_time="2020-01-01T00:00:00Z", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "overdue" in result[0].status_extended + + def test_invalid_rotation_period_format_fail(self): + """Unparseable rotation_period falls back to None → FAIL with no-rotation message.""" + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-bad-period"), + name="secret-bad-period", + project_id=GCP_PROJECT_ID, + rotation_period="not-a-duration", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Secret secret-bad-period does not have automatic rotation enabled." + ) + + def test_invalid_next_rotation_time_fail_closed(self): + """Unparseable next_rotation_time fails closed → FAIL as overdue.""" + secretmanager_client = mock.MagicMock() + secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_rotation_enabled.secretmanager_secret_rotation_enabled import ( + secretmanager_secret_rotation_enabled, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-bad-timestamp"), + name="secret-bad-timestamp", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + next_rotation_time="not-a-timestamp", + ) + ] + + check = secretmanager_secret_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "overdue" in result[0].status_extended diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_service_test.py b/tests/providers/gcp/services/secretmanager/secretmanager_service_test.py new file mode 100644 index 0000000000..f393b10a59 --- /dev/null +++ b/tests/providers/gcp/services/secretmanager/secretmanager_service_test.py @@ -0,0 +1,231 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + SecretManager, +) +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + + +def _make_secretmanager_client(secrets_list, iam_bindings=None): + """Return a mock GCP API client for the Secret Manager service.""" + client = MagicMock() + + client.projects().secrets().list().execute.return_value = {"secrets": secrets_list} + client.projects().secrets().list_next.return_value = None + + iam_response = {"bindings": iam_bindings or []} + + def mock_get_iam_policy(resource): + rv = MagicMock() + rv.execute.return_value = iam_response + return rv + + client.projects().secrets().getIamPolicy = mock_get_iam_policy + + return client + + +class TestSecretManagerService: + def test_get_secrets(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/my-secret", + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + secret = sm_client.secrets[0] + assert secret.name == "my-secret" + assert secret.id == f"projects/{GCP_PROJECT_ID}/secrets/my-secret" + assert secret.project_id == GCP_PROJECT_ID + assert secret.location == "global" + assert secret.rotation_period is None + assert secret.next_rotation_time is None + assert secret.publicly_accessible is False + + def test_get_secrets_with_rotation(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/secret-with-rotation", + "rotation": { + "rotationPeriod": "7776000s", + "nextRotationTime": "2026-09-01T00:00:00Z", + }, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + secret = sm_client.secrets[0] + assert secret.name == "secret-with-rotation" + assert secret.rotation_period == "7776000s" + assert secret.next_rotation_time == "2026-09-01T00:00:00Z" + + def test_get_secrets_with_null_rotation(self): + """API returning explicit `rotation: null` must not break enumeration.""" + + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/null-rotation", + "rotation": None, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + secret = sm_client.secrets[0] + assert secret.name == "null-rotation" + assert secret.rotation_period is None + assert secret.next_rotation_time is None + + def test_get_secrets_iam_policy_all_users(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/public-secret", + } + ], + iam_bindings=[ + { + "role": "roles/secretmanager.secretAccessor", + "members": ["allUsers"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + assert sm_client.secrets[0].publicly_accessible is True + + def test_get_secrets_iam_policy_all_authenticated_users(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/auth-users-secret", + } + ], + iam_bindings=[ + { + "role": "roles/secretmanager.secretAccessor", + "members": ["allAuthenticatedUsers"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + assert sm_client.secrets[0].publicly_accessible is True + + def test_get_secrets_iam_policy_not_public(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/private-secret", + } + ], + iam_bindings=[ + { + "role": "roles/secretmanager.secretAccessor", + "members": ["user:alice@example.com"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + assert sm_client.secrets[0].publicly_accessible is False diff --git a/tests/providers/kubernetes/services/core/conftest.py b/tests/providers/kubernetes/services/core/conftest.py new file mode 100644 index 0000000000..9b0becd33c --- /dev/null +++ b/tests/providers/kubernetes/services/core/conftest.py @@ -0,0 +1,74 @@ +import importlib +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import Container, Pod +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) + + +def make_container( + name="app", + image="nginx:1.25", + resources=None, + liveness_probe=None, + readiness_probe=None, +): + return Container( + name=name, + image=image, + command=None, + ports=None, + env=None, + security_context={}, + resources=resources, + liveness_probe=liveness_probe, + readiness_probe=readiness_probe, + ) + + +def make_pod( + containers=None, + init_containers=None, + ephemeral_containers=None, + name="test-pod", + uid="test-pod-uid", +): + return Pod( + name=name, + uid=uid, + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=False, + host_ipc=False, + host_network=False, + security_context={}, + containers=containers or {}, + init_containers=init_containers or {}, + ephemeral_containers=ephemeral_containers or {}, + ) + + +def make_core_client(pods): + core_client = mock.MagicMock() + core_client.pods = pods + return core_client + + +def run_check(module_path, class_name, core_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch(f"{module_path}.core_client", new=core_client), + ): + check_module = importlib.import_module(module_path) + check = getattr(check_module, class_name)() + return check.execute() diff --git a/tests/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set_test.py b/tests/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set_test.py new file mode 100644 index 0000000000..41663daab3 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set_test.py @@ -0,0 +1,82 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = ( + "prowler.providers.kubernetes.services.core.core_cpu_limits_set.core_cpu_limits_set" +) +CLASS = "core_cpu_limits_set" + + +class TestCoreCpuLimitsSet: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_cpu_limit_set_pass(self): + pod = make_pod( + containers={"app": make_container(resources={"limits": {"cpu": "500m"}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod regular containers have CPU limits configured." + ) + + def test_cpu_limit_missing_fail(self): + pod = make_pod(containers={"app": make_container(resources=None)}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a CPU limit configured." + ) + + def test_empty_cpu_limit_fail(self): + pod = make_pod( + containers={"app": make_container(resources={"limits": {"cpu": ""}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + + def test_mixed_regular_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container( + name="app", resources={"limits": {"cpu": "500m"}} + ), + "sidecar": make_container(name="sidecar", resources={"limits": {}}), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have a CPU limit configured." + ) + + def test_init_and_ephemeral_containers_ignored(self): + pod = make_pod( + containers={"app": make_container(resources={"limits": {"cpu": "500m"}})}, + init_containers={"init": make_container(name="init", resources=None)}, + ephemeral_containers={ + "debug": make_container(name="debug", resources=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" diff --git a/tests/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set_test.py b/tests/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set_test.py new file mode 100644 index 0000000000..47fcec1b1e --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set_test.py @@ -0,0 +1,80 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_cpu_requests_set.core_cpu_requests_set" +CLASS = "core_cpu_requests_set" + + +class TestCoreCpuRequestsSet: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_cpu_request_set_pass(self): + pod = make_pod( + containers={"app": make_container(resources={"requests": {"cpu": "100m"}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod regular containers have CPU requests configured." + ) + + def test_cpu_request_missing_fail(self): + pod = make_pod(containers={"app": make_container(resources={})}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a CPU request configured." + ) + + def test_empty_cpu_request_fail(self): + pod = make_pod( + containers={"app": make_container(resources={"requests": {"cpu": ""}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + + def test_mixed_regular_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container( + name="app", resources={"requests": {"cpu": "100m"}} + ), + "sidecar": make_container(name="sidecar", resources=None), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have a CPU request configured." + ) + + def test_init_and_ephemeral_containers_ignored(self): + pod = make_pod( + containers={"app": make_container(resources={"requests": {"cpu": "100m"}})}, + init_containers={"init": make_container(name="init", resources=None)}, + ephemeral_containers={ + "debug": make_container(name="debug", resources=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" diff --git a/tests/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed_test.py b/tests/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed_test.py new file mode 100644 index 0000000000..a5e62e8157 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed_test.py @@ -0,0 +1,572 @@ +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import Container, Pod +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed" +CLASS = "core_image_tag_fixed" + + +class Test_core_image_tag_fixed: + def test_no_pods(self): + core_client = mock.MagicMock() + core_client.pods = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 0 + + def test_image_tag_fixed(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has fixed image tags on all containers." + ) + assert result[0].resource_id == "test-uid-1234" + assert result[0].resource_name == "test-pod" + + def test_pod_without_containers(self): + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers=None, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has fixed image tags on all containers." + ) + + def test_image_tag_latest(self): + container = Container( + name="test-container", + image="nginx:latest", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_image_tag_blank(self): + container = Container( + name="test-container", + image="nginx", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_image_with_digest(self): + container = Container( + name="test-container", + image="nginx@sha256:abc123def456", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_image_with_registry_port_and_no_tag(self): + container = Container( + name="test-container", + image="localhost:5000/nginx", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_image_tag_uppercase_latest(self): + container = Container( + name="test-container", + image="nginx:Latest", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_image_with_empty_tag(self): + container = Container( + name="test-container", + image="nginx:", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_mixed_containers_fails_on_first_unfixed_image(self): + fixed_container = Container( + name="fixed-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + latest_container = Container( + name="latest-container", + image="busybox:latest", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={ + "fixed-container": fixed_container, + "latest-container": latest_container, + }, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod has container latest-container with image 'busybox:latest' that does not use a fixed tag." + ) + + def test_init_container_image_tag_latest(self): + pod = make_pod( + containers={"app": make_container(image="nginx:1.25.3")}, + init_containers={ + "init": make_container(name="init", image="busybox:latest") + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod has container init with image 'busybox:latest' that does not use a fixed tag." + ) + + def test_ephemeral_container_image_tag_blank(self): + pod = make_pod( + containers={"app": make_container(image="nginx:1.25.3")}, + ephemeral_containers={ + "debug": make_container(name="debug", image="busybox") + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod has container debug with image 'busybox' that does not use a fixed tag." + ) + + def test_init_and_ephemeral_image_tags_fixed_without_regular_containers(self): + pod = make_pod( + containers=None, + init_containers={"init": make_container(name="init", image="busybox:1.36")}, + ephemeral_containers={ + "debug": make_container(name="debug", image="debug@sha256:abc123") + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has fixed image tags on all containers." + ) diff --git a/tests/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured_test.py b/tests/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured_test.py new file mode 100644 index 0000000000..cc2d1915a5 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured_test.py @@ -0,0 +1,83 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_liveness_probe_configured.core_liveness_probe_configured" +CLASS = "core_liveness_probe_configured" + + +class TestCoreLivenessProbeConfigured: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_liveness_probe_configured_pass(self): + pod = make_pod( + containers={"app": make_container(liveness_probe={"http_get": {}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod has liveness probes configured for all regular containers." + ) + + def test_liveness_probe_missing_fail(self): + pod = make_pod(containers={"app": make_container(liveness_probe=None)}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a liveness probe configured." + ) + + def test_empty_liveness_probe_fail(self): + pod = make_pod(containers={"app": make_container(liveness_probe={})}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a liveness probe configured." + ) + + def test_mixed_regular_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container(name="app", liveness_probe={"http_get": {}}), + "sidecar": make_container(name="sidecar", liveness_probe=None), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have a liveness probe configured." + ) + + def test_init_and_ephemeral_containers_ignored(self): + pod = make_pod( + containers={"app": make_container(liveness_probe={"http_get": {}})}, + init_containers={"init": make_container(name="init", liveness_probe=None)}, + ephemeral_containers={ + "debug": make_container(name="debug", liveness_probe=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" diff --git a/tests/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set_test.py b/tests/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set_test.py new file mode 100644 index 0000000000..f3e37ae7f7 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set_test.py @@ -0,0 +1,313 @@ +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import Container, Pod +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) + + +class Test_core_memory_limits_set: + def test_no_pods(self): + core_client = mock.MagicMock() + core_client.pods = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 0 + + def test_memory_limits_set(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={ + "limits": {"memory": "128Mi", "cpu": "500m"}, + "requests": {"memory": "64Mi", "cpu": "250m"}, + }, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has memory limits set on all containers." + ) + assert result[0].resource_id == "test-uid-1234" + assert result[0].resource_name == "test-pod" + + def test_memory_limits_set_with_no_containers(self): + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers=None, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has memory limits set on all containers." + ) + + def test_memory_limits_not_set(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod does not have memory limits set on container test-container." + ) + + def test_memory_limits_missing_memory_key(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={ + "limits": {"cpu": "500m"}, + "requests": {"memory": "64Mi"}, + }, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_mixed_containers_fails_on_missing_memory_limit(self): + limited_container = Container( + name="limited-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={"limits": {"memory": "128Mi"}}, + ) + unlimited_container = Container( + name="unlimited-container", + image="busybox:1.36", + command=None, + ports=None, + env=None, + security_context={}, + resources={"requests": {"memory": "64Mi"}}, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={ + "limited-container": limited_container, + "unlimited-container": unlimited_container, + }, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod does not have memory limits set on container unlimited-container." + ) diff --git a/tests/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set_test.py b/tests/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set_test.py new file mode 100644 index 0000000000..8da3225abd --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set_test.py @@ -0,0 +1,313 @@ +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import Container, Pod +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) + + +class Test_core_memory_requests_set: + def test_no_pods(self): + core_client = mock.MagicMock() + core_client.pods = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 0 + + def test_memory_requests_set(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={ + "limits": {"memory": "128Mi", "cpu": "500m"}, + "requests": {"memory": "64Mi", "cpu": "250m"}, + }, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has memory requests set on all containers." + ) + assert result[0].resource_id == "test-uid-1234" + assert result[0].resource_name == "test-pod" + + def test_memory_requests_set_with_no_containers(self): + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers=None, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has memory requests set on all containers." + ) + + def test_memory_requests_not_set(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod does not have memory requests set on container test-container." + ) + + def test_memory_requests_missing_memory_key(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={ + "limits": {"memory": "128Mi"}, + "requests": {"cpu": "250m"}, + }, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_mixed_containers_fails_on_missing_memory_request(self): + requested_container = Container( + name="requested-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={"requests": {"memory": "64Mi"}}, + ) + unrequested_container = Container( + name="unrequested-container", + image="busybox:1.36", + command=None, + ports=None, + env=None, + security_context={}, + resources={"limits": {"memory": "128Mi"}}, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={ + "requested-container": requested_container, + "unrequested-container": unrequested_container, + }, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod does not have memory requests set on container unrequested-container." + ) diff --git a/tests/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured_test.py b/tests/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured_test.py new file mode 100644 index 0000000000..ca316545ae --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured_test.py @@ -0,0 +1,83 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_readiness_probe_configured.core_readiness_probe_configured" +CLASS = "core_readiness_probe_configured" + + +class TestCoreReadinessProbeConfigured: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_readiness_probe_configured_pass(self): + pod = make_pod( + containers={"app": make_container(readiness_probe={"http_get": {}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod has readiness probes configured for all regular containers." + ) + + def test_readiness_probe_missing_fail(self): + pod = make_pod(containers={"app": make_container(readiness_probe=None)}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a readiness probe configured." + ) + + def test_empty_readiness_probe_fail(self): + pod = make_pod(containers={"app": make_container(readiness_probe={})}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a readiness probe configured." + ) + + def test_mixed_regular_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container(name="app", readiness_probe={"http_get": {}}), + "sidecar": make_container(name="sidecar", readiness_probe=None), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have a readiness probe configured." + ) + + def test_init_and_ephemeral_containers_ignored(self): + pod = make_pod( + containers={"app": make_container(readiness_probe={"http_get": {}})}, + init_containers={"init": make_container(name="init", readiness_probe=None)}, + ephemeral_containers={ + "debug": make_container(name="debug", readiness_probe=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" diff --git a/tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml b/tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml new file mode 100644 index 0000000000..60a3edef84 --- /dev/null +++ b/tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml @@ -0,0 +1,9 @@ +Mutelist: + Accounts: + "E1AF1B6C-1111-2222-3333-444455556666": + Checks: + "administration_user_2fa_enabled": + Regions: + - "*" + Resources: + - "admin" diff --git a/tests/providers/linode/lib/mutelist/linode_mutelist_test.py b/tests/providers/linode/lib/mutelist/linode_mutelist_test.py new file mode 100644 index 0000000000..f122e5bcae --- /dev/null +++ b/tests/providers/linode/lib/mutelist/linode_mutelist_test.py @@ -0,0 +1,98 @@ +from unittest.mock import MagicMock + +import yaml + +from prowler.providers.linode.lib.mutelist.mutelist import LinodeMutelist + +MUTELIST_FIXTURE_PATH = ( + "tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml" +) + + +class Test_linode_mutelist: + def test_get_mutelist_file_from_local_file(self): + mutelist = LinodeMutelist(mutelist_path=MUTELIST_FIXTURE_PATH) + + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + assert mutelist.mutelist == mutelist_fixture + assert mutelist.mutelist_file_path == MUTELIST_FIXTURE_PATH + + def test_get_mutelist_file_from_local_file_non_existent(self): + mutelist_path = "tests/providers/linode/lib/mutelist/fixtures/not_present" + mutelist = LinodeMutelist(mutelist_path=mutelist_path) + + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path == mutelist_path + + def test_validate_mutelist_not_valid_key(self): + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + mutelist_fixture["Accounts1"] = mutelist_fixture["Accounts"] + del mutelist_fixture["Accounts"] + + mutelist = LinodeMutelist(mutelist_content=mutelist_fixture) + + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path is None + + def test_is_finding_muted(self): + mutelist_content = { + "Accounts": { + "E1AF1B6C-1111-2222-3333-444455556666": { + "Checks": { + "administration_user_2fa_enabled": { + "Regions": ["*"], + "Resources": ["admin"], + } + } + } + } + } + + mutelist = LinodeMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "administration_user_2fa_enabled" + finding.status = "FAIL" + finding.region = "global" + finding.resource_id = "admin" + finding.resource_name = "admin" + finding.resource_tags = [] + + assert mutelist.is_finding_muted( + finding, "E1AF1B6C-1111-2222-3333-444455556666" + ) + + def test_is_finding_not_muted(self): + mutelist_content = { + "Accounts": { + "E1AF1B6C-1111-2222-3333-444455556666": { + "Checks": { + "administration_user_2fa_enabled": { + "Regions": ["*"], + "Resources": ["other-user"], + } + } + } + } + } + + mutelist = LinodeMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "administration_user_2fa_enabled" + finding.status = "FAIL" + finding.region = "global" + finding.resource_id = "admin" + finding.resource_name = "admin" + finding.resource_tags = [] + + assert not mutelist.is_finding_muted( + finding, "E1AF1B6C-1111-2222-3333-444455556666" + ) diff --git a/tests/providers/linode/linode_fixtures.py b/tests/providers/linode/linode_fixtures.py new file mode 100644 index 0000000000..5786ffe881 --- /dev/null +++ b/tests/providers/linode/linode_fixtures.py @@ -0,0 +1,34 @@ +from unittest.mock import MagicMock + +from prowler.providers.linode.models import ( + LinodeIdentityInfo, + LinodeSession, +) + +# Linode Identity +USERNAME = "admin" +EMAIL = "admin@example.com" +ACCOUNT_ID = "E1AF1B6C-1111-2222-3333-444455556666" + +# Linode Credentials +TOKEN = "fake-linode-token-for-testing" + + +def set_mocked_linode_provider( + username: str = USERNAME, + email: str = EMAIL, + account_id: str = ACCOUNT_ID, +): + """Return a mocked LinodeProvider with identity and session set.""" + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username=username, + email=email, + account_id=account_id, + ) + provider.session = LinodeSession( + client=MagicMock(), + token=TOKEN, + ) + return provider diff --git a/tests/providers/linode/linode_metadata_test.py b/tests/providers/linode/linode_metadata_test.py new file mode 100644 index 0000000000..9409c468af --- /dev/null +++ b/tests/providers/linode/linode_metadata_test.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import pytest + +from prowler.lib.check.models import CheckMetadata + +EXPECTED_SERVICE_NAMES = { + "administration": "administration", + "compute": "compute", + "networking": "networking", +} + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/linode").glob("services/**/*.metadata.json")), +) +def test_linode_check_metadata_is_valid(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + assert metadata.Provider == "linode" + assert metadata.CheckID == metadata_file.stem.replace(".metadata", "") + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/linode").glob("services/**/*.metadata.json")), +) +def test_linode_checks_metadata_use_canonical_hub_urls(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + url = metadata.Remediation.Recommendation.Url + assert not url.startswith( + "https://hub.prowler.com/checks/linode/" + ), f"{metadata_file}: non-canonical hub URL {url}" + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/linode").glob("services/**/*.metadata.json")), +) +def test_linode_check_metadata_uses_product_area_service_names(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + service_folder = metadata_file.relative_to( + Path("prowler/providers/linode/services") + ).parts[0] + + assert metadata.ServiceName == EXPECTED_SERVICE_NAMES[service_folder] diff --git a/tests/providers/linode/linode_provider_test.py b/tests/providers/linode/linode_provider_test.py new file mode 100644 index 0000000000..67df6ccc20 --- /dev/null +++ b/tests/providers/linode/linode_provider_test.py @@ -0,0 +1,146 @@ +import os +from unittest import mock + +import pytest + +from prowler.providers.linode.exceptions.exceptions import ( + LinodeAuthenticationError, + LinodeCredentialsError, + LinodeInvalidRegionError, +) +from prowler.providers.linode.linode_provider import LinodeProvider +from prowler.providers.linode.models import LinodeIdentityInfo, LinodeSession +from tests.providers.linode.linode_fixtures import ( + ACCOUNT_ID, + EMAIL, + TOKEN, + USERNAME, +) + + +class TestLinodeProvider_setup_session: + def test_missing_token_raises_credentials_error(self): + with mock.patch.dict(os.environ, {"LINODE_TOKEN": ""}, clear=False): + os.environ.pop("LINODE_TOKEN", None) + with pytest.raises(LinodeCredentialsError): + LinodeProvider.setup_session() + + def test_returns_session_with_token(self): + session = LinodeProvider.setup_session(token=TOKEN) + assert isinstance(session, LinodeSession) + assert session.token == TOKEN + assert session.client is not None + + def test_reads_token_from_env(self): + with mock.patch.dict(os.environ, {"LINODE_TOKEN": TOKEN}, clear=False): + session = LinodeProvider.setup_session() + assert session.token == TOKEN + + +class TestLinodeProvider_setup_identity: + def _build_session(self): + client = mock.MagicMock() + return LinodeSession(client=client, token=TOKEN) + + def test_resolves_identity_from_profile_and_account(self): + session = self._build_session() + profile = mock.MagicMock() + profile.username = USERNAME + profile.email = EMAIL + session.client.profile.return_value = profile + + account = mock.MagicMock() + account.euuid = ACCOUNT_ID + session.client.account.return_value = account + + identity = LinodeProvider.setup_identity(session) + + assert isinstance(identity, LinodeIdentityInfo) + assert identity.username == USERNAME + assert identity.email == EMAIL + assert identity.account_id == ACCOUNT_ID + + def test_invalid_token_raises_authentication_error(self): + # An invalid token fails the profile call (any valid token can read its + # own profile), so the scan must abort instead of returning empty data. + session = self._build_session() + session.client.profile.side_effect = Exception("[401] Invalid Token") + + with pytest.raises(LinodeAuthenticationError): + LinodeProvider.setup_identity(session) + + def test_identity_with_account_failure_still_returns(self): + session = self._build_session() + profile = mock.MagicMock() + profile.username = USERNAME + profile.email = EMAIL + session.client.profile.return_value = profile + session.client.account.side_effect = Exception("forbidden") + + identity = LinodeProvider.setup_identity(session) + + assert identity.username == USERNAME + assert identity.email == EMAIL + assert identity.account_id is None + + +class TestLinodeProvider_test_connection: + def test_successful_connection(self): + with mock.patch( + "prowler.providers.linode.linode_provider.LinodeProvider.setup_session" + ) as mock_session: + session = mock.MagicMock() + session.client.profile.return_value = mock.MagicMock() + mock_session.return_value = session + + conn = LinodeProvider.test_connection(token=TOKEN, raise_on_exception=False) + + assert conn.is_connected is True + + def test_missing_credentials(self): + with mock.patch.dict(os.environ, {"LINODE_TOKEN": ""}, clear=False): + os.environ.pop("LINODE_TOKEN", None) + conn = LinodeProvider.test_connection(token=None, raise_on_exception=False) + assert conn.is_connected is False + + def test_connection_failure_raises_when_requested(self): + with mock.patch( + "prowler.providers.linode.linode_provider.LinodeProvider.setup_session" + ) as mock_session: + mock_session.side_effect = LinodeCredentialsError( + file="test", message="No token" + ) + with pytest.raises(LinodeCredentialsError): + LinodeProvider.test_connection(token=None, raise_on_exception=True) + + +class TestLinodeProvider_validate_regions: + def _session_with_regions(self, region_ids): + client = mock.MagicMock() + client.regions.return_value = [mock.MagicMock(id=rid) for rid in region_ids] + return LinodeSession(client=client, token=TOKEN) + + def test_no_regions_returns_none(self): + session = self._session_with_regions(["eu-central", "us-east"]) + assert LinodeProvider.validate_regions(session, None) is None + + def test_valid_regions_returns_set(self): + session = self._session_with_regions(["eu-central", "us-east", "ap-south"]) + result = LinodeProvider.validate_regions(session, ["eu-central", "us-east"]) + assert result == {"eu-central", "us-east"} + + def test_invalid_region_raises(self): + session = self._session_with_regions(["eu-central", "us-east"]) + with pytest.raises(LinodeInvalidRegionError): + LinodeProvider.validate_regions(session, ["eu-central", "nonexistent"]) + + def test_regions_api_failure_does_not_block(self): + # If the public regions list cannot be fetched, the scan proceeds with + # the requested regions instead of failing. + client = mock.MagicMock() + client.regions.side_effect = Exception("regions API error") + session = LinodeSession(client=client, token=TOKEN) + + result = LinodeProvider.validate_regions(session, ["eu-central"]) + + assert result == {"eu-central"} diff --git a/tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py b/tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py new file mode 100644 index 0000000000..a4e77c662a --- /dev/null +++ b/tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py @@ -0,0 +1,97 @@ +from unittest import mock + +from prowler.providers.linode.services.administration.administration_service import ( + User, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_administration_user_2fa_enabled: + def test_no_users(self): + administration_client = mock.MagicMock() + administration_client.users = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled.administration_client", + new=administration_client, + ), + ): + from prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled import ( + administration_user_2fa_enabled, + ) + + check = administration_user_2fa_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_user_with_2fa(self): + administration_client = mock.MagicMock() + administration_client.users = [ + User( + username="admin", + email="admin@example.com", + tfa_enabled=True, + restricted=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled.administration_client", + new=administration_client, + ), + ): + from prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled import ( + administration_user_2fa_enabled, + ) + + check = administration_user_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "admin" + assert result[0].resource_id == "admin" + + def test_user_without_2fa(self): + administration_client = mock.MagicMock() + administration_client.users = [ + User( + username="dev-user", + email="dev@example.com", + tfa_enabled=False, + restricted=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled.administration_client", + new=administration_client, + ), + ): + from prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled import ( + administration_user_2fa_enabled, + ) + + check = administration_user_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "dev-user" + assert result[0].resource_id == "dev-user" diff --git a/tests/providers/linode/services/administration/linode_administration_service_test.py b/tests/providers/linode/services/administration/linode_administration_service_test.py new file mode 100644 index 0000000000..b8393c073a --- /dev/null +++ b/tests/providers/linode/services/administration/linode_administration_service_test.py @@ -0,0 +1,102 @@ +from unittest.mock import MagicMock, patch + +from linode_api4.errors import ApiError + +from prowler.providers.linode.services.administration.administration_service import ( + AdministrationService, +) + + +def _mock_user(username="admin", email="admin@example.com", tfa=True, restricted=False): + user = MagicMock() + user.username = username + user.email = email + user.tfa_enabled = tfa + user.restricted = restricted + return user + + +def _build_service(account_users_return=None, account_users_side_effect=None): + """Build an AdministrationService with an isolated mock client.""" + service = object.__new__(AdministrationService) + service.users = [] + + # Build isolated mock hierarchy for client.account.users() + # Must explicitly create the users callable as a fresh MagicMock + # because check tests contaminate MagicMock class with users=[...] + users_callable = MagicMock() + if account_users_side_effect: + users_callable.side_effect = account_users_side_effect + else: + users_callable.return_value = account_users_return or [] + + account_mock = MagicMock() + account_mock.users = users_callable + + client_mock = MagicMock() + client_mock.account = account_mock + service.client = client_mock + return service + + +class TestLinodeAdministrationService: + def test_describe_users_parses_correctly(self): + mock_users = [ + _mock_user("admin", "admin@example.com", True, False), + _mock_user("reader", "reader@example.com", False, True), + ] + + service = _build_service(account_users_return=mock_users) + service._describe_users() + + assert len(service.users) == 2 + assert service.users[0].username == "admin" + assert service.users[0].tfa_enabled is True + assert service.users[1].username == "reader" + assert service.users[1].restricted is True + + def test_describe_users_handles_empty_list(self): + service = _build_service(account_users_return=[]) + service._describe_users() + + assert len(service.users) == 0 + + def test_describe_users_handles_api_error(self): + service = _build_service(account_users_side_effect=Exception("API error")) + service._describe_users() + + assert len(service.users) == 0 + + def test_describe_users_handles_null_fields(self): + """A user with null tfa_enabled/email must still be included with safe + defaults instead of being dropped by a ValidationError.""" + user = MagicMock() + user.username = "partial" + user.email = None + user.tfa_enabled = None + user.restricted = None + + service = _build_service(account_users_return=[user]) + service._describe_users() + + assert len(service.users) == 1 + assert service.users[0].username == "partial" + assert service.users[0].email == "" + assert service.users[0].tfa_enabled is False + assert service.users[0].restricted is False + + def test_describe_users_missing_scope_logs_permission_error(self): + error = ApiError( + "Your OAuth token is not authorized to use this endpoint.", status=401 + ) + service = _build_service(account_users_side_effect=error) + + with patch( + "prowler.providers.linode.lib.service.service.logger" + ) as logger_mock: + service._describe_users() + + assert len(service.users) == 0 + logged = " ".join(str(c) for c in logger_mock.error.call_args_list) + assert "LinodeMissingPermissionError" in logged + assert "account:read_only" in logged diff --git a/tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py b/tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py new file mode 100644 index 0000000000..93e19fa6b1 --- /dev/null +++ b/tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py @@ -0,0 +1,109 @@ +from unittest import mock + +from prowler.providers.linode.services.compute.compute_service import ( + Instance, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_compute_instance_backups_enabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled import ( + compute_instance_backups_enabled, + ) + + check = compute_instance_backups_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_compute_instance_backups_enabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + backups_enabled=True, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled import ( + compute_instance_backups_enabled, + ) + + check = compute_instance_backups_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central has the Backup service enabled." + ) + + def test_instance_backups_disabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + backups_enabled=False, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled import ( + compute_instance_backups_enabled, + ) + + check = compute_instance_backups_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central does not have the Backup service enabled." + ) diff --git a/tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py b/tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py new file mode 100644 index 0000000000..d5023d2f94 --- /dev/null +++ b/tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py @@ -0,0 +1,109 @@ +from unittest import mock + +from prowler.providers.linode.services.compute.compute_service import ( + Instance, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_compute_instance_disk_encryption_enabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled import ( + compute_instance_disk_encryption_enabled, + ) + + check = compute_instance_disk_encryption_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_compute_instance_disk_encryption_enabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + disk_encryption="enabled", + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled import ( + compute_instance_disk_encryption_enabled, + ) + + check = compute_instance_disk_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central has disk encryption enabled." + ) + + def test_instance_disk_encryption_disabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + disk_encryption="disabled", + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled import ( + compute_instance_disk_encryption_enabled, + ) + + check = compute_instance_disk_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central does not have disk encryption enabled." + ) diff --git a/tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py b/tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py new file mode 100644 index 0000000000..db5ddaacd0 --- /dev/null +++ b/tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py @@ -0,0 +1,109 @@ +from unittest import mock + +from prowler.providers.linode.services.compute.compute_service import ( + Instance, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_compute_instance_watchdog_enabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled import ( + compute_instance_watchdog_enabled, + ) + + check = compute_instance_watchdog_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_compute_instance_watchdog_enabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + watchdog_enabled=True, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled import ( + compute_instance_watchdog_enabled, + ) + + check = compute_instance_watchdog_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central has Watchdog (Lassie) enabled." + ) + + def test_instance_watchdog_disabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + watchdog_enabled=False, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled import ( + compute_instance_watchdog_enabled, + ) + + check = compute_instance_watchdog_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central does not have Watchdog (Lassie) enabled." + ) diff --git a/tests/providers/linode/services/compute/linode_compute_service_test.py b/tests/providers/linode/services/compute/linode_compute_service_test.py new file mode 100644 index 0000000000..09b1a54e84 --- /dev/null +++ b/tests/providers/linode/services/compute/linode_compute_service_test.py @@ -0,0 +1,143 @@ +from unittest.mock import MagicMock, patch + +from linode_api4.errors import ApiError + +from prowler.providers.linode.services.compute.compute_service import ( + ComputeService, +) + + +def _mock_instance( + id=1, + label="my-instance", + region="us-east", + status="running", + backups_enabled=True, + disk_encryption="enabled", + watchdog_enabled=True, + tags=None, +): + inst = MagicMock() + inst.id = id + inst.label = label + region_mock = MagicMock() + region_mock.id = region + inst.region = region_mock + inst.status = status + backups = MagicMock() + backups.enabled = backups_enabled + inst.backups = backups + inst.disk_encryption = disk_encryption + inst.watchdog_enabled = watchdog_enabled + inst.tags = tags or [] + return inst + + +def _build_service(linode_instances_return=None, linode_instances_side_effect=None): + """Build a ComputeService with an isolated mock client.""" + service = object.__new__(ComputeService) + service.instances = [] + + # Build isolated mock hierarchy for client.linode.instances() + # Must explicitly create the instances callable as a fresh MagicMock + # because check tests contaminate MagicMock class with instances=[...] + instances_callable = MagicMock() + if linode_instances_side_effect: + instances_callable.side_effect = linode_instances_side_effect + else: + instances_callable.return_value = linode_instances_return or [] + + linode_mock = MagicMock() + linode_mock.instances = instances_callable + + client_mock = MagicMock() + client_mock.linode = linode_mock + service.client = client_mock + return service + + +class TestLinodeComputeService: + def test_describe_instances_parses_correctly(self): + mock_instances = [ + _mock_instance(id=1, label="web-1", region="us-east"), + _mock_instance(id=2, label="db-1", region="eu-west", backups_enabled=False), + ] + + service = _build_service(linode_instances_return=mock_instances) + service._describe_instances() + + assert len(service.instances) == 2 + assert service.instances[0].label == "web-1" + assert service.instances[0].region == "us-east" + assert service.instances[0].backups_enabled is True + assert service.instances[1].label == "db-1" + assert service.instances[1].backups_enabled is False + + def test_describe_instances_handles_empty_list(self): + service = _build_service(linode_instances_return=[]) + service._describe_instances() + + assert len(service.instances) == 0 + + def test_describe_instances_handles_api_error(self): + service = _build_service(linode_instances_side_effect=Exception("API error")) + service._describe_instances() + + assert len(service.instances) == 0 + + def test_describe_instances_missing_scope_logs_permission_error(self): + error = ApiError( + "Your OAuth token is not authorized to use this endpoint.", status=401 + ) + service = _build_service(linode_instances_side_effect=error) + + with patch( + "prowler.providers.linode.lib.service.service.logger" + ) as logger_mock: + service._describe_instances() + + assert len(service.instances) == 0 + logged = " ".join(str(c) for c in logger_mock.error.call_args_list) + assert "LinodeMissingPermissionError" in logged + assert "linodes:read_only" in logged + + def test_describe_instances_disk_encryption(self): + mock_instances = [ + _mock_instance(id=1, disk_encryption="enabled"), + _mock_instance(id=2, disk_encryption="disabled"), + ] + + service = _build_service(linode_instances_return=mock_instances) + service._describe_instances() + + assert service.instances[0].disk_encryption == "enabled" + assert service.instances[1].disk_encryption == "disabled" + + def test_describe_instances_region_filter_keeps_only_matching(self): + mock_instances = [ + _mock_instance(id=1, label="eu", region="eu-central"), + _mock_instance(id=2, label="us", region="us-east"), + _mock_instance(id=3, label="eu-2", region="eu-central"), + ] + service = _build_service(linode_instances_return=mock_instances) + service.provider = MagicMock() + service.provider.regions = {"eu-central"} + + service._describe_instances() + + assert len(service.instances) == 2 + assert {i.label for i in service.instances} == {"eu", "eu-2"} + assert all(i.region == "eu-central" for i in service.instances) + + def test_describe_instances_no_region_filter_keeps_all(self): + mock_instances = [ + _mock_instance(id=1, region="eu-central"), + _mock_instance(id=2, region="us-east"), + ] + service = _build_service(linode_instances_return=mock_instances) + service.provider = MagicMock() + service.provider.regions = None + + service._describe_instances() + + assert len(service.instances) == 2 diff --git a/tests/providers/linode/services/networking/linode_networking_service_test.py b/tests/providers/linode/services/networking/linode_networking_service_test.py new file mode 100644 index 0000000000..cd4f43f423 --- /dev/null +++ b/tests/providers/linode/services/networking/linode_networking_service_test.py @@ -0,0 +1,193 @@ +from unittest.mock import MagicMock, patch + +from linode_api4.errors import ApiError + +from prowler.providers.linode.services.networking.networking_service import ( + NetworkingService, +) + + +def _mock_rule( + protocol="TCP", ports="22", ipv4=None, ipv6=None, action="ACCEPT", label="" +): + rule = MagicMock() + rule.protocol = protocol + rule.ports = ports + rule.action = action + rule.label = label + addresses = MagicMock() + addresses.ipv4 = ipv4 or [] + addresses.ipv6 = ipv6 or [] + rule.addresses = addresses + return rule + + +def _mock_firewall( + id=1, label="my-fw", status="enabled", inbound=None, outbound=None, tags=None +): + fw = MagicMock() + fw.id = id + fw.label = label + fw.status = status + fw.tags = tags or [] + rules = MagicMock() + rules.inbound = inbound or [] + rules.outbound = outbound or [] + rules.inbound_policy = "DROP" + rules.outbound_policy = "DROP" + fw.rules = rules + return fw + + +def _build_service( + networking_firewalls_return=None, networking_firewalls_side_effect=None +): + """Build a NetworkingService instance with a properly isolated mock client.""" + service = object.__new__(NetworkingService) + service.firewalls = [] + + firewalls_callable = MagicMock() + if networking_firewalls_side_effect: + firewalls_callable.side_effect = networking_firewalls_side_effect + else: + firewalls_callable.return_value = networking_firewalls_return or [] + + networking_mock = MagicMock() + networking_mock.firewalls = firewalls_callable + + client_mock = MagicMock() + client_mock.networking = networking_mock + service.client = client_mock + return service + + +class TestLinodeNetworkingService: + def test_describe_firewalls_parses_correctly(self): + inbound_rules = [ + _mock_rule("TCP", "22", ipv4=["192.168.1.0/24"]), + _mock_rule("TCP", "443", ipv4=["0.0.0.0/0"]), + ] + mock_fws = [ + _mock_firewall(id=1, label="prod-fw", inbound=inbound_rules), + ] + + service = _build_service(networking_firewalls_return=mock_fws) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + assert service.firewalls[0].label == "prod-fw" + assert len(service.firewalls[0].inbound_rules) == 2 + assert service.firewalls[0].inbound_rules[0].ports == "22" + assert service.firewalls[0].inbound_rules[0].addresses_ipv4 == [ + "192.168.1.0/24" + ] + assert service.firewalls[0].inbound_rules[1].addresses_ipv4 == ["0.0.0.0/0"] + + def test_describe_firewalls_handles_empty_list(self): + service = _build_service(networking_firewalls_return=[]) + service._describe_firewalls() + + assert len(service.firewalls) == 0 + + def test_describe_firewalls_handles_api_error(self): + service = _build_service( + networking_firewalls_side_effect=Exception("API error") + ) + service._describe_firewalls() + + assert len(service.firewalls) == 0 + + def test_describe_firewalls_missing_scope_logs_permission_error(self): + error = ApiError( + "Your OAuth token is not authorized to use this endpoint.", status=401 + ) + service = _build_service(networking_firewalls_side_effect=error) + + with patch( + "prowler.providers.linode.lib.service.service.logger" + ) as logger_mock: + service._describe_firewalls() + + assert len(service.firewalls) == 0 + logged = " ".join(str(c) for c in logger_mock.error.call_args_list) + assert "LinodeMissingPermissionError" in logged + assert "firewall:read_only" in logged + + def test_describe_firewalls_device_fetch_error_yields_none_count(self): + """A devices fetch failure must leave attached_devices_count as None + (undetermined) rather than 0, to avoid a false 'not assigned' FAIL.""" + + class _NoDevicesFw: + id = 5 + label = "no-devices-fw" + status = "enabled" + tags = [] + + @property + def devices(self): + raise Exception("devices API error") + + @property + def rules(self): + r = MagicMock() + r.inbound = [] + r.outbound = [] + r.inbound_policy = "DROP" + r.outbound_policy = "DROP" + return r + + service = _build_service(networking_firewalls_return=[_NoDevicesFw()]) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + assert service.firewalls[0].attached_devices_count is None + + def test_describe_firewalls_handles_null_rule_fields(self): + """Rule fields returned as explicit null must fall back to defaults + instead of raising a ValidationError that drops the whole firewall.""" + rule = MagicMock() + rule.protocol = None + rule.ports = None + rule.action = None + rule.label = None + addresses = MagicMock() + addresses.ipv4 = None + addresses.ipv6 = None + rule.addresses = addresses + + mock_fws = [_mock_firewall(id=6, label="null-rule-fw", inbound=[rule])] + + service = _build_service(networking_firewalls_return=mock_fws) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + parsed = service.firewalls[0].inbound_rules[0] + assert parsed.protocol == "TCP" + assert parsed.action == "ACCEPT" + assert parsed.ports == "" + assert parsed.addresses_ipv4 == [] + assert parsed.addresses_ipv6 == [] + assert parsed.label == "" + + def test_describe_firewalls_handles_rules_fetch_error(self): + """Firewall is still added even if rules fail to load.""" + + class _BrokenFw: + id = 1 + label = "broken-fw" + status = "enabled" + tags = [] + devices = [] + + @property + def rules(self): + raise Exception("rules API error") + + fw = _BrokenFw() + + service = _build_service(networking_firewalls_return=[fw]) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + assert service.firewalls[0].label == "broken-fw" + assert len(service.firewalls[0].inbound_rules) == 0 diff --git a/tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py b/tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py new file mode 100644 index 0000000000..72d8a9f454 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py @@ -0,0 +1,142 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_assigned_to_devices: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 0 + + def test_firewall_assigned(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="assigned-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=2, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "assigned-fw" + + def test_firewall_not_assigned(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="unassigned-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=0, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "unassigned-fw" + + def test_firewall_device_count_undetermined_is_skipped(self): + # attached_devices_count is None when the devices fetch failed; the + # firewall must be skipped rather than reported as a false FAIL. + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=102, + label="undetermined-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=None, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 0 diff --git a/tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py b/tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py new file mode 100644 index 0000000000..1543a45656 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_default_inbound_policy_drop: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop import ( + networking_firewall_default_inbound_policy_drop, + ) + + check = networking_firewall_default_inbound_policy_drop() + result = check.execute() + + assert len(result) == 0 + + def test_inbound_policy_drop(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="drop-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop import ( + networking_firewall_default_inbound_policy_drop, + ) + + check = networking_firewall_default_inbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "drop-fw" + + def test_inbound_policy_accept(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="accept-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="ACCEPT", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop import ( + networking_firewall_default_inbound_policy_drop, + ) + + check = networking_firewall_default_inbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "accept-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py b/tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py new file mode 100644 index 0000000000..5a586d710c --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_default_outbound_policy_drop: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop import ( + networking_firewall_default_outbound_policy_drop, + ) + + check = networking_firewall_default_outbound_policy_drop() + result = check.execute() + + assert len(result) == 0 + + def test_outbound_policy_drop(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="drop-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop import ( + networking_firewall_default_outbound_policy_drop, + ) + + check = networking_firewall_default_outbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "drop-fw" + + def test_outbound_policy_accept(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="accept-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="ACCEPT", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop import ( + networking_firewall_default_outbound_policy_drop, + ) + + check = networking_firewall_default_outbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "accept-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py b/tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py new file mode 100644 index 0000000000..9b8272c2f2 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py @@ -0,0 +1,117 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import ( + Firewall, + FirewallRule, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_inbound_rules_configured: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured import ( + networking_firewall_inbound_rules_configured, + ) + + check = networking_firewall_inbound_rules_configured() + result = check.execute() + + assert len(result) == 0 + + def test_inbound_rules_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="empty-inbound-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured import ( + networking_firewall_inbound_rules_configured, + ) + + check = networking_firewall_inbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "100" + assert result[0].resource_name == "empty-inbound-fw" + + def test_inbound_rules_not_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="non-empty-inbound-fw", + status="enabled", + inbound_rules=[ + FirewallRule( + protocol="TCP", + ports="443", + addresses_ipv4=["0.0.0.0/0"], + addresses_ipv6=[], + action="ACCEPT", + label="allow-https", + ) + ], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured import ( + networking_firewall_inbound_rules_configured, + ) + + check = networking_firewall_inbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "101" + assert result[0].resource_name == "non-empty-inbound-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py b/tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py new file mode 100644 index 0000000000..658569c940 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py @@ -0,0 +1,117 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import ( + Firewall, + FirewallRule, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_outbound_rules_configured: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured import ( + networking_firewall_outbound_rules_configured, + ) + + check = networking_firewall_outbound_rules_configured() + result = check.execute() + + assert len(result) == 0 + + def test_outbound_rules_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="empty-outbound-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured import ( + networking_firewall_outbound_rules_configured, + ) + + check = networking_firewall_outbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "100" + assert result[0].resource_name == "empty-outbound-fw" + + def test_outbound_rules_not_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="non-empty-outbound-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[ + FirewallRule( + protocol="TCP", + ports="443", + addresses_ipv4=["0.0.0.0/0"], + addresses_ipv6=[], + action="ACCEPT", + label="allow-https-egress", + ) + ], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured import ( + networking_firewall_outbound_rules_configured, + ) + + check = networking_firewall_outbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "101" + assert result[0].resource_name == "non-empty-outbound-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py b/tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py new file mode 100644 index 0000000000..632d94ca2e --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_status_enabled: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled import ( + networking_firewall_status_enabled, + ) + + check = networking_firewall_status_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_firewall_enabled(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="enabled-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled import ( + networking_firewall_status_enabled, + ) + + check = networking_firewall_status_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "enabled-fw" + + def test_firewall_disabled(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="disabled-fw", + status="disabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled import ( + networking_firewall_status_enabled, + ) + + check = networking_firewall_status_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "disabled-fw" diff --git a/ui/.prettierignore b/ui/.prettierignore index 5377d96f2d..8b679ff005 100644 --- a/ui/.prettierignore +++ b/ui/.prettierignore @@ -8,6 +8,7 @@ build/ coverage/ dist/ esm/ +CHANGELOG.md # Generated files next-env.d.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 1baddf169a..d1951c68a8 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.31.0] (Prowler UNRELEASED) +## [1.31.0] (Prowler v5.31.0) ### 🚀 Added @@ -10,11 +10,14 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed +- Attack Paths now shows distinct messages while a scan is queued, running, or building its graph — plus a separate "couldn't load scans" error — instead of always showing "No scans available" [(#11512)](https://github.com/prowler-cloud/prowler/pull/11512) - Radio button no longer shifts vertically when selected [(#11608)](https://github.com/prowler-cloud/prowler/pull/11608) +- Handle rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) ### 🔐 Security - Bump vulnerable `Next.js`, React, AI SDK, `postcss`, `hono`, `qs`, `esbuild`, and Alpine OpenSSL packages (`libcrypto3` and `libssl3`) [(#11581)](https://github.com/prowler-cloud/prowler/pull/11581) +- Bump transitive `dompurify` from 3.4.2 to 3.4.10, patching XSS sanitization bypass advisories [(#11636)](https://github.com/prowler-cloud/prowler/pull/11636) --- @@ -25,6 +28,14 @@ All notable changes to the **Prowler UI** are documented in this file. - Threat Map no longer shows an empty map for accounts that only have Okta or Google Workspace scans [(#11542)](https://github.com/prowler-cloud/prowler/pull/11542) - Compliance attributes requests now pass the selected scan, so multi-provider universal frameworks (e.g. CSA CCM) load the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) +### 🔄 Changed + +- Public SaaS config (Sentry, Google Tag Manager, API base/docs URL) now resolves at container runtime instead of build time; self-hosted deployments set the UI config through the new `UI_`-prefixed env vars (`UI_API_BASE_URL`, `UI_API_DOCS_URL`, `UI_GOOGLE_TAG_MANAGER_ID`, `UI_SENTRY_DSN`, `UI_SENTRY_ENVIRONMENT`), with the previous `NEXT_PUBLIC_*` names still honored as a deprecated fallback [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500) + +### 🐞 Fixed + +- `ui/.env` template now lists only the canonical `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT` names; the deprecated `NEXT_PUBLIC_SENTRY_DSN` and `NEXT_PUBLIC_SENTRY_ENVIRONMENT` entries have been removed [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500) + --- ## [1.30.0] (Prowler v5.30.0) diff --git a/ui/Dockerfile b/ui/Dockerfile index 6cf930ee5e..86673ba046 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -36,12 +36,8 @@ RUN corepack install ENV NEXT_TELEMETRY_DISABLED=1 ARG NEXT_PUBLIC_PROWLER_RELEASE_VERSION ENV NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${NEXT_PUBLIC_PROWLER_RELEASE_VERSION} -ARG NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID -ENV NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=${NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID} -ARG NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} -ARG NEXT_PUBLIC_API_DOCS_URL -ENV NEXT_PUBLIC_API_DOCS_URL=${NEXT_PUBLIC_API_DOCS_URL} + +# GTM / API base+docs URLs are runtime container env (prod stage), not build ARGs. RUN pnpm run build @@ -78,6 +74,12 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +# Runtime configuration is read by `node server.js` at container start and is +# NOT baked into the image. Supply it via your orchestrator (docker-compose, +# Helm/K8s): +# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot) +# - optional: UI_API_DOCS_URL, UI_GOOGLE_TAG_MANAGER_ID, UI_SENTRY_DSN, UI_SENTRY_ENVIRONMENT +# - reserved: POSTHOG_KEY, POSTHOG_HOST, REO_DEV_CLIENT_ID (no consumer yet) # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output CMD ["node", "server.js"] diff --git a/ui/__tests__/msw/handlers/attack-paths.ts b/ui/__tests__/msw/handlers/attack-paths.ts index 39076666dd..7c76fa574b 100644 --- a/ui/__tests__/msw/handlers/attack-paths.ts +++ b/ui/__tests__/msw/handlers/attack-paths.ts @@ -10,7 +10,7 @@ import type { QueryResultAttributes, } from "@/types/attack-paths"; -const API = process.env.NEXT_PUBLIC_API_BASE_URL; +const API = process.env.UI_API_BASE_URL; type JsonApiErrorBody = { errors: Array<{ detail: string; status: string }>; diff --git a/ui/actions/resources/resources.test.ts b/ui/actions/resources/resources.test.ts index cbdb0b1296..d8c5d75456 100644 --- a/ui/actions/resources/resources.test.ts +++ b/ui/actions/resources/resources.test.ts @@ -28,6 +28,10 @@ import { vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", getAuthHeaders: getAuthHeadersMock, + GENERIC_SERVER_ERROR_MESSAGE: + "Server is temporarily unavailable. Please try again in a few minutes.", + sanitizeErrorMessage: (message: string, fallback: string) => + / { }); }); + it("returns a generic error when a gateway returns HTML", async () => { + // Given + const mockResponse = new Response( + "502 Bad Gateway

    502 Bad Gateway

    ", + { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }, + ); + fetchMock.mockResolvedValue(mockResponse); + + // When + const result = await getResourceEvents("resource-123"); + + // Then + expect(result).toEqual({ + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + status: 502, + }); + }); + it("returns generic error when fetch throws", async () => { // Given fetchMock.mockRejectedValue(new Error("Network failure")); diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts index 8af2576852..3ab84efa4f 100644 --- a/ui/actions/resources/resources.ts +++ b/ui/actions/resources/resources.ts @@ -4,7 +4,13 @@ import { redirect } from "next/navigation"; import { getLatestFindings } from "@/actions/findings"; import { listOrganizationsSafe } from "@/actions/organizations/organizations"; -import { apiBaseUrl, FINDINGS_FILTERED_SORT, getAuthHeaders } from "@/lib"; +import { + apiBaseUrl, + FINDINGS_FILTERED_SORT, + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + sanitizeErrorMessage, +} from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; import { OrganizationResource } from "@/types/organizations"; @@ -193,22 +199,27 @@ export const getResourceEvents = async ( if (!response.ok) { const rawText = await response.text().catch(() => ""); + const contentType = + response.headers.get("content-type")?.toLowerCase() || ""; const defaultError = "An error occurred while fetching events."; + const fallbackError = contentType.includes("text/html") + ? GENERIC_SERVER_ERROR_MESSAGE + : response.statusText || defaultError; try { const errorData = rawText ? JSON.parse(rawText) : null; + const errorMessage = + errorData?.errors?.[0]?.detail || + errorData?.error || + errorData?.message || + rawText || + fallbackError; return { - error: - errorData?.errors?.[0]?.detail || - errorData?.error || - errorData?.message || - rawText || - response.statusText || - defaultError, + error: sanitizeErrorMessage(String(errorMessage), fallbackError), status: response.status, }; } catch { return { - error: rawText || response.statusText || defaultError, + error: sanitizeErrorMessage(rawText || fallbackError, fallbackError), status: response.status, }; } diff --git a/ui/actions/scans/scans.test.ts b/ui/actions/scans/scans.test.ts index ae82ba860d..a7f7fd0c7e 100644 --- a/ui/actions/scans/scans.test.ts +++ b/ui/actions/scans/scans.test.ts @@ -14,6 +14,8 @@ const { vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", + GENERIC_SERVER_ERROR_MESSAGE: + "Server is temporarily unavailable. Please try again in a few minutes.", getAuthHeaders: getAuthHeadersMock, getErrorMessage: (error: unknown) => error instanceof Error ? error.message : String(error), @@ -28,7 +30,7 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({ addScanOperation: vi.fn(), })); -import { launchOrganizationScans } from "./scans"; +import { getExportsZip, launchOrganizationScans } from "./scans"; describe("launchOrganizationScans", () => { beforeEach(() => { @@ -69,3 +71,34 @@ describe("launchOrganizationScans", () => { expect(result.failureCount).toBe(0); }); }); + +describe("getExportsZip", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("returns a generic server error when the report endpoint returns HTML", async () => { + // Given + fetchMock.mockResolvedValue( + new Response( + "502 Bad Gateway

    502 Bad Gateway

    ", + { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }, + ), + ); + + // When + const result = await getExportsZip("scan-123"); + + // Then + expect(result).toEqual({ + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + }); + }); +}); diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index fb32a6286e..5a6ddb7a29 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -3,7 +3,12 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib"; +import { + apiBaseUrl, + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + getErrorMessage, +} from "@/lib"; import { COMPLIANCE_REPORT_DISPLAY_NAMES, type ComplianceReportType, @@ -162,18 +167,20 @@ export const scheduleDaily = async (formData: FormData) => { const url = new URL(`${apiBaseUrl}/schedules/daily`); + const body = { + data: { + type: "daily-schedules", + attributes: { + provider_id: providerId, + }, + }, + }; + try { const response = await fetch(url.toString(), { method: "POST", headers, - body: JSON.stringify({ - data: { - type: "daily-schedules", - attributes: { - provider_id: providerId, - }, - }, - }), + body: JSON.stringify(body), }); return handleApiResponse(response, "/scans"); @@ -249,6 +256,27 @@ export const launchOrganizationScans = async ( return summary; }; +async function getScanReportErrorMessage( + response: Response, + fallbackMessage: string, +): Promise { + const contentType = response.headers.get("content-type")?.toLowerCase() || ""; + + if (contentType.includes("text/html")) { + return GENERIC_SERVER_ERROR_MESSAGE; + } + + const errorData = await response.json().catch(() => null); + + return ( + errorData?.errors?.[0]?.detail || + errorData?.errors?.detail || + errorData?.error || + errorData?.message || + (response.status >= 500 ? GENERIC_SERVER_ERROR_MESSAGE : fallbackMessage) + ); +} + export const updateScan = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); @@ -300,11 +328,11 @@ export const getExportsZip = async (scanId: string) => { } if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData?.errors?.detail || + await getScanReportErrorMessage( + response, "Unable to fetch scan report. Contact support if the issue continues.", + ), ); } @@ -375,10 +403,11 @@ const _fetchScanBinary = async ( } if (!response.ok) { - const errorData = await response.json().catch(() => ({})); throw new Error( - errorData?.errors?.detail || + await getScanReportErrorMessage( + response, `Unable to retrieve ${errorLabel}. Contact support if the issue continues.`, + ), ); } diff --git a/ui/actions/schedules/index.ts b/ui/actions/schedules/index.ts new file mode 100644 index 0000000000..7e6e7b459a --- /dev/null +++ b/ui/actions/schedules/index.ts @@ -0,0 +1 @@ +export * from "./schedules"; diff --git a/ui/actions/schedules/schedules.test.ts b/ui/actions/schedules/schedules.test.ts new file mode 100644 index 0000000000..3122430f83 --- /dev/null +++ b/ui/actions/schedules/schedules.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SCHEDULE_FREQUENCY, + type ScheduleUpdatePayload, +} from "@/types/schedules"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, + revalidatePathMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), + revalidatePathMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { + getSchedule, + getSchedulesPage, + removeSchedule, + updateSchedule, + updateSchedulesBulk, +} from "./schedules"; + +const PROVIDER_ID = "1795f636-37e6-42f6-b158-d4faaa64e0fc"; +const SECOND_PROVIDER_ID = "9b7fae7d-5e72-49fe-8b0b-5bff5930db1a"; + +const payload = { + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: 12, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, +}; + +describe("schedule write actions revalidate only on success", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + handleApiErrorMock.mockReturnValue({ error: "Failed" }); + }); + + it("revalidates /scans and /providers after a successful update", async () => { + handleApiResponseMock.mockResolvedValue({ success: true }); + + await updateSchedule(PROVIDER_ID, payload); + + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("posts the JSON:API bulk schedule payload", async () => { + handleApiResponseMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: [PROVIDER_ID, SECOND_PROVIDER_ID], + failed: [], + }, + }, + }); + + await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/schedules/bulk", + expect.objectContaining({ + method: "POST", + headers: { Authorization: "Bearer token" }, + body: JSON.stringify({ + data: { + type: "schedules-bulk", + attributes: { + schedule: payload, + provider_ids: [PROVIDER_ID, SECOND_PROVIDER_ID], + }, + }, + }), + }), + ); + }); + + it("rejects invalid bulk provider ids without issuing a request", async () => { + expect( + await updateSchedulesBulk([PROVIDER_ID, "../users/me"], payload), + ).toEqual({ + error: "Invalid provider ids.", + }); + expect(await updateSchedulesBulk([], payload)).toEqual({ + error: "Invalid provider ids.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects invalid bulk schedule payloads without issuing a request", async () => { + const invalidPayload = { + ...payload, + scan_hour: "12", + } as unknown as ScheduleUpdatePayload; + + expect( + await updateSchedulesBulk( + [PROVIDER_ID, SECOND_PROVIDER_ID], + invalidPayload, + ), + ).toEqual({ + error: "Invalid schedule payload.", + }); + expect(getAuthHeadersMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("normalizes bulk auth header errors", async () => { + const authError = new Error("Session expired"); + getAuthHeadersMock.mockRejectedValue(authError); + + expect( + await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload), + ).toEqual({ error: "Failed" }); + expect(handleApiErrorMock).toHaveBeenCalledWith(authError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("revalidates /scans and /providers after a partial bulk success", async () => { + handleApiResponseMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: [PROVIDER_ID], + failed: [{ provider_id: SECOND_PROVIDER_ID, error: "Denied" }], + }, + }, + }); + + const result = await updateSchedulesBulk( + [PROVIDER_ID, SECOND_PROVIDER_ID], + payload, + ); + + expect(result.data?.attributes?.updated).toEqual([PROVIDER_ID]); + expect(result.data?.attributes?.failed).toHaveLength(1); + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("does not revalidate when the bulk update returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Bulk rejected" }); + + await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("does not revalidate when the update returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Schedule rejected" }); + + await updateSchedule(PROVIDER_ID, payload); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("revalidates /scans and /providers after a successful delete", async () => { + handleApiResponseMock.mockResolvedValue({ success: true }); + + await removeSchedule(PROVIDER_ID); + + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("does not revalidate when the delete returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Not allowed" }); + + await removeSchedule(PROVIDER_ID); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("rejects non-UUID provider ids without issuing a request", async () => { + const malicious = "../users/me"; + + expect(await getSchedule(malicious)).toEqual({ + error: "Invalid provider id.", + }); + expect(await updateSchedule(malicious, payload)).toEqual({ + error: "Invalid provider id.", + }); + expect(await removeSchedule(malicious)).toEqual({ + error: "Invalid provider id.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("getSchedulesPage delegates pagination to the endpoint", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + handleApiErrorMock.mockReturnValue({ error: "Failed" }); + }); + + it("requests only configured schedules with native pagination and include", async () => { + handleApiResponseMock.mockResolvedValue({ + data: [], + included: [], + meta: { pagination: { page: 2, pages: 4, count: 35 } }, + }); + + const result = await getSchedulesPage({ + page: 2, + pageSize: 10, + sort: "next_scan_at", + filters: { "filter[provider_type__in]": "aws,azure" }, + }); + + const calledUrl = new URL(fetchMock.mock.calls[0][0] as string); + expect(calledUrl.pathname).toBe("/api/v1/schedules"); + expect(calledUrl.searchParams.get("filter[configured]")).toBe("true"); + expect(calledUrl.searchParams.get("include")).toBe("provider"); + expect(calledUrl.searchParams.get("page[number]")).toBe("2"); + expect(calledUrl.searchParams.get("page[size]")).toBe("10"); + expect(calledUrl.searchParams.get("sort")).toBe("next_scan_at"); + expect(calledUrl.searchParams.get("filter[provider_type__in]")).toBe( + "aws,azure", + ); + // meta is propagated verbatim so the table paginates natively. + expect(result?.meta?.pagination?.count).toBe(35); + }); + + it("normalizes array filter values into a CSV param", async () => { + handleApiResponseMock.mockResolvedValue({ data: [], meta: {} }); + + await getSchedulesPage({ + filters: { "filter[provider__in]": ["id-1", "id-2"] }, + }); + + const calledUrl = new URL(fetchMock.mock.calls[0][0] as string); + expect(calledUrl.searchParams.get("filter[provider__in]")).toBe( + "id-1,id-2", + ); + }); +}); diff --git a/ui/actions/schedules/schedules.ts b/ui/actions/schedules/schedules.ts new file mode 100644 index 0000000000..63ef862b3c --- /dev/null +++ b/ui/actions/schedules/schedules.ts @@ -0,0 +1,214 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { scheduleUpdatePayloadSchema } from "@/lib/schedules"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import type { + ScheduleProps, + SchedulesBulkResponse, + ScheduleUpdatePayload, +} from "@/types/schedules"; + +// SSRF guard: the id is interpolated into the request URL, so only UUIDs pass. +const providerIdSchema = z.uuid(); + +function parseProviderId(providerId: string): string | null { + const parsed = providerIdSchema.safeParse(providerId); + return parsed.success ? parsed.data : null; +} + +function parseProviderIds(providerIds: string[]): string[] | null { + if (providerIds.length === 0) return null; + + const ids = providerIds.map(parseProviderId); + return ids.every((id): id is string => id !== null) ? ids : null; +} + +function revalidateScheduleViews() { + revalidatePath("/scans"); + revalidatePath("/providers"); +} + +export const getSchedule = async (providerId: string) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + url.searchParams.set("include", "provider"); + + try { + const response = await fetch(url.toString(), { headers }); + + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +/** + * Lists every schedule (one per provider), following pagination — the backend + * has no multi-provider filter. + */ +export const getSchedules = async () => { + const headers = await getAuthHeaders({ contentType: false }); + const schedules: ScheduleProps[] = []; + const MAX_PAGES = 20; + + try { + for (let page = 1; page <= MAX_PAGES; page++) { + const url = new URL(`${apiBaseUrl}/schedules`); + url.searchParams.set("page[number]", String(page)); + url.searchParams.set("page[size]", "100"); + + const response = await fetch(url.toString(), { headers }); + + const result = await handleApiResponse(response); + if (result?.error) return result; + + schedules.push(...(result?.data ?? [])); + + const totalPages = result?.meta?.pagination?.pages ?? 1; + if (page >= totalPages) break; + } + + return { data: schedules }; + } catch (error) { + return handleApiError(error); + } +}; + +/** Fetches one page of configured schedules for the Scheduled tab, with native pagination. */ +export const getSchedulesPage = async ({ + page = 1, + pageSize = 10, + sort = "", + filters = {}, +}: { + page?: number; + pageSize?: number; + sort?: string; + filters?: Record; +} = {}) => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/schedules`); + + url.searchParams.set("filter[configured]", "true"); + url.searchParams.set("include", "provider"); + url.searchParams.set("page[number]", String(page)); + url.searchParams.set("page[size]", String(pageSize)); + if (sort) url.searchParams.set("sort", sort); + + for (const [key, value] of Object.entries(filters)) { + const normalized = Array.isArray(value) ? value.join(",") : value; + if (normalized) url.searchParams.set(key, normalized); + } + + try { + const response = await fetch(url.toString(), { headers }); + + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +export const updateSchedule = async ( + providerId: string, + payload: ScheduleUpdatePayload, +) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + + const body = { + data: { + type: "schedules", + id, + attributes: payload, + }, + }; + + try { + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(body), + }); + + const result = await handleApiResponse(response); + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; + +export const updateSchedulesBulk = async ( + providerIds: string[], + payload: ScheduleUpdatePayload, +): Promise => { + const ids = parseProviderIds(providerIds); + if (!ids) return { error: "Invalid provider ids." }; + + const parsedPayload = scheduleUpdatePayloadSchema.safeParse(payload); + if (!parsedPayload.success) return { error: "Invalid schedule payload." }; + + try { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/bulk`); + const body = { + data: { + type: "schedules-bulk", + attributes: { + schedule: parsedPayload.data, + provider_ids: ids, + }, + }, + }; + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + const result = (await handleApiResponse(response)) as SchedulesBulkResponse; + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; + +export const removeSchedule = async (providerId: string) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + const result = await handleApiResponse(response); + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts index d95966e680..41118bd6e2 100644 --- a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts +++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts @@ -14,7 +14,7 @@ const lastFetchCall = (): { url: string; init: RequestInit } => { describe("confirmAlertRecipient", () => { beforeEach(() => { vi.stubGlobal("fetch", fetchMock); - vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1"); + vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1"); fetchMock.mockResolvedValue( new Response( JSON.stringify({ @@ -104,7 +104,8 @@ describe("confirmAlertRecipient", () => { }); it("returns the fallback message when the API base URL is missing", async () => { - // Given + // Given - neither the new name nor its legacy fallback is set + vi.stubEnv("UI_API_BASE_URL", ""); vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); // When @@ -120,6 +121,21 @@ describe("confirmAlertRecipient", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => { + // Given - only the legacy name is configured + vi.stubEnv("UI_API_BASE_URL", undefined); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1"); + + // When + const result = await confirmAlertRecipient("token-1"); + + // Then + expect(result.ok).toBe(true); + expect(lastFetchCall().url).toBe( + "https://legacy.example.com/api/v1/alerts/recipients/confirm?token=token-1", + ); + }); + it("returns the fallback message when the request fails", async () => { // Given fetchMock.mockRejectedValueOnce(new Error("network down")); diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts index b567bb4124..19e9216339 100644 --- a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts +++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts @@ -1,3 +1,5 @@ +import { readEnv } from "@/lib/runtime-env"; + interface AlertConfirmApiResponse { state?: string; message?: string; @@ -41,7 +43,7 @@ const toState = (payload: unknown): string => { export const confirmAlertRecipient = async ( token?: string, ): Promise => { - const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"); if (!apiBaseUrl) { return { ok: false, diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts index 8a595fa63e..508625b4be 100644 --- a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts +++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts @@ -14,7 +14,7 @@ const lastFetchCall = (): { url: string; init: RequestInit } => { describe("unsubscribeAlertRecipient", () => { beforeEach(() => { vi.stubGlobal("fetch", fetchMock); - vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1"); + vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1"); fetchMock.mockResolvedValue( new Response( JSON.stringify({ @@ -102,4 +102,37 @@ describe("unsubscribeAlertRecipient", () => { "https://api.example.com/api/v1/alerts/recipients/unsubscribe", ); }); + + it("returns the fallback message when the API base URL is missing", async () => { + // Given - neither the new name nor its legacy fallback is set + vi.stubEnv("UI_API_BASE_URL", ""); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); + + // When + const result = await unsubscribeAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: false, + state: "missing_api_base_url", + message: + "We could not process this unsubscribe link. Please try again later.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => { + // Given - only the legacy name is configured + vi.stubEnv("UI_API_BASE_URL", undefined); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1"); + + // When + const result = await unsubscribeAlertRecipient("token-1"); + + // Then + expect(result.ok).toBe(true); + expect(lastFetchCall().url).toBe( + "https://legacy.example.com/api/v1/alerts/recipients/unsubscribe?token=token-1", + ); + }); }); diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts index af64165a1e..8c3a4e2a79 100644 --- a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts +++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts @@ -1,3 +1,5 @@ +import { readEnv } from "@/lib/runtime-env"; + interface AlertUnsubscribeApiResponse { state?: string; message?: string; @@ -41,7 +43,7 @@ const toState = (payload: unknown): string => { export const unsubscribeAlertRecipient = async ( token?: string, ): Promise => { - const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"); if (!apiBaseUrl) { return { ok: false, diff --git a/ui/app/(auth)/layout.tsx b/ui/app/(auth)/layout.tsx index b8d7d05248..08fc27c7c4 100644 --- a/ui/app/(auth)/layout.tsx +++ b/ui/app/(auth)/layout.tsx @@ -2,12 +2,15 @@ import "@/styles/globals.css"; import { GoogleTagManager } from "@next/third-parties/google"; import { Metadata, Viewport } from "next"; +import { connection } from "next/server"; import { ReactNode, Suspense } from "react"; +import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config"; import { NavigationProgress, Toaster } from "@/components/shadcn"; import { fontMono, fontSans } from "@/config/fonts"; import { siteConfig } from "@/config/site"; import { cn } from "@/lib"; +import { readEnv } from "@/lib/runtime-env"; import { Providers } from "../providers"; @@ -29,10 +32,27 @@ export const viewport: Viewport = { ], }; -export default function AuthLayout({ children }: { children: ReactNode }) { +export default async function AuthLayout({ + children, +}: { + children: ReactNode; +}) { + // Force dynamic rendering so the read below resolves from the container env + // at request time rather than being snapshotted at build (independent of the + // island's own connection() call). + await connection(); + + // Server-side runtime read. Empty/unset id ⇒ GoogleTagManager is not mounted + const gtmId = readEnv( + "UI_GOOGLE_TAG_MANAGER_ID", + "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID", + ); + return ( - + + + {children} - + {gtmId && } diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index 19c63cf3d2..8a738ef5e1 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -2,6 +2,8 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import { FilterType } from "@/types/filters"; + import { AccountsSelector } from "./accounts-selector"; const multiSelectContentSpy = vi.fn(); @@ -170,7 +172,10 @@ describe("AccountsSelector", () => { it("can use provider UID values for pages whose API filters by provider_uid__in", () => { render( - , + , ); expect( diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 0c389febb7..128784517f 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -17,25 +17,18 @@ import { MultiSelectValue, } from "@/components/shadcn/select/multiselect"; import { useUrlFilters } from "@/hooks/use-url-filters"; +import { type AccountFilterKey, FilterType } from "@/types/filters"; import { getProviderDisplayName, type ProviderProps, type ProviderType, } from "@/types/providers"; -const ACCOUNT_SELECTOR_FILTER = { - PROVIDER_ID: "provider_id__in", - PROVIDER_UID: "provider_uid__in", -} as const; - -type AccountSelectorFilter = - (typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER]; - /** Common props shared by both batch and instant modes. */ interface AccountsSelectorBaseProps { providers: ProviderProps[]; search?: MultiSelectSearchProp; - filterKey?: AccountSelectorFilter; + filterKey?: AccountFilterKey; id?: string; disabledValues?: string[]; closeOnSelect?: boolean; @@ -72,7 +65,7 @@ export function AccountsSelector({ providers, onBatchChange, selectedValues, - filterKey = ACCOUNT_SELECTOR_FILTER.PROVIDER_ID, + filterKey = FilterType.PROVIDER_ID, id = "accounts-selector", disabledValues = [], search = { @@ -92,7 +85,7 @@ export function AccountsSelector({ const visibleProviders = providers; const getProviderValue = (provider: ProviderProps) => - filterKey === ACCOUNT_SELECTOR_FILTER.PROVIDER_UID + filterKey === FilterType.PROVIDER_UID ? provider.attributes.uid : provider.id; const disabledValuesSet = new Set(disabledValues); 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 new file mode 100644 index 0000000000..20210a3126 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx @@ -0,0 +1,64 @@ +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", () => { + it("renders the no-scans message with a link to Scan Jobs", () => { + render( + , + ); + expect(screen.getByText(/no scans available/i)).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /go to scan jobs/i }), + ).toHaveAttribute("href", "/scans"); + }); + + it("renders the scan-pending message", () => { + render( + , + ); + expect(screen.getByText(/scan in progress/i)).toBeInTheDocument(); + }); + + it("renders the graph-building message with progress", () => { + render( + , + ); + expect( + screen.getByText(/preparing attack paths data/i), + ).toBeInTheDocument(); + expect(screen.getByText(/45%/)).toBeInTheDocument(); + }); + + it("renders the no-graph-data message", () => { + render( + , + ); + expect(screen.getByText(/no attack paths data/i)).toBeInTheDocument(); + }); + + it("renders the error message and calls onRetry when Retry is clicked", () => { + const onRetry = vi.fn(); + render( + , + ); + expect(screen.getByText(/couldn.t load scans/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it("renders nothing for the ready state", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx new file mode 100644 index 0000000000..89db0dbfd2 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx @@ -0,0 +1,87 @@ +import Link from "next/link"; + +import { Button } from "@/components/shadcn"; +import { StatusAlert } from "@/components/shared/status-alert"; + +import { + ATTACK_PATHS_VIEW_STATES, + type AttackPathsViewState, +} from "../_lib/get-attack-paths-view-state"; + +interface AttackPathsStatusPanelProps { + state: AttackPathsViewState; + progress?: number; + onRetry?: () => void; +} + +/** + * Full-page status message shown whenever the Attack Paths graph is not yet + * queryable. The page renders the normal workflow instead once `state` is + * `READY` (this component renders nothing for `READY`/`LOADING`). + */ +export const AttackPathsStatusPanel = ({ + state, + progress = 0, + onRetry, +}: AttackPathsStatusPanelProps) => { + if (state === ATTACK_PATHS_VIEW_STATES.ERROR) { + return ( + + Something went wrong loading your scans. + {onRetry ? ( + + ) : null} + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.NO_SCANS) { + return ( + + + You need to run a scan before you can analyze attack paths.{" "} + + Go to Scan Jobs + + + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.SCAN_PENDING) { + return ( + + + Your scan is queued. Attack Paths will be available once it completes. + + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING) { + return ( + + + We're building the graph from your latest scan ({progress}%). + This will be ready shortly. + + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA) { + return ( + + This scan didn't produce Attack Paths data. + + ); + } + + return null; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx index 084bcf6e16..b451cf58f6 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx @@ -1,6 +1,4 @@ -import { CircleAlert } from "lucide-react"; - -import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn"; +import { StatusAlert } from "@/components/shared/status-alert"; interface QueryExecutionErrorProps { error: string; @@ -14,17 +12,17 @@ export const QueryExecutionError = ({ description, }: QueryExecutionErrorProps) => { return ( - - - {title} - - {description ?

    {description}

    : null} -
    -
    -            {error}
    -          
    -
    -
    -
    + + {description ?

    {description}

    : null} +
    +
    +          {error}
    +        
    +
    +
    ); }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts index 39515698a1..5957e175d7 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { getAttackPathScans } from "@/actions/attack-paths"; import { useMountEffect } from "@/hooks/use-mount-effect"; @@ -19,7 +19,9 @@ export interface UseAttackPathScansOptions { export interface UseAttackPathScansResult { scans: AttackPathScan[]; scansLoading: boolean; + loadError: boolean; refreshScans: () => Promise; + retryLoadScans: () => Promise; } /** @@ -35,7 +37,11 @@ export function useAttackPathScans( const [scans, setScans] = useState([]); const [scansLoading, setScansLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const mountedRef = useRef(true); + // Silent background refresh for auto-refresh: never flips loading/error, so it + // can't disrupt the visible view if it fails. const refreshScans = async () => { try { const scansData = await getAttackPathScans(); @@ -47,35 +53,50 @@ export function useAttackPathScans( } }; - useMountEffect(() => { - let active = true; - - const loadScans = async () => { - setScansLoading(true); - try { - const scansData = await getAttackPathScans(); - const nextScans = scansData?.data ?? []; - if (!active) return; - setScans(nextScans); - if (!nextScans.some((scan) => scan.attributes.graph_data_ready)) { + // Full (re)load: drives loading + error state. Runs on mount and is reused by + // the error view's Retry action. A successful empty result (`{ data: [] }`) is + // not an error; only a missing payload or a thrown request is. + const loadScans = async () => { + setScansLoading(true); + setLoadError(false); + try { + const scansData = await getAttackPathScans(); + if (!mountedRef.current) return; + if (scansData?.data) { + setScans(scansData.data); + if (!scansData.data.some((scan) => scan.attributes.graph_data_ready)) { onNoReadyScan?.(); } - } catch (error) { - if (!active) return; - console.error("Failed to load scans:", error); + } else { setScans([]); + setLoadError(true); onNoReadyScan?.(); - } finally { - if (active) setScansLoading(false); } - }; + } catch (error) { + if (!mountedRef.current) return; + console.error("Failed to load scans:", error); + setScans([]); + setLoadError(true); + onNoReadyScan?.(); + } finally { + if (mountedRef.current) setScansLoading(false); + } + }; + useMountEffect(() => { + mountedRef.current = true; void loadScans(); return () => { - active = false; + mountedRef.current = false; }; }); - return { scans, scansLoading, refreshScans }; + return { + scans, + scansLoading, + loadError, + refreshScans, + retryLoadScans: loadScans, + }; } diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts new file mode 100644 index 0000000000..c6722021ed --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; + +import type { AttackPathScan, ScanState } from "@/types/attack-paths"; + +import { + ATTACK_PATHS_VIEW_STATES, + getAttackPathsViewState, + getGraphBuildingProgress, + isScanInFlight, +} from "./get-attack-paths-view-state"; + +const scan = ( + state: ScanState, + graph_data_ready: boolean, + progress = 0, +): AttackPathScan => ({ + type: "attack-paths-scans", + id: `${state}-${String(graph_data_ready)}-${progress}`, + attributes: { + state, + progress, + graph_data_ready, + provider_alias: "Provider", + provider_type: "aws", + provider_uid: "123456789012", + inserted_at: "2026-04-21T10:00:00Z", + started_at: "2026-04-21T10:00:00Z", + completed_at: null, + duration: null, + }, + relationships: { + provider: { data: { type: "providers", id: "p" } }, + scan: { data: { type: "scans", id: "s" } }, + task: { data: { type: "tasks", id: "t" } }, + }, +}); + +describe("getAttackPathsViewState", () => { + it("returns loading while scans are loading, regardless of other inputs", () => { + expect( + getAttackPathsViewState({ + scansLoading: true, + loadError: true, + scans: [], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.LOADING); + }); + + it("returns error on load failure (error wins over empty scans)", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: true, + scans: [], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.ERROR); + }); + + it("returns no-scans for an empty list", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.NO_SCANS); + }); + + it("returns ready when any provider has a queryable graph", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("executing", false, 50), scan("completed", true, 100)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.READY); + }); + + it("returns graph-building when none ready and some scan is executing (wins over scheduled)", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("scheduled", false), scan("executing", false, 30)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING); + }); + + it("returns scan-pending when none ready and some scan is scheduled/available", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("scheduled", false)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.SCAN_PENDING); + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("available", false)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.SCAN_PENDING); + }); + + it("returns no-graph-data when none ready and all scans are terminal", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("completed", false), scan("failed", false)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA); + }); +}); + +describe("isScanInFlight", () => { + it("is true for an available scan (created, not yet scheduled)", () => { + expect(isScanInFlight([scan("available", false)])).toBe(true); + }); + + it("is true for scheduled and executing scans", () => { + expect(isScanInFlight([scan("scheduled", false)])).toBe(true); + expect(isScanInFlight([scan("executing", false, 40)])).toBe(true); + }); + + it("is false when every scan is in a terminal state", () => { + expect( + isScanInFlight([scan("completed", true), scan("failed", false)]), + ).toBe(false); + }); + + it("is false for an empty list", () => { + expect(isScanInFlight([])).toBe(false); + }); +}); + +describe("getGraphBuildingProgress", () => { + it("returns the max progress among executing scans", () => { + expect( + getGraphBuildingProgress([ + scan("executing", false, 30), + scan("executing", false, 70), + scan("scheduled", false, 99), + ]), + ).toBe(70); + }); + + it("returns 0 when no scan is executing", () => { + expect(getGraphBuildingProgress([scan("scheduled", false, 50)])).toBe(0); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts new file mode 100644 index 0000000000..0e734304f8 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts @@ -0,0 +1,65 @@ +import type { AttackPathScan, ScanState } from "@/types/attack-paths"; +import { SCAN_STATES } from "@/types/attack-paths"; + +// In-flight = scan still progressing toward a graph. AVAILABLE is the default +// state of a new scan. Shared by the deriver and polling so they can't diverge. +const IN_FLIGHT_SCAN_STATES: ScanState[] = [ + SCAN_STATES.AVAILABLE, + SCAN_STATES.SCHEDULED, + SCAN_STATES.EXECUTING, +]; + +export const isScanInFlight = (scans: AttackPathScan[]): boolean => + scans.some((s) => IN_FLIGHT_SCAN_STATES.includes(s.attributes.state)); + +export const ATTACK_PATHS_VIEW_STATES = { + LOADING: "loading", + ERROR: "error", + NO_SCANS: "no-scans", + SCAN_PENDING: "scan-pending", + GRAPH_BUILDING: "graph-building", + NO_GRAPH_DATA: "no-graph-data", + READY: "ready", +} as const; + +export type AttackPathsViewState = + (typeof ATTACK_PATHS_VIEW_STATES)[keyof typeof ATTACK_PATHS_VIEW_STATES]; + +interface GetAttackPathsViewStateInput { + scansLoading: boolean; + loadError: boolean; + scans: AttackPathScan[]; +} + +/** + * Single source of truth for what the Attack Paths page shows. The full-page + * message owns every "not queryable yet" state; the workflow renders only once + * at least one provider's graph is ready. + */ +export const getAttackPathsViewState = ({ + scansLoading, + loadError, + scans, +}: GetAttackPathsViewStateInput): AttackPathsViewState => { + if (scansLoading) return ATTACK_PATHS_VIEW_STATES.LOADING; + if (loadError) return ATTACK_PATHS_VIEW_STATES.ERROR; + if (scans.length === 0) return ATTACK_PATHS_VIEW_STATES.NO_SCANS; + + if (scans.some((s) => s.attributes.graph_data_ready)) { + return ATTACK_PATHS_VIEW_STATES.READY; + } + if (scans.some((s) => s.attributes.state === SCAN_STATES.EXECUTING)) { + return ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING; + } + // EXECUTING returned above; an in-flight scan here is AVAILABLE/SCHEDULED. + if (isScanInFlight(scans)) { + return ATTACK_PATHS_VIEW_STATES.SCAN_PENDING; + } + return ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA; +}; + +/** Highest progress among scans whose graph is actively building. */ +export const getGraphBuildingProgress = (scans: AttackPathScan[]): number => + scans + .filter((s) => s.attributes.state === SCAN_STATES.EXECUTING) + .reduce((max, s) => Math.max(max, s.attributes.progress), 0); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx index 9e455a4b6d..bc2cf36331 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx @@ -75,6 +75,31 @@ describe("loading the page", () => { }); }); +describe("waiting states", () => { + test("a pending scan shows the scan-in-progress message", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.scanPending()); + expect(await graph.emptyStateMessage()).toMatch(/scan in progress/i); + }); + + test("a building graph shows the preparing message with progress", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.graphBuilding()); + const message = await graph.emptyStateMessage(); + expect(message).toMatch(/preparing attack paths data/i); + expect(message).toMatch(/45%/); + }); + + test("a completed scan with no graph shows the no-data message", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.noGraphData()); + expect(await graph.emptyStateMessage()).toMatch(/no attack paths data/i); + }); +}); + describe("running a query", () => { test("the graph renders with a background, a minimap, and a viewport", async ({ mountWith, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts index 37e2170355..41089af527 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts @@ -143,6 +143,52 @@ export const emptyScans = (): PageFixture => ({ queryResult: null, }); +export const scanPending = (): PageFixture => ({ + scans: [ + buildScan(TYPICAL_SCAN_ID, { + state: "scheduled", + progress: 0, + graph_data_ready: false, + completed_at: null, + duration: null, + }), + ], + scanId: TYPICAL_SCAN_ID, + queries: [], + queryId: DEFAULT_QUERY_ID, + queryResult: null, +}); + +export const graphBuilding = (): PageFixture => ({ + scans: [ + buildScan(TYPICAL_SCAN_ID, { + state: "executing", + progress: 45, + graph_data_ready: false, + completed_at: null, + duration: null, + }), + ], + scanId: TYPICAL_SCAN_ID, + queries: [], + queryId: DEFAULT_QUERY_ID, + queryResult: null, +}); + +export const noGraphData = (): PageFixture => ({ + scans: [ + buildScan(TYPICAL_SCAN_ID, { + state: "completed", + progress: 100, + graph_data_ready: false, + }), + ], + scanId: TYPICAL_SCAN_ID, + queries: [], + queryId: DEFAULT_QUERY_ID, + queryResult: null, +}); + export const emptyGraph = (): PageFixture => ({ scans: [buildScan(TYPICAL_SCAN_ID)], scanId: TYPICAL_SCAN_ID, @@ -269,6 +315,9 @@ export const edgeCases = (): PageFixture => { export const fixtures = { typical, emptyScans, + scanPending, + graphBuilding, + noGraphData, emptyGraph, singleNode, findingsOnly, 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 08d61df235..d88a8fffb6 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 @@ -1,7 +1,6 @@ "use client"; -import { ArrowLeft, Info, Maximize2 } from "lucide-react"; -import Link from "next/link"; +import { ArrowLeft, Maximize2 } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef, useState } from "react"; import { FormProvider } from "react-hook-form"; @@ -17,13 +16,7 @@ import { FindingDetailDrawer } from "@/components/findings/table"; import { PageReady } from "@/components/onboarding"; import { useFindingDetails } from "@/components/resources/table/use-finding-details"; import { AutoRefresh } from "@/components/scans"; -import { - Alert, - AlertDescription, - AlertTitle, - Button, -} from "@/components/shadcn"; -import { useToast } from "@/components/shadcn"; +import { Button, useToast } from "@/components/shadcn"; import { Dialog, DialogContent, @@ -32,6 +25,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/shadcn/dialog"; +import { StatusAlert } from "@/components/shared/status-alert"; import { useMountEffect } from "@/hooks/use-mount-effect"; import { isCloud } from "@/lib/shared/env"; import { @@ -61,11 +55,21 @@ import { QuerySelector, ScanListTable, } from "./_components"; +import { AttackPathsStatusPanel } from "./_components/attack-paths-status-panel"; import type { GraphHandle } from "./_components/graph/attack-path-graph"; import { useAttackPathScans } from "./_hooks/use-attack-path-scans"; import { useGraphState } from "./_hooks/use-graph-state"; import { useQueryBuilder } from "./_hooks/use-query-builder"; import { exportGraphAsPNG } from "./_lib"; +import { + ATTACK_PATHS_VIEW_STATES, + getAttackPathsViewState, + getGraphBuildingProgress, + isScanInFlight, +} from "./_lib/get-attack-paths-view-state"; + +const SCROLL_CONTAINER_CLASS = + "minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4"; export default function AttackPathsPage() { const searchParams = useSearchParams(); @@ -80,11 +84,12 @@ export default function AttackPathsPage() { const finding = useFindingDetails(); const { toast } = useToast(); - const { scans, scansLoading, refreshScans } = useAttackPathScans({ - onNoReadyScan: isAttackPathsReplay - ? () => router.push("/scans?onboarding=view-first-scan") - : undefined, - }); + const { scans, scansLoading, loadError, refreshScans, retryLoadScans } = + useAttackPathScans({ + onNoReadyScan: isAttackPathsReplay + ? () => router.push("/scans?onboarding=view-first-scan") + : undefined, + }); const [queriesLoading, setQueriesLoading] = useState(true); const [queriesError, setQueriesError] = useState(null); @@ -103,7 +108,9 @@ export default function AttackPathsPage() { const hasNoScans = scans.length === 0; useDriverTour(attackPathsEmptyTour, { - enabled: onboardingEnabled && !scansLoading && hasNoScans, + // Gate on !loadError: the empty-scans CTA anchor only renders in the + // NO_SCANS view-state, not in the ERROR state (which also has scans === []). + enabled: onboardingEnabled && !scansLoading && !loadError && hasNoScans, }); const { start: startAttackPathsTour } = useDriverTour( @@ -164,12 +171,12 @@ export default function AttackPathsPage() { graphState.resetGraph(); }, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only - const hasExecutingScan = scans.some( - (scan) => - scan.attributes.state === SCAN_STATES.EXECUTING || - scan.attributes.state === SCAN_STATES.SCHEDULED, - ); + // Poll while a scan is in flight so the page auto-advances when the graph is ready. + const hasScanInFlight = isScanInFlight(scans); + const viewState = getAttackPathsViewState({ scansLoading, loadError, scans }); + + // Detect if the selected scan is showing data from a previous cycle const selectedScan = scans.find((scan) => scan.id === scanId); const isViewingPreviousCycleData = selectedScan && @@ -396,7 +403,7 @@ export default function AttackPathsPage() { return (
    @@ -418,25 +425,23 @@ export default function AttackPathsPage() {

    - {scansLoading ? ( -
    + {viewState === ATTACK_PATHS_VIEW_STATES.LOADING ? ( +

    Loading scans...

    - ) : hasNoScans ? ( + ) : viewState === ATTACK_PATHS_VIEW_STATES.NO_SCANS ? ( + // Keep the empty-scans tour anchor: attackPathsEmptyTour targets + // data-tour-id="attack-paths-empty-scans-cta". The panel's NO_SCANS + // render is the same "No scans available" + Go to Scan Jobs CTA.
    - - - No scans available - - - You need to run a scan before you can analyze attack paths.{" "} - - Go to Scan Jobs - - - - +
    + ) : viewState !== ATTACK_PATHS_VIEW_STATES.READY ? ( + ) : ( <> Loading scans...
    }> @@ -444,21 +449,20 @@ export default function AttackPathsPage() { {isViewingPreviousCycleData && ( - - - Viewing data from a previous scan - - This scan is currently{" "} - {selectedScan.attributes.state === SCAN_STATES.EXECUTING - ? `running (${selectedScan.attributes.progress}%)` - : selectedScan.attributes.state} - . The graph data shown is from the last completed cycle. - - + + This scan is currently{" "} + {selectedScan.attributes.state === SCAN_STATES.EXECUTING + ? `running (${selectedScan.attributes.progress}%)` + : selectedScan.attributes.state} + . The graph data shown is from the last completed cycle. + )} {scanId && ( -
    +
    {queriesLoading ? (

    Loading queries...

    ) : queriesError ? ( @@ -516,7 +520,7 @@ export default function AttackPathsPage() { (graphState.data && graphState.data.nodes && graphState.data.nodes.length > 0)) && ( -
    +
    {graphState.loading ? ( ) : graphState.data && diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx index b789b10a31..ed63f95026 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -4,14 +4,15 @@ import * as Sentry from "@sentry/nextjs"; import { Metadata, Viewport } from "next"; import { ReactNode, Suspense } from "react"; -import { getScansByState } from "@/actions/scans/scans"; import { getProviders } from "@/actions/providers"; +import { getScansByState } from "@/actions/scans/scans"; import MainLayout from "@/components/layout/main-layout/main-layout"; import { OnboardingCheckpointWatcher, OnboardingGate, OnboardingSequenceBanner, } from "@/components/onboarding"; +import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config"; import { NavigationProgress } from "@/components/shadcn/navigation-progress"; import { Toaster } from "@/components/shadcn/toast"; import { fontMono, fontSans } from "@/config/fonts"; @@ -76,7 +77,9 @@ export default async function RootLayout({ return ( - + + + ({ getScans: vi.fn(), })); +const schedulesActionsMock = vi.hoisted(() => ({ + getSchedules: vi.fn(), +})); + vi.mock("@/actions/providers", () => providersActionsMock); vi.mock( "@/actions/organizations/organizations", () => organizationsActionsMock, ); vi.mock("@/actions/scans", () => scansActionsMock); +vi.mock("@/actions/schedules", () => schedulesActionsMock); import { SearchParamsProps } from "@/types"; import { ProvidersApiResponse } from "@/types/providers"; -import { ProvidersProviderRow } from "@/types/providers-table"; +import { + isProvidersOrganizationRow, + ProvidersProviderRow, +} from "@/types/providers-table"; +import { + SCHEDULE_FREQUENCY, + type ScheduleAttributes, + type ScheduleProps, +} from "@/types/schedules"; import { buildProvidersTableRows, @@ -156,6 +169,33 @@ const toProviderRow = ( }, }); +const buildSchedule = ( + providerId: string, + overrides: Partial = {}, +): ScheduleProps => ({ + type: "schedules", + id: providerId, + attributes: { + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: 9, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, + ...overrides, + }, + relationships: { + provider: { data: { type: "providers", id: providerId } }, + }, +}); + +const findProviderRow = ( + rows: { id: string }[], + providerId: string, +): ProvidersProviderRow | undefined => + rows.find((row) => row.id === providerId) as ProvidersProviderRow | undefined; + describe("buildProvidersTableRows", () => { it("returns a flat providers table for OSS", () => { // Given @@ -606,13 +646,73 @@ describe("buildProvidersTableRows", () => { // Then expect(rows).toHaveLength(1); - expect(rows[0].rowType).toBe(PROVIDERS_ROW_TYPE.ORGANIZATION); - expect(rows[0].subRows).toHaveLength(2); + const orgRow = rows[0]; + expect(isProvidersOrganizationRow(orgRow)).toBe(true); + if (!isProvidersOrganizationRow(orgRow)) { + throw new Error("Expected organization row"); + } + expect(orgRow.subRows).toHaveLength(2); expect( - rows[0].subRows?.every( + orgRow.subRows?.every( (row) => row.rowType === PROVIDERS_ROW_TYPE.PROVIDER, ), ).toBe(true); + expect(orgRow.providerIds).toEqual(["provider-1", "provider-2"]); + }); + + it("keeps organization relationship provider ids even when providers are not in the visible page", () => { + // Given + const providers = [ + toProviderRow(providersResponse.data[0], { + relationships: { + ...providersResponse.data[0].relationships, + organization: { + data: null, + }, + }, + }), + ]; + + // When + const rows = buildProvidersTableRows({ + providers, + organizations: [ + { + id: "org-1", + type: "organizations", + attributes: { + name: "Large Organization", + org_type: "aws", + external_id: "o-large", + metadata: {}, + root_external_id: "r-large", + }, + relationships: { + providers: { + data: [ + { type: "providers", id: "provider-1" }, + { type: "providers", id: "provider-not-in-page" }, + ], + }, + organizational_units: { + data: [], + }, + }, + }, + ], + organizationUnits: [], + isCloud: true, + }); + + // Then + expect(rows).toHaveLength(1); + const orgRow = rows[0]; + expect(isProvidersOrganizationRow(orgRow)).toBe(true); + if (!isProvidersOrganizationRow(orgRow)) { + throw new Error("Expected organization row"); + } + expect(orgRow.subRows).toHaveLength(1); + expect(orgRow.providerIds).toEqual(["provider-1", "provider-not-in-page"]); }); }); @@ -751,4 +851,98 @@ describe("loadProvidersAccountsViewData", () => { viewData.rows.every((row) => row.rowType === PROVIDERS_ROW_TYPE.PROVIDER), ).toBe(true); }); + + it("surfaces the real cadence (not a hardcoded label) from a configured schedule with no materialized scan yet", async () => { + // Given — provider-1 has a WEEKLY schedule but the backend has not yet + // created a Scan row (the gap between configuring and the first fire). + providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + scansActionsMock.getScans.mockResolvedValue({ data: [] }); + schedulesActionsMock.getSchedules.mockResolvedValue({ + data: [ + buildSchedule("provider-1", { + scan_frequency: SCHEDULE_FREQUENCY.WEEKLY, + scan_hour: 9, + scan_day_of_week: 1, + }), + ], + }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: false, + }); + + // Then — the row carries the Weekly cadence, not "Daily". + const providerRow = findProviderRow(viewData.rows, "provider-1"); + expect(providerRow?.hasSchedule).toBe(true); + expect(providerRow?.scheduleSummary?.cadence).toBe("Weekly on Monday"); + expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe( + false, + ); + expect( + findProviderRow(viewData.rows, "provider-2")?.scheduleSummary, + ).toBeUndefined(); + }); + + it("ignores paused or unconfigured schedules", async () => { + // Given — provider-1 paused (disabled), provider-2 never configured. + providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + scansActionsMock.getScans.mockResolvedValue({ data: [] }); + schedulesActionsMock.getSchedules.mockResolvedValue({ + data: [ + buildSchedule("provider-1", { scan_enabled: false, scan_hour: 9 }), + buildSchedule("provider-2", { scan_enabled: true, scan_hour: null }), + ], + }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: false, + }); + + // Then + expect(findProviderRow(viewData.rows, "provider-1")?.hasSchedule).toBe( + false, + ); + expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe( + false, + ); + }); + + it("falls back to scan-based detection when /schedules is unavailable (OSS)", async () => { + // Given — /schedules errors, but provider-1 has a materialized scheduled scan. + providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + scansActionsMock.getScans.mockResolvedValue({ + data: [ + { + type: "scans", + id: "scan-1", + attributes: { trigger: "scheduled", state: "scheduled" }, + relationships: { + provider: { data: { type: "providers", id: "provider-1" } }, + }, + }, + ], + }); + schedulesActionsMock.getSchedules.mockResolvedValue({ error: "Not found" }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: false, + }); + + // Then — scan-based path still flags the provider; no throw from the error. + expect(findProviderRow(viewData.rows, "provider-1")?.hasSchedule).toBe( + true, + ); + expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe( + false, + ); + }); }); diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts index db79452709..32ce2a5321 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.ts @@ -4,10 +4,16 @@ import { } from "@/actions/organizations/organizations"; import { getAllProviders, getProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; +import { getSchedules } from "@/actions/schedules"; import { extractFiltersAndQuery, extractSortAndKey, } from "@/lib/helper-filters"; +import { + buildProviderScheduleSummary, + buildSchedulesByProviderId, + isScheduleConfigured, +} from "@/lib/schedules"; import { FilterEntity, FilterOption, @@ -27,7 +33,8 @@ import { ProvidersTableRow, ProvidersTableRowsInput, } from "@/types/providers-table"; -import { SCAN_TRIGGER, ScanProps } from "@/types/scans"; +import { SCAN_TRIGGER, ScanProps, ScanScheduleSummary } from "@/types/scans"; +import { ScheduleAttributes } from "@/types/schedules"; const PROVIDERS_STATUS_MAPPING = [ { @@ -127,22 +134,46 @@ const buildScheduledProviderIds = (scans: ScanProps[]): Set => { return scheduled; }; +// A schedule is backed by the Provider row itself, so its `/schedules` entry +// exists before the first scheduled Scan is materialized — only enabled, +// configured ones carry a displayable cadence summary. +const buildProviderScheduleSummaryFor = ( + attributes: ScheduleAttributes | undefined, + now: Date, +): ScanScheduleSummary | undefined => + attributes && attributes.scan_enabled && isScheduleConfigured(attributes) + ? buildProviderScheduleSummary(attributes, now) + : undefined; + const enrichProviders = ( - providersResponse?: ProvidersApiResponse, - scheduledProviderIds?: Set, + providersResponse: ProvidersApiResponse | undefined, + scanScheduledProviderIds: Set, + schedulesByProviderId: Record, ): ProvidersProviderRow[] => { const providerGroupLookup = createProviderGroupLookup(providersResponse); + const now = new Date(); - return (providersResponse?.data ?? []).map((provider) => ({ - ...provider, - rowType: PROVIDERS_ROW_TYPE.PROVIDER, - groupNames: - provider.relationships.provider_groups.data.map( - (providerGroup: { id: string }) => - providerGroupLookup.get(providerGroup.id) ?? "Unknown Group", - ) ?? [], - hasSchedule: scheduledProviderIds?.has(provider.id) ?? false, - })); + return (providersResponse?.data ?? []).map((provider) => { + const scheduleSummary = buildProviderScheduleSummaryFor( + schedulesByProviderId[provider.id], + now, + ); + + return { + ...provider, + rowType: PROVIDERS_ROW_TYPE.PROVIDER, + groupNames: + provider.relationships.provider_groups.data.map( + (providerGroup: { id: string }) => + providerGroupLookup.get(providerGroup.id) ?? "Unknown Group", + ) ?? [], + // A fired scheduled scan OR a configured schedule that hasn't fired yet. + hasSchedule: + scanScheduledProviderIds.has(provider.id) || + scheduleSummary !== undefined, + scheduleSummary, + }; + }); }; const createOrganizationRow = ({ @@ -152,6 +183,7 @@ const createOrganizationRow = ({ externalId, organizationId, parentExternalId, + providerIds, subRows, }: { externalId: string | null; @@ -160,6 +192,7 @@ const createOrganizationRow = ({ name: string; organizationId: string | null; parentExternalId: string | null; + providerIds: string[]; subRows: ProvidersTableRow[]; }): ProvidersOrganizationRow => ({ id, @@ -169,7 +202,8 @@ const createOrganizationRow = ({ externalId, organizationId, parentExternalId, - providerCount: countProviderRows(subRows), + providerCount: providerIds.length, + providerIds, subRows, }); @@ -203,14 +237,14 @@ function getProviderRowsByIds({ .filter((provider): provider is ProvidersProviderRow => Boolean(provider)); } -function countProviderRows(rows: ProvidersTableRow[]): number { - return rows.reduce((total, row) => { - if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) { - return total + 1; - } +function dedupeIds(ids: string[]): string[] { + return Array.from(new Set(ids)); +} - return total + countProviderRows(row.subRows); - }, 0); +function collectOrganizationRowProviderIds( + rows: ProvidersOrganizationRow[], +): string[] { + return dedupeIds(rows.flatMap((row) => row.providerIds)); } function getOrganizationUnitRelationshipId( @@ -277,6 +311,13 @@ function buildOrganizationUnitRows({ ? providerRowsFromRelationships : (providersByOrganizationUnitId.get(organizationUnit.id) ?? []); const subRows = [...childOrganizationUnitRows, ...providerRows]; + const directProviderIds = + providerRowsFromRelationships.length > 0 + ? getRelationshipProviderIds(organizationUnit.relationships) + : providerRows.map((provider) => provider.id); + const childProviderIds = collectOrganizationRowProviderIds( + childOrganizationUnitRows, + ); return createOrganizationRow({ groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT, @@ -285,10 +326,13 @@ function buildOrganizationUnitRows({ externalId: organizationUnit.attributes.external_id, organizationId, parentExternalId: organizationUnit.attributes.parent_external_id, + providerIds: dedupeIds([...childProviderIds, ...directProviderIds]), subRows, }); }) - .filter((organizationUnitRow) => organizationUnitRow.subRows.length > 0); + .filter( + (organizationUnitRow) => organizationUnitRow.providerIds.length > 0, + ); } export function buildProvidersTableRows({ @@ -387,6 +431,12 @@ export function buildProvidersTableRows({ (provider) => !providersInOus.has(provider.id), ); const subRows = [...organizationProviders, ...organizationUnitRows]; + const directProviderIds = + organizationProvidersFromRelationships.length > 0 + ? getRelationshipProviderIds(organization.relationships) + : organizationProviders.map((provider) => provider.id); + const organizationUnitProviderIds = + collectOrganizationRowProviderIds(organizationUnitRows); return createOrganizationRow({ groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION, @@ -395,10 +445,14 @@ export function buildProvidersTableRows({ externalId: organization.attributes.external_id, organizationId: organization.id, parentExternalId: organization.attributes.root_external_id, + providerIds: dedupeIds([ + ...directProviderIds, + ...organizationUnitProviderIds, + ]), subRows, }); }) - .filter((organizationRow) => organizationRow.subRows.length > 0); + .filter((organizationRow) => organizationRow.providerIds.length > 0); const assignedProviderIds = new Set(); @@ -453,6 +507,7 @@ export async function loadProvidersAccountsViewData({ providersResponse, allProvidersResponse, scansResponse, + schedulesResponse, organizationsResponse, organizationUnitsResponse, ] = await Promise.all([ @@ -468,7 +523,7 @@ export async function loadProvidersAccountsViewData({ // Unfiltered fetch for ProviderTypeSelector — only needs distinct types; // TODO: Replace with a dedicated lightweight endpoint when available. resolveActionResult(getAllProviders()), - // Fetch active scheduled scans to determine daily schedule per provider + // Fetch active scheduled scans to flag providers whose schedule has fired. resolveActionResult( getScans({ pageSize: 500, @@ -478,6 +533,9 @@ export async function loadProvidersAccountsViewData({ }, }), ), + // Fetch configured schedules to also flag providers whose schedule has not + // fired yet (best-effort: absent in OSS, where the helper yields no ids). + resolveActionResult(getSchedules()), isCloud ? listOrganizationsSafe() : Promise.resolve(emptyOrganizationsResponse), @@ -486,13 +544,18 @@ export async function loadProvidersAccountsViewData({ : Promise.resolve(emptyOrganizationUnitsResponse), ]); - const scheduledProviderIds = buildScheduledProviderIds( + const scanScheduledProviderIds = buildScheduledProviderIds( scansResponse?.data ?? [], ); + const schedulesByProviderId = buildSchedulesByProviderId(schedulesResponse); const orgs = organizationsResponse?.data ?? []; const ous = organizationUnitsResponse?.data ?? []; - const providers = enrichProviders(providersResponse, scheduledProviderIds); + const providers = enrichProviders( + providersResponse, + scanScheduledProviderIds, + schedulesByProviderId, + ); const rows = buildProvidersTableRows({ isCloud, diff --git a/ui/app/(prowler)/scans/page.test.ts b/ui/app/(prowler)/scans/page.test.ts index 3ea7bcfa7c..08c36aaf46 100644 --- a/ui/app/(prowler)/scans/page.test.ts +++ b/ui/app/(prowler)/scans/page.test.ts @@ -21,3 +21,20 @@ describe("scans page onboarding", () => { expect(source).toContain("onboardingAction={onboardingAction}"); }); }); + +describe("scans page scheduled tab source", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const pagePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(pagePath, "utf8"); + + it("sources the Scheduled tab from /schedules only for the advanced capability", () => { + expect(source).toContain("getSchedulesPage"); + expect(source).toContain("SCAN_SCHEDULE_CAPABILITY.ADVANCED"); + expect(source).toContain("tab === SCAN_JOBS_TAB.SCHEDULED"); + }); + + it("maps schedule resources to rows and delegates pagination to the endpoint", () => { + expect(source).toContain("buildScheduledTabRows"); + expect(source).toContain("pickScheduleProviderFilters"); + }); +}); diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index e08faa2951..1ce1b9ed81 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -3,12 +3,17 @@ import { Suspense } from "react"; import { getAllProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; +import { getSchedules, getSchedulesPage } from "@/actions/schedules"; import { auth } from "@/auth.config"; import { PageReady } from "@/components/onboarding"; import { + appendPendingScheduleRowsToPage, + buildScheduledTabRows, + getProviderIdsFromScans, getScanJobsTab, getScanJobsTabFilters, getScanJobsUserFilters, + pickScheduleProviderFilters, } from "@/components/scans/scans.utils"; import { ScansPageShell } from "@/components/scans/scans-page-shell"; import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state"; @@ -16,13 +21,49 @@ import { SkeletonTableScans } from "@/components/scans/table"; import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table"; import { ContentLayout } from "@/components/shadcn/content-layout"; import { + buildProviderScheduleSummary, + buildSchedulesByProviderId, + getScanScheduleCapability, + isScheduleConfigured, +} from "@/lib/schedules"; +import { isCloud } from "@/lib/shared/env"; +import { + FilterType, ProviderProps, SCAN_JOBS_TAB, + SCAN_TRIGGER, ScanProps, SearchParamsProps, } from "@/types"; +import { + SCAN_SCHEDULE_CAPABILITY, + type ScanScheduleCapability, +} from "@/types/schedules"; const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1; +// Pending schedule rows must honor the same provider filters as real scan rows. +// The `__in` keys reuse the shared FilterType; the singular variants have no +// FilterType equivalent, so they stay as literals. +const PENDING_ROW_PROVIDER_FILTER = { + PROVIDER_IN: FilterType.PROVIDER, + PROVIDER: "provider", + PROVIDER_TYPE_IN: FilterType.PROVIDER_TYPE, + PROVIDER_TYPE: "provider_type", +} as const; + +type PendingRowProviderFilter = + (typeof PENDING_ROW_PROVIDER_FILTER)[keyof typeof PENDING_ROW_PROVIDER_FILTER]; +type PendingRowProviderFilterParam = `filter[${PendingRowProviderFilter}]`; + +const PROVIDER_ID_FILTER_KEYS = [ + `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_IN}]`, + `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER}]`, +] as const satisfies ReadonlyArray; + +const PROVIDER_TYPE_FILTER_KEYS = [ + `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE_IN}]`, + `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE}]`, +] as const satisfies ReadonlyArray; const getFilterSearchQuery = ( filters: Record, @@ -33,6 +74,47 @@ const getFilterSearchQuery = ( return value ?? ""; }; +const parseCsvParam = (value?: string | string[]): string[] => { + const rawValue = Array.isArray(value) ? value.join(",") : value; + if (!rawValue) return []; + + return rawValue + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +}; + +const getFirstSearchParam = ( + searchParams: SearchParamsProps, + keys: ReadonlyArray, +): string | string[] | undefined => { + for (const key of keys) { + const value = searchParams[key]; + if (value !== undefined) return value; + } + + return undefined; +}; + +/** Applies the table's provider filters to synthetic pending-schedule rows. */ +const filterProvidersForPendingRows = ( + providers: ProviderProps[], + searchParams: SearchParamsProps, +): ProviderProps[] => { + const ids = parseCsvParam( + getFirstSearchParam(searchParams, PROVIDER_ID_FILTER_KEYS), + ); + const types = parseCsvParam( + getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS), + ); + + return providers.filter( + (provider) => + (ids.length === 0 || ids.includes(provider.id)) && + (types.length === 0 || types.includes(provider.attributes.provider)), + ); +}; + const getActiveScanCount = async ( searchParams: SearchParamsProps, ): Promise => { @@ -53,6 +135,40 @@ const getActiveScanCount = async ( return scansData && "meta" in scansData ? scansData.meta.pagination.count : 0; }; +/** + * A provider can already have a real scheduled scan on a different page. + * Current-page rows are not enough to decide whether a schedule needs a + * synthetic Pending row, so fetch all scheduled scan provider ids when the + * backend paginated result is larger than the current slice. + */ +const getCoveredScheduledProviderIds = async ({ + currentScans, + realScanCount, + query, + filters, +}: { + currentScans: ScanProps[]; + realScanCount: number; + query: string; + filters: Record; +}): Promise> => { + if (realScanCount === 0 || currentScans.length === realScanCount) { + return getProviderIdsFromScans(currentScans); + } + + const allScheduledScansData = await getScans({ + query, + page: 1, + pageSize: realScanCount, + filters, + include: "provider", + }); + + return getProviderIdsFromScans( + (allScheduledScansData?.data ?? []) as ScanProps[], + ); +}; + export default async function Scans({ searchParams, }: { @@ -123,7 +239,10 @@ export default async function Scans({ /> } > - + )} @@ -133,8 +252,12 @@ export default async function Scans({ const SSRDataTableScans = async ({ searchParams, + providers, + scanScheduleCapability, }: { searchParams: SearchParamsProps; + providers: ProviderProps[]; + scanScheduleCapability?: ScanScheduleCapability; }) => { const tab = getScanJobsTab(searchParams.tab); @@ -157,6 +280,33 @@ const SSRDataTableScans = async ({ const query = (filters["filter[search]"] as string) || ""; + // Advanced (Cloud) sources the Scheduled tab from /schedules; other envs keep the legacy /scans path. + const capability = + scanScheduleCapability ?? getScanScheduleCapability(isCloud()); + + if ( + tab === SCAN_JOBS_TAB.SCHEDULED && + capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED + ) { + const schedulesPage = await getSchedulesPage({ + page, + pageSize, + sort, + filters: pickScheduleProviderFilters(searchParams), + }); + const { data, meta } = buildScheduledTabRows(schedulesPage, new Date()); + + return ( + + ); + } + const scansData = await getScans({ query, page, @@ -170,7 +320,7 @@ const SSRDataTableScans = async ({ const included = scansData?.included; const meta = scansData && "meta" in scansData ? scansData.meta : undefined; - const expandedScansData = + const expandedScansData: ScanProps[] = scans?.map((scan: ScanProps) => { const providerId = scan.relationships?.provider?.data?.id; @@ -191,12 +341,63 @@ const SSRDataTableScans = async ({ }; }) || []; + const needsSchedules = + tab === SCAN_JOBS_TAB.SCHEDULED || + expandedScansData.some( + (scan) => scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED, + ); + const schedulesResult = needsSchedules ? await getSchedules() : null; + + // Schedules are keyed by provider id so real scheduled scan rows can display + // cadence/next-run info, and schedule-only providers can become Pending rows. + const schedulesByProviderId = buildSchedulesByProviderId(schedulesResult); + + const scansWithSchedule = expandedScansData.map((scan) => { + if (scan.attributes.trigger !== SCAN_TRIGGER.SCHEDULED) return scan; + + const providerId = scan.relationships?.provider?.data?.id; + const schedule = providerId ? schedulesByProviderId[providerId] : undefined; + if (!schedule || !isScheduleConfigured(schedule)) return scan; + + return { + ...scan, + providerSchedule: buildProviderScheduleSummary(schedule, new Date()), + }; + }); + + let tableData = scansWithSchedule; + let tableMeta = meta; + if (tab === SCAN_JOBS_TAB.SCHEDULED) { + // The backend paginates real scans only. Pending schedule rows are generated + // client-side, so reconcile both sources before passing data/meta to the table. + const coveredProviderIds = await getCoveredScheduledProviderIds({ + currentScans: scansWithSchedule, + realScanCount: meta?.pagination?.count ?? scansWithSchedule.length, + query, + filters, + }); + const scheduledTable = appendPendingScheduleRowsToPage({ + scans: scansWithSchedule, + meta, + page, + pageSize, + providers: filterProvidersForPendingRows(providers, searchParams), + schedulesByProviderId, + coveredProviderIds, + now: new Date(), + }); + + tableData = scheduledTable.data; + tableMeta = scheduledTable.meta; + } + return ( ); }; diff --git a/ui/app/instrumentation.client.ts b/ui/app/instrumentation.client.ts deleted file mode 100644 index a9abf3f345..0000000000 --- a/ui/app/instrumentation.client.ts +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; - -/** - * Client-side Sentry instrumentation - * - * This file is automatically loaded by Next.js in the browser via the instrumentation hook. - * It configures Sentry for client-side error tracking and performance monitoring. - * - * For server-side configuration, see: instrumentation.ts - * For runtime-specific configs, see: sentry/sentry.server.config.ts and sentry/sentry.edge.config.ts - */ - -import * as Sentry from "@sentry/nextjs"; - -const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; - -// Only initialize Sentry in the browser (not during SSR) -if (typeof window !== "undefined" && SENTRY_DSN) { - const isDevelopment = process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT === "local"; - - /** - * Initialize Sentry error tracking and performance monitoring - * - * This setup includes: - * - Performance monitoring with Web Vitals tracking (LCP, FID, CLS, INP) - * - Long task detection for UI-blocking operations - * - beforeSend hook to filter noise - */ - Sentry.init({ - // 📍 DSN - Data Source Name (identifies your Sentry project) - dsn: SENTRY_DSN, - - // 🌍 Environment - Separate dev errors from production - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "local", - - // 📦 Release - Track which version has the error - release: process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION, - - // 🐛 Debug - Detailed logs in development console - debug: isDevelopment, - - // 📊 Sample Rates - Performance monitoring - // 100% in dev (test everything), 50% in production (balance visibility with costs) - tracesSampleRate: isDevelopment ? 1.0 : 0.5, - profilesSampleRate: isDevelopment ? 1.0 : 0.5, - - // 🔌 Integrations - browserTracingIntegration is client-only - integrations: [ - // 📊 Performance Monitoring: Core Web Vitals + RUM - // Tracks LCP, FID, CLS, INP - // Real User Monitoring captures actual user experience, not synthetic tests - Sentry.browserTracingIntegration({ - enableLongTask: true, // Detect tasks that block UI (>50ms) - enableInp: true, // Interaction to Next Paint (Core Web Vital) - }), - ], - - // 🎣 beforeSend Hook - Filter or modify events before sending to Sentry - ignoreErrors: [ - // Browser extensions - "top.GLOBALS", - // Random network errors - "Network request failed", - "NetworkError", - "Failed to fetch", - // User canceled actions - "AbortError", - "Non-Error promise rejection captured", - // NextAuth expected errors - "NEXT_REDIRECT", - // ResizeObserver errors (common browser quirk, not real bugs) - "ResizeObserver", - ], - - beforeSend(event, hint) { - // Filter out noise: ResizeObserver errors (common browser quirk, not real bugs) - if (event.message?.includes("ResizeObserver")) { - return null; // Don't send to Sentry - } - - // Filter out non-actionable errors - if (event.exception) { - const error = hint.originalException; - - // Don't send cancelled requests - if ( - error && - typeof error === "object" && - "name" in error && - error.name === "AbortError" - ) { - return null; - } - - // Add additional context for API errors - if ( - error && - typeof error === "object" && - "message" in error && - typeof error.message === "string" && - error.message.includes("Request failed") - ) { - event.tags = { - ...event.tags, - error_type: "api_error", - }; - } - } - - return event; // Send to Sentry - }, - }); - - // 👤 Set user context (identifies who experienced the error) - // In production, this will be updated after authentication - if (isDevelopment) { - Sentry.setUser({ - id: "dev-user", - }); - } -} diff --git a/ui/app/providers.tsx b/ui/app/providers.tsx index 7a40f17739..6dc47cf42d 100644 --- a/ui/app/providers.tsx +++ b/ui/app/providers.tsx @@ -1,8 +1,5 @@ "use client"; -// Import Sentry client-side initialization -import "@/app/instrumentation.client"; - import { SessionProvider } from "next-auth/react"; import { ThemeProvider as NextThemesProvider } from "next-themes"; import { ThemeProviderProps } from "next-themes/dist/types"; diff --git a/ui/components/compliance/compliance-download-container.test.tsx b/ui/components/compliance/compliance-download-container.test.tsx index 4d7290535d..34f877a789 100644 --- a/ui/components/compliance/compliance-download-container.test.tsx +++ b/ui/components/compliance/compliance-download-container.test.tsx @@ -163,7 +163,7 @@ describe("ComplianceDownloadContainer", () => { compact presentation="dropdown" scanId="scan-1" - complianceId="dora" + complianceId="dora_2022_2554" />, ); @@ -178,7 +178,7 @@ describe("ComplianceDownloadContainer", () => { expect(downloadComplianceOcsfMock).toHaveBeenCalledWith( "scan-1", - "dora", + "dora_2022_2554", {}, ); }); diff --git a/ui/components/filters/provider-account-selectors.test.tsx b/ui/components/filters/provider-account-selectors.test.tsx index 7d576251ac..a8b8bd1689 100644 --- a/ui/components/filters/provider-account-selectors.test.tsx +++ b/ui/components/filters/provider-account-selectors.test.tsx @@ -1,6 +1,7 @@ import { render } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FilterType } from "@/types/filters"; import type { ProviderProps } from "@/types/providers"; import { ProviderAccountSelectors } from "./provider-account-selectors"; @@ -170,7 +171,7 @@ describe("ProviderAccountSelectors", () => { render( , @@ -229,7 +230,7 @@ describe("ProviderAccountSelectors", () => { { (state) => state.openLaunchScanModal, ); const isScansPage = pathname.startsWith("/scans"); + const { apiDocsUrl } = useRuntimeConfig(); const menuList = getMenuList({ pathname, + apiDocsUrl, }); const labelsToHide = MENU_HIDE_RULES.filter((rule) => diff --git a/ui/components/providers/link-to-scans.tsx b/ui/components/providers/link-to-scans.tsx index 09f06a7fd8..fee1ec407d 100644 --- a/ui/components/providers/link-to-scans.tsx +++ b/ui/components/providers/link-to-scans.tsx @@ -1,25 +1,29 @@ "use client"; -import Link from "next/link"; - -import { Button } from "@/components/shadcn"; +import { StackedCell } from "@/components/shadcn"; +import { formatLocalTimeWithZone } from "@/lib/date-utils"; +import type { ScanScheduleSummary } from "@/types/scans"; interface LinkToScansProps { hasSchedule: boolean; - providerUid?: string; + schedule?: ScanScheduleSummary; } -export const LinkToScans = ({ hasSchedule, providerUid }: LinkToScansProps) => { +// Matches the scans table Schedule column: cadence on top, next-run local time +// underneath. Falls back to a plain label when the cadence is unknown. +export const LinkToScans = ({ hasSchedule, schedule }: LinkToScansProps) => { + if (schedule) { + return ( + + ); + } + return ( -
    - - {hasSchedule ? "Daily" : "None"} - - -
    + + {hasSchedule ? "Daily" : "None"} + ); }; diff --git a/ui/components/providers/organizations/org-launch-scan.test.tsx b/ui/components/providers/organizations/org-launch-scan.test.tsx index 409ccfd6fe..ca22d17eb1 100644 --- a/ui/components/providers/organizations/org-launch-scan.test.tsx +++ b/ui/components/providers/organizations/org-launch-scan.test.tsx @@ -1,21 +1,42 @@ -import { act, render, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + ACTION_ERROR_API_MESSAGES, + ACTION_ERROR_MESSAGES, + ACTION_ERROR_STATUS, +} from "@/lib/action-errors"; import { useOrgSetupStore } from "@/store/organizations/store"; +import { + SCAN_JOBS_TAB, + SCAN_SCHEDULE_CAPABILITY, + SCHEDULE_FREQUENCY, +} from "@/types"; import { OrgLaunchScan } from "./org-launch-scan"; -const { launchOrganizationScansMock, pushMock, toastMock } = vi.hoisted(() => ({ +const { + launchOrganizationScansMock, + pushMock, + toastMock, + updateSchedulesBulkMock, +} = vi.hoisted(() => ({ launchOrganizationScansMock: vi.fn(), pushMock: vi.fn(), toastMock: vi.fn(), + updateSchedulesBulkMock: vi.fn(), })); vi.mock("@/actions/scans/scans", () => ({ launchOrganizationScans: launchOrganizationScansMock, })); +vi.mock("@/actions/schedules/schedules", () => ({ + updateSchedulesBulk: updateSchedulesBulkMock, +})); + vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock, @@ -32,49 +53,386 @@ vi.mock("@/components/shadcn", async (importOriginal) => ({ }), })); +const PROVIDER_IDS = ["provider-1", "provider-2"]; + +const lastFooterConfig = (onFooterChange: ReturnType) => + onFooterChange.mock.calls.at(-1)?.[0]; + describe("OrgLaunchScan", () => { beforeEach(() => { + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "Europe/Madrid" }), + } as Intl.DateTimeFormat); sessionStorage.clear(); localStorage.clear(); launchOrganizationScansMock.mockReset(); pushMock.mockReset(); toastMock.mockReset(); + updateSchedulesBulkMock.mockReset(); + launchOrganizationScansMock.mockResolvedValue({ + successCount: 2, + failureCount: 0, + totalCount: 2, + errors: [], + }); + updateSchedulesBulkMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: PROVIDER_IDS, + failed: [], + }, + }, + }); useOrgSetupStore.getState().reset(); useOrgSetupStore .getState() .setOrganization("org-1", "My Organization", "o-abc123def4"); - useOrgSetupStore.getState().setCreatedProviderIds(["provider-1"]); + useOrgSetupStore.getState().setCreatedProviderIds(PROVIDER_IDS); }); - it("shows a success toast with an action linking to scans", async () => { - // Given - launchOrganizationScansMock.mockResolvedValue({ successCount: 1 }); - const onFooterChange = vi.fn(); + describe("when capability is ADVANCED", () => { + it("should save schedules through the bulk endpoint", async () => { + // Given + const onFooterChange = vi.fn(); - render( - , - ); + render( + , + ); - // When - await waitFor(() => { - expect(onFooterChange).toHaveBeenCalled(); - }); - const footerConfig = onFooterChange.mock.calls.at(-1)?.[0]; - await act(async () => { - footerConfig.onAction?.(); + // When + await screen.findByText("Scan Schedule"); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => + expect(updateSchedulesBulkMock).toHaveBeenCalledTimes(1), + ); + expect(updateSchedulesBulkMock).toHaveBeenCalledWith( + PROVIDER_IDS, + expect.objectContaining({ + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: expect.any(Number), + scan_timezone: "Europe/Madrid", + }), + ); + expect(launchOrganizationScansMock).not.toHaveBeenCalled(); + expect(pushMock).toHaveBeenCalledWith("/providers"); + expect( + toastMock.mock.calls[0]?.[0].action.props.children.props.href, + ).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`); }); - // Then - await waitFor(() => { - expect(toastMock).toHaveBeenCalledTimes(1); + it("should launch initial scans only for updated providers", async () => { + // Given + const user = userEvent.setup(); + const onFooterChange = vi.fn(); + updateSchedulesBulkMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: ["provider-2"], + failed: [{ id: "provider-1", error: "Denied" }], + }, + }, + }); + + render( + , + ); + + // When + await user.click( + await screen.findByRole("checkbox", { + name: /launch an initial scan now/i, + }), + ); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => + expect(launchOrganizationScansMock).toHaveBeenCalledTimes(1), + ); + expect(launchOrganizationScansMock).toHaveBeenCalledWith( + ["provider-2"], + "single", + ); + expect( + toastMock.mock.calls[0]?.[0].action.props.children.props.href, + ).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`); + }); + + it("should disable launch actions while schedule capability is loading", async () => { + // Given + const onFooterChange = vi.fn(); + + render( + , + ); + + // When + await screen.findByText("Loading scan options..."); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + expect(lastFooterConfig(onFooterChange)?.backDisabled).toBe(true); + expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true); + expect(updateSchedulesBulkMock).not.toHaveBeenCalled(); + expect(launchOrganizationScansMock).not.toHaveBeenCalled(); + }); + + it("should surface an error toast and stay on the wizard when the bulk update fails", async () => { + // Given + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + updateSchedulesBulkMock.mockResolvedValue({ + error: ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], + status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED, + }); + + render( + , + ); + + // When + await screen.findByText("Scan Schedule"); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + title: "Unable to save scan schedules", + description: + ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], + }), + ), + ); + expect(launchOrganizationScansMock).not.toHaveBeenCalled(); + expect(pushMock).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("should treat a fully-failed bulk response as an error without navigating away", async () => { + // Given + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + updateSchedulesBulkMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: [], + failed: [ + { id: "provider-1", error: "Denied" }, + { id: "provider-2", error: "Denied" }, + ], + }, + }, + }); + + render( + , + ); + + // When + await screen.findByText("Scan Schedule"); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + title: "Unable to save scan schedules", + description: "The scan schedule could not be saved for 2 accounts.", + }), + ), + ); + expect(launchOrganizationScansMock).not.toHaveBeenCalled(); + expect(pushMock).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("should describe partial failures in the success toast", async () => { + // Given + const onFooterChange = vi.fn(); + updateSchedulesBulkMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: ["provider-2"], + failed: [{ provider_id: "provider-1", error: "Denied" }], + }, + }, + }); + + render( + , + ); + + // When + await screen.findByText("Scan Schedule"); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(toastMock).toHaveBeenCalled()); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Scan schedules saved", + description: + "The schedule was saved for 1 account, but 1 account could not be updated.", + }), + ); + }); + }); + + describe("when capability is DAILY_LEGACY", () => { + it("should keep the legacy daily scheduling path", async () => { + // Given + const onFooterChange = vi.fn(); + + render( + , + ); + + // When + await screen.findByText( + "Select a Prowler scan schedule for these accounts.", + ); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => + expect(launchOrganizationScansMock).toHaveBeenCalledWith( + PROVIDER_IDS, + "daily", + ), + ); + expect(updateSchedulesBulkMock).not.toHaveBeenCalled(); + expect( + toastMock.mock.calls[0]?.[0].action.props.children.props.href, + ).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`); + }); + }); + + describe("when capability is MANUAL_ONLY", () => { + it("should launch single scans without rendering schedule controls", async () => { + // Given + const onFooterChange = vi.fn(); + + render( + , + ); + + // When + expect( + screen.getByText( + /scheduled scans are not available for trial accounts/i, + ), + ).toBeInTheDocument(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => + expect(launchOrganizationScansMock).toHaveBeenCalledWith( + PROVIDER_IDS, + "single", + ), + ); + expect(updateSchedulesBulkMock).not.toHaveBeenCalled(); + expect( + toastMock.mock.calls[0]?.[0].action.props.children.props.href, + ).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`); + }); + }); + + describe("when capability is BLOCKED", () => { + it("should disable the action without calling scans or schedules", async () => { + // Given + const onFooterChange = vi.fn(); + + render( + , + ); + + // When + await waitFor(() => { + expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true); + }); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument(); + expect(updateSchedulesBulkMock).not.toHaveBeenCalled(); + expect(launchOrganizationScansMock).not.toHaveBeenCalled(); }); - const toastPayload = toastMock.mock.calls[0]?.[0]; - expect(toastPayload.title).toBe("Scan Launched"); - expect(toastPayload.action).toBeDefined(); - expect(toastPayload.action.props.children.props.href).toBe("/scans"); }); }); diff --git a/ui/components/providers/organizations/org-launch-scan.tsx b/ui/components/providers/organizations/org-launch-scan.tsx index f1f70a49ad..2e2441ad25 100644 --- a/ui/components/providers/organizations/org-launch-scan.tsx +++ b/ui/components/providers/organizations/org-launch-scan.tsx @@ -1,15 +1,19 @@ "use client"; +import { zodResolver } from "@hookform/resolvers/zod"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; import { launchOrganizationScans } from "@/actions/scans/scans"; +import { updateSchedulesBulk } from "@/actions/schedules/schedules"; import { AWSProviderBadge } from "@/components/icons/providers-badge"; import { WIZARD_FOOTER_ACTION_TYPE, WizardFooterConfig, } from "@/components/providers/wizard/steps/footer-controls"; +import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields"; import { ToastAction, useToast } from "@/components/shadcn"; import { Select, @@ -20,13 +24,40 @@ import { } from "@/components/shadcn/select/select"; import { Spinner } from "@/components/shadcn/spinner/spinner"; import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon"; +import { getActionErrorMessage, hasActionError } from "@/lib/action-errors"; +import { + buildScheduleUpdatePayload, + getScanScheduleCapability, + getScheduleFormDefaults, + scheduleFormSchema, +} from "@/lib/schedules"; +import { isCloud } from "@/lib/shared/env"; import { useOrgSetupStore } from "@/store/organizations/store"; +import { + SCAN_JOBS_TAB, + SCAN_SCHEDULE_CAPABILITY, + type ScanScheduleCapability, + type ScheduleFormValues, + type SchedulesBulkResponse, +} from "@/types"; import { TREE_ITEM_STATUS } from "@/types/tree"; interface OrgLaunchScanProps { onClose: () => void; onBack: () => void; onFooterChange: (config: WizardFooterConfig) => void; + /** + * Schedule capability override. Prowler Cloud passes MANUAL_ONLY/BLOCKED for + * billing-limited tenants; OSS falls back to an environment-based capability. + */ + capability?: ScanScheduleCapability; + /** Cloud-only manual scan quota signal. */ + isScanLimitReached?: boolean; + /** + * Cloud-only loading state while billing is resolved into a schedule + * capability. OSS leaves it false. + */ + isScheduleCapabilityLoading?: boolean; } const SCAN_SCHEDULE = { @@ -36,69 +67,247 @@ const SCAN_SCHEDULE = { type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE]; +function getStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +/** + * Providers whose schedule was actually saved. The backend reports successes + * under `updated`, populated only after each provider's schedule commits, so + * it already excludes failures — no client-side subtraction is needed. + */ +function getUpdatedProviderIds(result: SchedulesBulkResponse): string[] { + return getStringArray(result.data?.attributes?.updated); +} + +function getFailedCount(result: SchedulesBulkResponse): number { + const failed = result.data?.attributes?.failed; + return Array.isArray(failed) ? failed.length : 0; +} + +function formatAccountCount(count: number): string { + return `${count} account${count === 1 ? "" : "s"}`; +} + +function getScansHref(tab: (typeof SCAN_JOBS_TAB)[keyof typeof SCAN_JOBS_TAB]) { + return `/scans?tab=${tab}`; +} + export function OrgLaunchScan({ onClose, onBack, onFooterChange, + capability, + isScanLimitReached = false, + isScheduleCapabilityLoading = false, }: OrgLaunchScanProps) { const router = useRouter(); const { toast } = useToast(); const { organizationExternalId, createdProviderIds, reset } = useOrgSetupStore(); + const resolvedCapability = capability ?? getScanScheduleCapability(isCloud()); + const isAdvanced = resolvedCapability === SCAN_SCHEDULE_CAPABILITY.ADVANCED; + const isDailyLegacy = + resolvedCapability === SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY; + const isManualOnly = + resolvedCapability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; + const isBlocked = + resolvedCapability === SCAN_SCHEDULE_CAPABILITY.BLOCKED || + (isManualOnly && isScanLimitReached); + const [isLaunching, setIsLaunching] = useState(false); const [scheduleOption, setScheduleOption] = useState( SCAN_SCHEDULE.DAILY, ); + const form = useForm({ + resolver: zodResolver(scheduleFormSchema), + defaultValues: getScheduleFormDefaults(), + }); + const launchInitialScan = useWatch({ + control: form.control, + name: "launchInitialScan", + }); const launchActionRef = useRef<() => void>(() => {}); - const handleLaunchScan = async () => { + const effectiveScheduleOption = isManualOnly + ? SCAN_SCHEDULE.SINGLE + : scheduleOption; + const actionDisabled = + isLaunching || + isScheduleCapabilityLoading || + isBlocked || + createdProviderIds.length === 0; + const actionLabel = isAdvanced + ? isLaunching + ? launchInitialScan + ? "Saving and launching..." + : "Saving..." + : launchInitialScan + ? "Save and launch scan" + : "Save" + : isLaunching + ? "Launching scans..." + : "Launch scan"; + + const finishSuccess = () => { + reset(); + onClose(); + router.push("/providers"); + }; + + const handleAdvancedSchedule = form.handleSubmit(async (values) => { + if (actionDisabled || !isAdvanced) { + return; + } + + setIsLaunching(true); + + const result = await updateSchedulesBulk( + createdProviderIds, + buildScheduleUpdatePayload(values), + ); + + if (hasActionError(result)) { + setIsLaunching(false); + toast({ + variant: "destructive", + title: "Unable to save scan schedules", + description: getActionErrorMessage(result), + }); + return; + } + + const updatedProviderIds = getUpdatedProviderIds(result); + const failedCount = getFailedCount(result); + + // No provider was actually updated (e.g. the endpoint returned 200 but every + // schedule failed). Surface it as an error and keep the wizard open to retry + // instead of navigating away with a misleading "saved for 0 accounts" toast. + if (updatedProviderIds.length === 0) { + setIsLaunching(false); + toast({ + variant: "destructive", + title: "Unable to save scan schedules", + description: + failedCount > 0 + ? `The scan schedule could not be saved for ${formatAccountCount(failedCount)}.` + : "The scan schedule could not be saved for any account.", + }); + return; + } + + let initialScanFailureCount = 0; + let initialScanSuccessCount = 0; + + if (values.launchInitialScan) { + const scanResult = await launchOrganizationScans( + updatedProviderIds, + SCAN_SCHEDULE.SINGLE, + ); + initialScanFailureCount = scanResult.failureCount; + initialScanSuccessCount = scanResult.successCount; + } + + setIsLaunching(false); + finishSuccess(); + + const updatedCount = updatedProviderIds.length; + const description = + failedCount > 0 + ? `The schedule was saved for ${formatAccountCount(updatedCount)}, but ${formatAccountCount(failedCount)} could not be updated.` + : `The scan schedule was saved for ${formatAccountCount(updatedCount)}.`; + const targetTab = + initialScanSuccessCount > 0 + ? SCAN_JOBS_TAB.ACTIVE + : SCAN_JOBS_TAB.SCHEDULED; + + toast({ + title: + values.launchInitialScan && initialScanFailureCount === 0 + ? "Scan schedules saved and initial scans launched" + : "Scan schedules saved", + description: + initialScanFailureCount > 0 + ? `${description} Initial scans failed for ${formatAccountCount(initialScanFailureCount)}.` + : description, + action: ( + + Go to scans + + ), + }); + }); + + const handleLegacyLaunch = async () => { + if (actionDisabled || isAdvanced) { + return; + } + setIsLaunching(true); const result = await launchOrganizationScans( createdProviderIds, - scheduleOption, + effectiveScheduleOption, ); const successCount = result.successCount; + const targetTab = + effectiveScheduleOption === SCAN_SCHEDULE.SINGLE + ? SCAN_JOBS_TAB.ACTIVE + : SCAN_JOBS_TAB.SCHEDULED; setIsLaunching(false); - reset(); - onClose(); - router.push("/providers"); + finishSuccess(); toast({ title: "Scan Launched", description: - scheduleOption === SCAN_SCHEDULE.DAILY - ? `Daily scan scheduled for ${successCount} account${successCount !== 1 ? "s" : ""}.` - : `Single scan launched for ${successCount} account${successCount !== 1 ? "s" : ""}.`, + effectiveScheduleOption === SCAN_SCHEDULE.DAILY + ? `Daily scan scheduled for ${formatAccountCount(successCount)}.` + : `Single scan launched for ${formatAccountCount(successCount)}.`, action: ( - Go to scans + Go to scans ), }); }; launchActionRef.current = () => { - void handleLaunchScan(); + if (isAdvanced) { + void handleAdvancedSchedule(); + return; + } + void handleLegacyLaunch(); }; useEffect(() => { onFooterChange({ showBack: true, backLabel: "Back", - backDisabled: isLaunching, + backDisabled: isLaunching || isScheduleCapabilityLoading, onBack, showAction: true, - actionLabel: "Launch scan", - actionDisabled: isLaunching || createdProviderIds.length === 0, + actionLabel, + actionDisabled, actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, onAction: () => { launchActionRef.current(); }, }); - }, [createdProviderIds.length, isLaunching, onBack, onFooterChange]); + }, [ + actionDisabled, + actionLabel, + createdProviderIds.length, + isAdvanced, + isLaunching, + isScheduleCapabilityLoading, + launchInitialScan, + onBack, + onFooterChange, + ]); return (
    @@ -118,11 +327,17 @@ export function OrgLaunchScan({
    - {isLaunching ? ( + {isLaunching || isScheduleCapabilityLoading ? (
    -

    Launching scans...

    +

    + {isScheduleCapabilityLoading + ? "Loading scan options..." + : isAdvanced + ? "Saving scan schedules..." + : "Launching scans..."} +

    ) : ( @@ -146,30 +361,51 @@ export function OrgLaunchScan({

    )} -
    -

    - Select a Prowler scan schedule for these accounts. + {isBlocked ? ( +

    + You have reached your scan limit, so additional scans are not + available right now.

    - -
    + showLaunchInitialScan + showNextScheduledCopy + /> + ) : isManualOnly ? ( +
    +

    + Scheduled scans are not available for trial accounts. These + accounts will run a one-time manual scan now. +

    +
    + ) : isDailyLegacy ? ( +
    +

    + Select a Prowler scan schedule for these accounts. +

    + +
    + ) : null}
    )}
    diff --git a/ui/components/providers/providers-accounts-table.test.tsx b/ui/components/providers/providers-accounts-table.test.tsx new file mode 100644 index 0000000000..7419bffe1a --- /dev/null +++ b/ui/components/providers/providers-accounts-table.test.tsx @@ -0,0 +1,306 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { MetaDataProps } from "@/types"; +import { + PROVIDERS_GROUP_KIND, + PROVIDERS_ROW_TYPE, + type ProvidersTableRow, +} from "@/types/providers-table"; +import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules"; + +const { dataTableMockState, getColumnProvidersMock } = vi.hoisted(() => ({ + dataTableMockState: { + nextSelection: {} as Record, + }, + getColumnProvidersMock: vi.fn((..._args: unknown[]) => []), +})); + +vi.mock("@/components/shadcn/table", () => ({ + DataTable: ({ + onRowSelectionChange, + }: { + onRowSelectionChange?: (selection: Record) => void; + }) => ( +
    + +
    + ), +})); + +vi.mock("./table", () => ({ + getColumnProviders: (...args: unknown[]) => getColumnProvidersMock(...args), +})); + +import { + computeSelectedScheduleProviders, + ProvidersAccountsTable, +} from "./providers-accounts-table"; + +const metadata: MetaDataProps = { + pagination: { page: 1, pages: 1, count: 0, itemsPerPage: [10] }, + version: "latest", +}; + +const createProviderRow = ( + id: string, + uid = id, + alias: string | null = id, +): ProvidersTableRow => + ({ + id, + rowType: PROVIDERS_ROW_TYPE.PROVIDER, + type: "providers", + attributes: { + provider: "aws", + uid, + alias, + status: "completed", + resources: 0, + connection: { + connected: true, + last_checked_at: "2026-01-01T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { data: { id: `secret-${id}`, type: "secrets" } }, + provider_groups: { meta: { count: 0 }, data: [] }, + }, + groupNames: [], + hasSchedule: false, + }) as ProvidersTableRow; + +const providerOne = createProviderRow("provider-1", "111111111111", "Prod"); +const providerTwo = createProviderRow("provider-2", "222222222222", "Stage"); +const providerThree = createProviderRow("provider-3", "333333333333", "Dev"); + +const organizationRow: ProvidersTableRow = { + id: "org-1", + rowType: PROVIDERS_ROW_TYPE.ORGANIZATION, + groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION, + name: "My AWS Organization", + externalId: "o-abc123def4", + parentExternalId: null, + organizationId: "org-1", + providerCount: 3, + providerIds: ["provider-1", "provider-2", "provider-hidden"], + subRows: [providerOne, providerTwo], +}; + +const organizationalUnitRow: ProvidersTableRow = { + id: "ou-1", + rowType: PROVIDERS_ROW_TYPE.ORGANIZATION, + groupKind: PROVIDERS_GROUP_KIND.ORGANIZATION_UNIT, + name: "Production OU", + externalId: "ou-abc123", + parentExternalId: "o-abc123def4", + organizationId: "org-1", + providerCount: 2, + providerIds: ["provider-2", "provider-hidden-ou"], + subRows: [providerTwo], +}; + +describe("ProvidersAccountsTable", () => { + beforeEach(() => { + vi.clearAllMocks(); + dataTableMockState.nextSelection = {}; + }); + + it("passes scan schedule capability to provider row action columns", () => { + // Given/When + render( + , + ); + + // Then + expect(screen.getByTestId("providers-data-table")).toBeInTheDocument(); + expect(getColumnProvidersMock).toHaveBeenCalledWith( + expect.any(Object), + [], + [], + [], + expect.any(Function), + expect.any(Function), + expect.any(Function), + SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY, + ); + }); + + describe("schedule provider selection", () => { + it("uses the selected provider id for provider rows", () => { + // Given + const rows = [providerOne, providerTwo]; + + // When + const result = computeSelectedScheduleProviders(rows, { "0": true }); + + // Then + expect(result.providerIds).toEqual(["provider-1"]); + expect(result.providers.map((provider) => provider.providerId)).toEqual([ + "provider-1", + ]); + }); + + it("uses every organization provider id when the organization is selected", () => { + // Given + const rows = [organizationRow]; + + // When + const result = computeSelectedScheduleProviders(rows, { "0": true }); + + // Then + expect(result.providerIds).toEqual([ + "provider-1", + "provider-2", + "provider-hidden", + ]); + expect(result.providers.map((provider) => provider.providerId)).toEqual([ + "provider-1", + "provider-2", + ]); + }); + + it("uses every organizational unit provider id when the OU is selected", () => { + // Given + const rows = [ + { + ...organizationRow, + subRows: [organizationalUnitRow], + }, + ]; + + // When + const result = computeSelectedScheduleProviders(rows, { "0.0": true }); + + // Then + expect(result.providerIds).toEqual(["provider-2", "provider-hidden-ou"]); + }); + + it("deduplicates provider ids when an organization and child provider are selected", () => { + // Given + const rows = [organizationRow]; + + // When + const result = computeSelectedScheduleProviders(rows, { + "0": true, + "0.0": true, + }); + + // Then + expect(result.providerIds).toEqual([ + "provider-1", + "provider-2", + "provider-hidden", + ]); + }); + + it("uses only selected child providers when an organization is partially selected", () => { + // Given + const rows = [organizationRow, providerThree]; + + // When + const result = computeSelectedScheduleProviders(rows, { + "0.1": true, + "1": true, + }); + + // Then + expect(result.providerIds).toEqual(["provider-2", "provider-3"]); + }); + }); + + it("passes selected provider ids to provider row action columns", async () => { + // Given + const user = userEvent.setup(); + dataTableMockState.nextSelection = { "0": true }; + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Apply selection" })); + + // Then + expect(getColumnProvidersMock).toHaveBeenLastCalledWith( + expect.any(Object), + ["provider-1"], + ["provider-1"], + [ + expect.objectContaining({ + providerId: "provider-1", + providerType: "aws", + providerUid: "111111111111", + providerAlias: "Prod", + }), + ], + expect.any(Function), + expect.any(Function), + expect.any(Function), + SCAN_SCHEDULE_CAPABILITY.ADVANCED, + ); + }); + + it("passes selected organization provider ids and visible providers to provider row action columns", async () => { + // Given + const user = userEvent.setup(); + dataTableMockState.nextSelection = { "0": true }; + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Apply selection" })); + + // Then + expect(getColumnProvidersMock).toHaveBeenLastCalledWith( + expect.any(Object), + [], + ["provider-1", "provider-2", "provider-hidden"], + [ + expect.objectContaining({ providerId: "provider-1" }), + expect.objectContaining({ providerId: "provider-2" }), + ], + expect.any(Function), + expect.any(Function), + expect.any(Function), + SCAN_SCHEDULE_CAPABILITY.ADVANCED, + ); + }); +}); diff --git a/ui/components/providers/providers-accounts-table.tsx b/ui/components/providers/providers-accounts-table.tsx index 572f2e12f0..45f7341128 100644 --- a/ui/components/providers/providers-accounts-table.tsx +++ b/ui/components/providers/providers-accounts-table.tsx @@ -1,7 +1,7 @@ "use client"; import { RowSelectionState } from "@tanstack/react-table"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import type { OrgWizardInitialData, @@ -11,8 +11,13 @@ import { DataTable } from "@/components/shadcn/table"; import { MetaDataProps } from "@/types"; import { isProvidersOrganizationRow, + isProvidersProviderRow, ProvidersTableRow, } from "@/types/providers-table"; +import type { + ScanScheduleCapability, + ScanScheduleProvider, +} from "@/types/schedules"; import { getColumnProviders } from "./table"; @@ -20,6 +25,7 @@ interface ProvidersAccountsTableProps { isCloud: boolean; metadata?: MetaDataProps; rows: ProvidersTableRow[]; + scanScheduleCapability?: ScanScheduleCapability; onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void; onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void; } @@ -50,31 +56,132 @@ function computeTestableProviderIds( return ids; } -export function ProvidersAccountsTable({ +function toScanScheduleProvider( + row: ProvidersTableRow, +): ScanScheduleProvider | null { + if (!isProvidersProviderRow(row)) return null; + + return { + providerId: row.id, + providerType: row.attributes.provider, + providerUid: row.attributes.uid, + providerAlias: row.attributes.alias, + }; +} + +function appendUnique(target: string[], seen: Set, ids: string[]) { + for (const id of ids) { + if (seen.has(id)) continue; + seen.add(id); + target.push(id); + } +} + +function appendUniqueProvider( + target: ScanScheduleProvider[], + seen: Set, + provider: ScanScheduleProvider | null, +) { + if (!provider || seen.has(provider.providerId)) return; + seen.add(provider.providerId); + target.push(provider); +} + +function collectVisibleScheduleProviders(rows: ProvidersTableRow[]) { + const providers: ScanScheduleProvider[] = []; + const seen = new Set(); + + function walk(items: ProvidersTableRow[]) { + for (const item of items) { + appendUniqueProvider(providers, seen, toScanScheduleProvider(item)); + if (isProvidersOrganizationRow(item)) { + walk(item.subRows); + } + } + } + + walk(rows); + return providers; +} + +export interface SelectedScheduleProvidersResult { + providerIds: string[]; + providers: ScanScheduleProvider[]; +} + +export function computeSelectedScheduleProviders( + rows: ProvidersTableRow[], + rowSelection: RowSelectionState, +): SelectedScheduleProvidersResult { + const providerIds: string[] = []; + const providers: ScanScheduleProvider[] = []; + const seenProviderIds = new Set(); + const seenVisibleProviders = new Set(); + + function walk(items: ProvidersTableRow[], prefix: string) { + items.forEach((item, idx) => { + const key = prefix ? `${prefix}.${idx}` : `${idx}`; + const isSelected = rowSelection[key] === true; + + if (isProvidersOrganizationRow(item)) { + if (isSelected) { + appendUnique(providerIds, seenProviderIds, item.providerIds); + for (const provider of collectVisibleScheduleProviders( + item.subRows, + )) { + appendUniqueProvider(providers, seenVisibleProviders, provider); + } + return; + } + + walk(item.subRows, key); + return; + } + + if (isSelected) { + appendUnique(providerIds, seenProviderIds, [item.id]); + appendUniqueProvider( + providers, + seenVisibleProviders, + toScanScheduleProvider(item), + ); + } + }); + } + + walk(rows, ""); + + return { providerIds, providers }; +} + +function ProvidersAccountsTableContent({ isCloud, metadata, rows, + scanScheduleCapability, onOpenProviderWizard, onOpenOrganizationWizard, }: ProvidersAccountsTableProps) { const [rowSelection, setRowSelection] = useState({}); - // Reset selection when page changes - const currentPage = metadata?.pagination?.page; - useEffect(() => { - setRowSelection({}); - }, [currentPage]); - const testableProviderIds = computeTestableProviderIds(rows, rowSelection); + const selectedScheduleProviders = computeSelectedScheduleProviders( + rows, + rowSelection, + ); + const selectedScheduleProviderIds = selectedScheduleProviders.providerIds; const clearSelection = () => setRowSelection({}); const columns = getColumnProviders( rowSelection, testableProviderIds, + selectedScheduleProviderIds, + selectedScheduleProviders.providers, clearSelection, onOpenProviderWizard, onOpenOrganizationWizard, + scanScheduleCapability, ); return ( @@ -92,3 +199,9 @@ export function ProvidersAccountsTable({ /> ); } + +export function ProvidersAccountsTable(props: ProvidersAccountsTableProps) { + const currentPage = props.metadata?.pagination?.page ?? "none"; + + return ; +} diff --git a/ui/components/providers/providers-accounts-view.test.tsx b/ui/components/providers/providers-accounts-view.test.tsx index c8b3baf596..f888f7f4d8 100644 --- a/ui/components/providers/providers-accounts-view.test.tsx +++ b/ui/components/providers/providers-accounts-view.test.tsx @@ -5,8 +5,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; import type { ProvidersTableRow } from "@/types/providers-table"; +import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules"; -const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({ +const { + providersAccountsTableSpy, + refreshMock, + replaceMock, + searchParamsValue, +} = vi.hoisted(() => ({ + providersAccountsTableSpy: vi.fn(), refreshMock: vi.fn(), replaceMock: vi.fn(), searchParamsValue: { current: "" }, @@ -49,7 +56,10 @@ vi.mock("@/components/providers/providers-filters", () => ({ })); vi.mock("@/components/providers/providers-accounts-table", () => ({ - ProvidersAccountsTable: () =>
    Table
    , + ProvidersAccountsTable: (props: { scanScheduleCapability?: string }) => { + providersAccountsTableSpy(props); + return
    Table
    ; + }, })); vi.mock("@/components/providers/wizard", () => ({ @@ -123,6 +133,7 @@ const disconnectedProviders: ProviderProps[] = [ describe("ProvidersAccountsView", () => { afterEach(() => { vi.restoreAllMocks(); + providersAccountsTableSpy.mockClear(); searchParamsValue.current = ""; window.history.replaceState({}, "", "/"); }); @@ -285,6 +296,27 @@ describe("ProvidersAccountsView", () => { ).not.toBeInTheDocument(); }); + it("passes scan schedule capability to provider row actions", () => { + // Given/When + render( + , + ); + + // Then + expect(providersAccountsTableSpy).toHaveBeenCalledWith( + expect.objectContaining({ + scanScheduleCapability: SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY, + }), + ); + }); + it("opens the provider wizard from the normal Add Provider button", async () => { // Given const user = userEvent.setup(); diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx index a7b7702f0e..8a5d8f93fc 100644 --- a/ui/components/providers/providers-accounts-view.tsx +++ b/ui/components/providers/providers-accounts-view.tsx @@ -29,6 +29,7 @@ import { } from "@/lib/tours/use-driver-tour"; import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; import type { ProvidersTableRow } from "@/types/providers-table"; +import type { ScanScheduleCapability } from "@/types/schedules"; const addProviderFlow = getFlowById("add-provider")!; @@ -51,6 +52,9 @@ interface ProvidersAccountsViewProps { metadata?: MetaDataProps; providers: ProviderProps[]; rows: ProvidersTableRow[]; + /** Cloud overlay seam for provider-creation scan launch. */ + scanScheduleCapability?: ScanScheduleCapability; + isScanLimitReached?: boolean; } export function ProvidersAccountsView({ @@ -59,6 +63,8 @@ export function ProvidersAccountsView({ metadata, providers, rows, + scanScheduleCapability, + isScanLimitReached, }: ProvidersAccountsViewProps) { const pathname = usePathname(); const searchParams = useSearchParams(); @@ -146,6 +152,7 @@ export function ProvidersAccountsView({ isCloud={isCloud} metadata={metadata} rows={rows} + scanScheduleCapability={scanScheduleCapability} onOpenProviderWizard={openProviderWizard} onOpenOrganizationWizard={openOrganizationWizard} /> @@ -157,6 +164,8 @@ export function ProvidersAccountsView({ initialData={providerWizardInitialData} orgInitialData={orgWizardInitialData} refreshOnClose={false} + scanScheduleCapability={scanScheduleCapability} + isScanLimitReached={isScanLimitReached} /> ); diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index d759936e15..18ea1cd72d 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -1,18 +1,13 @@ "use client"; import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table"; -import { - Building2, - FolderTree, - ShieldAlert, - ShieldCheck, - ShieldOff, -} from "lucide-react"; +import { Building2, FolderTree } from "lucide-react"; import type { OrgWizardInitialData, ProviderWizardInitialData, } from "@/components/providers/wizard/types"; +import { Badge } from "@/components/shadcn"; import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet"; import { DateWithTime, EntityInfo } from "@/components/shadcn/entities"; @@ -25,6 +20,10 @@ import { ProvidersProviderRow, ProvidersTableRow, } from "@/types/providers-table"; +import type { + ScanScheduleCapability, + ScanScheduleProvider, +} from "@/types/schedules"; import { LinkToScans } from "../link-to-scans"; import { DataTableRowActions } from "./data-table-row-actions"; @@ -47,27 +46,24 @@ const OrganizationIcon = ({ groupKind }: { groupKind: string }) => { const ProviderStatusCell = ({ connected }: { connected: boolean | null }) => { if (connected === true) { return ( -
    - - Connected -
    + + Connected + ); } if (connected === false) { return ( -
    - - Connection failed -
    + + Connection failed + ); } return ( -
    - - Not connected -
    + + Not connected + ); }; @@ -111,9 +107,12 @@ function countSelectedLeaves(rows: Row[]): number { export function getColumnProviders( rowSelection: RowSelectionState, testableProviderIds: string[], + selectedScheduleProviderIds: string[], + selectedScheduleProviders: ScanScheduleProvider[], onClearSelection: () => void, onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void, onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void, + scanScheduleCapability?: ScanScheduleCapability, ): ColumnDef[] { return [ { @@ -259,7 +258,7 @@ export function getColumnProviders( return ( ); }, @@ -325,9 +324,12 @@ export function getColumnProviders( hasSelection={hasSelection} isRowSelected={row.getIsSelected()} testableProviderIds={testableProviderIds} + selectedScheduleProviderIds={selectedScheduleProviderIds} + selectedScheduleProviders={selectedScheduleProviders} onClearSelection={onClearSelection} onOpenProviderWizard={onOpenProviderWizard} onOpenOrganizationWizard={onOpenOrganizationWizard} + capability={scanScheduleCapability} /> ); }, diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx index 08d74376ea..663e5ad473 100644 --- a/ui/components/providers/table/data-table-row-actions.test.tsx +++ b/ui/components/providers/table/data-table-row-actions.test.tsx @@ -1,7 +1,7 @@ import { Row } from "@tanstack/react-table"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; import { @@ -9,8 +9,19 @@ import { PROVIDERS_ROW_TYPE, ProvidersTableRow, } from "@/types/providers-table"; +import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules"; -const checkConnectionProviderMock = vi.hoisted(() => vi.fn()); +const { checkConnectionProviderMock, getScheduleMock, pushMock } = vi.hoisted( + () => ({ + checkConnectionProviderMock: vi.fn(), + getScheduleMock: vi.fn(), + pushMock: vi.fn(), + }), +); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock }), +})); vi.mock("@/actions/organizations/organizations", () => ({ updateOrganizationName: vi.fn(), @@ -20,6 +31,10 @@ vi.mock("@/actions/providers/providers", () => ({ checkConnectionProvider: checkConnectionProviderMock, })); +vi.mock("@/actions/schedules", () => ({ + getSchedule: getScheduleMock, +})); + vi.mock("../forms/delete-form", () => ({ DeleteForm: () => null, })); @@ -32,6 +47,29 @@ vi.mock("../forms/edit-name-form", () => ({ EditNameForm: () => null, })); +vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({ + EDIT_SCAN_SCHEDULE_STATE: { + LOADING: "loading", + LOADED: "loaded", + ERROR: "error", + }, + EditScanScheduleModal: ({ + open, + provider, + providers, + }: { + open: boolean; + provider?: { providerId: string }; + providers?: { providerId: string }[]; + }) => + open ? ( +
    + Editing schedule for{" "} + {providers ? `${providers.length} providers` : provider?.providerId} +
    + ) : null, +})); + vi.mock("@/components/shadcn", async (importOriginal) => ({ ...(await importOriginal>()), useToast: () => ({ toast: vi.fn() }), @@ -97,6 +135,7 @@ const createOrgRow = () => parentExternalId: null, organizationId: "org-1", providerCount: 3, + providerIds: ["provider-child-1", "provider-child-2"], subRows: [ { id: "provider-child-1", @@ -129,6 +168,7 @@ const createOuRow = () => parentExternalId: "o-abc123def4", organizationId: "org-1", providerCount: 2, + providerIds: ["provider-ou-child-1"], subRows: [ { id: "provider-ou-child-1", @@ -144,6 +184,23 @@ const createOuRow = () => }) as unknown as Row; describe("DataTableRowActions", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + beforeEach(() => { + getScheduleMock.mockResolvedValue({ + data: { + type: "schedules", + id: "provider-1", + attributes: { scan_hour: null }, + relationships: { + provider: { data: { type: "providers", id: "provider-1" } }, + }, + }, + }); + }); + it("renders Add Credentials for provider rows without credentials", async () => { // Given const user = userEvent.setup(); @@ -164,12 +221,147 @@ describe("DataTableRowActions", () => { // Then expect(screen.getByText("Edit Provider Alias")).toBeInTheDocument(); + // Advanced schedule editing is gated to Prowler Cloud subscribed accounts. + expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument(); expect(screen.getByText("Add Credentials")).toBeInTheDocument(); expect(screen.getByText("Test Connection")).toBeInTheDocument(); expect(screen.getByText("Delete Provider")).toBeInTheDocument(); expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); }); + it("navigates to the provider-filtered scan jobs from View Scan Jobs", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("View Scan Jobs")); + + // Then: navigates with the key the scans filter bar binds to + // (provider__in, by id), URL-encoded, so the provider is pre-selected. + expect(pushMock).toHaveBeenCalledWith( + "/scans?filter%5Bprovider__in%5D=provider-1", + ); + }); + + it("links to scan jobs by provider id even when the uid contains unsafe chars", async () => { + // Given a GitHub provider whose UID is a URL with unsafe chars. + const user = userEvent.setup(); + const row = createRow(true); + ( + row.original as unknown as { attributes: { uid: string } } + ).attributes.uid = "https://github.com/prowler-cloud/prowler"; + + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("View Scan Jobs")); + + // Then the link carries the provider id, never the raw uid. + expect(pushMock).toHaveBeenCalledWith( + "/scans?filter%5Bprovider__in%5D=provider-1", + ); + }); + + it("opens Edit Scan Schedule for Prowler Cloud subscribed provider rows", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("Edit Scan Schedule")); + + // Then + expect( + screen.getByRole("dialog", { name: /edit scan schedule/i }), + ).toHaveTextContent("Editing schedule for provider-1"); + }); + + it("hides Edit Scan Schedule for manual-only Cloud provider rows", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + + // Then + expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument(); + }); + + it("hides Edit Scan Schedule for blocked Cloud provider rows", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + + // Then + expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument(); + }); + it("renders Update Credentials for provider rows with credentials", async () => { // Given const user = userEvent.setup(); @@ -216,6 +408,29 @@ describe("DataTableRowActions", () => { expect(screen.getByText("Delete Organization")).toBeInTheDocument(); }); + it("opens Edit Scan Schedule for AWS organization rows", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("Edit Scan Schedule")); + + expect( + screen.getByRole("dialog", { name: /edit scan schedule/i }), + ).toHaveTextContent("Editing schedule for 2 providers"); + }); + it("renders Delete Organization with destructive styling for org rows", async () => { const user = userEvent.setup(); render( @@ -301,6 +516,49 @@ describe("DataTableRowActions", () => { expect(screen.queryByText("Test Connections (1)")).not.toBeInTheDocument(); }); + it("shows bulk Edit Scan Schedule next to Test Connection for selected rows", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.click(screen.getByRole("button")); + + // Then + expect(screen.getByText("Edit Scan Schedule (3)")).toBeInTheDocument(); + expect(screen.getByText("Test Connection (2)")).toBeInTheDocument(); + }); + it("does NOT render Edit Organization Name or Update Credentials for OU rows", async () => { const user = userEvent.setup(); render( diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 969f1a63e9..bb0565d648 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -1,16 +1,30 @@ "use client"; import { Row } from "@tanstack/react-table"; -import { KeyRound, Pencil, Rocket, Trash2 } from "lucide-react"; +import { + CalendarClock, + KeyRound, + Pencil, + Rocket, + Timer, + Trash2, +} from "lucide-react"; +import { useRouter } from "next/navigation"; import { useState } from "react"; import { updateOrganizationName } from "@/actions/organizations/organizations"; import { updateProvider } from "@/actions/providers"; +import { getSchedule } from "@/actions/schedules"; import { ORG_WIZARD_INTENT, OrgWizardInitialData, ProviderWizardInitialData, } from "@/components/providers/wizard/types"; +import { + EDIT_SCAN_SCHEDULE_STATE, + EditScanScheduleModal, + type EditScanScheduleState, +} from "@/components/scans/schedule/edit-scan-schedule-modal"; import { useToast } from "@/components/shadcn"; import { ActionDropdown, @@ -20,6 +34,8 @@ import { import { Modal } from "@/components/shadcn/modal"; import { runWithConcurrencyLimit } from "@/lib/concurrency"; import { testProviderConnection } from "@/lib/provider-helpers"; +import { getScanScheduleCapability } from "@/lib/schedules"; +import { isCloud } from "@/lib/shared/env"; import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; import { @@ -29,6 +45,12 @@ import { ProvidersOrganizationRow, ProvidersTableRow, } from "@/types/providers-table"; +import { + SCAN_SCHEDULE_CAPABILITY, + type ScanScheduleCapability, + type ScanScheduleProvider, + type ScheduleApiResponse, +} from "@/types/schedules"; import { DeleteForm } from "../forms/delete-form"; import { DeleteOrganizationForm } from "../forms/delete-organization-form"; @@ -42,10 +64,21 @@ interface DataTableRowActionsProps { isRowSelected: boolean; /** IDs of all selected providers that have credentials (testable) */ testableProviderIds: string[]; + /** IDs of all selected providers that can receive schedule updates. */ + selectedScheduleProviderIds?: string[]; + /** Visible selected providers used as modal reference rows. */ + selectedScheduleProviders?: ScanScheduleProvider[]; /** Callback to clear the row selection after bulk operation */ onClearSelection: () => void; onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void; onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void; + /** + * Schedule capability override. Absent in OSS (defaults to a Cloud-vs-non-Cloud + * decision). The prowler-cloud overlay injects a billing-aware capability so + * only subscribed Cloud accounts can open the advanced schedule editor (which + * talks to the new schedule API). + */ + capability?: ScanScheduleCapability; } function collectTestableChildProviderIds(rows: ProvidersTableRow[]): string[] { @@ -62,28 +95,56 @@ function collectTestableChildProviderIds(rows: ProvidersTableRow[]): string[] { return ids; } +function collectChildScheduleProviders( + rows: ProvidersTableRow[], +): ScanScheduleProvider[] { + const providers: ScanScheduleProvider[] = []; + + for (const row of rows) { + if (row.rowType === PROVIDERS_ROW_TYPE.PROVIDER) { + providers.push({ + providerId: row.id, + providerType: row.attributes.provider, + providerUid: row.attributes.uid, + providerAlias: row.attributes.alias, + }); + continue; + } + + providers.push(...collectChildScheduleProviders(row.subRows)); + } + + return providers; +} + interface OrgGroupDropdownActionsProps { rowData: ProvidersOrganizationRow; loading: boolean; + canEditSchedule: boolean; hasSelection: boolean; testableProviderIds: string[]; childTestableIds: string[]; + scheduleProviderCount: number; onClearSelection: () => void; onBulkTest: (ids: string[]) => Promise; onTestChildConnections: () => Promise; onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void; + onOpenScheduleEditor: () => void; } function OrgGroupDropdownActions({ rowData, loading, + canEditSchedule, hasSelection, testableProviderIds, childTestableIds, + scheduleProviderCount, onClearSelection, onBulkTest, onTestChildConnections, onOpenOrganizationWizard, + onOpenScheduleEditor, }: OrgGroupDropdownActionsProps) { const [isDeleteOrgOpen, setIsDeleteOrgOpen] = useState(false); const [isEditNameOpen, setIsEditNameOpen] = useState(false); @@ -162,6 +223,14 @@ function OrgGroupDropdownActions({ /> )} + {isOrgKind && canEditSchedule && ( + } + label="Edit Scan Schedule" + onSelect={() => onOpenScheduleEditor()} + disabled={scheduleProviderCount === 0} + /> + )} } label={loading ? "Testing..." : `Test Connections (${testCount})`} @@ -197,14 +266,25 @@ export function DataTableRowActions({ hasSelection, isRowSelected, testableProviderIds, + selectedScheduleProviderIds = [], + selectedScheduleProviders = [], onClearSelection, onOpenProviderWizard, onOpenOrganizationWizard, + capability, }: DataTableRowActionsProps) { + const canEditSchedule = + (capability ?? getScanScheduleCapability(isCloud())) === + SCAN_SCHEDULE_CAPABILITY.ADVANCED; const [isEditOpen, setIsEditOpen] = useState(false); + const [isScheduleOpen, setIsScheduleOpen] = useState(false); + const [scheduleState, setScheduleState] = useState({ + kind: EDIT_SCAN_SCHEDULE_STATE.LOADING, + }); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [loading, setLoading] = useState(false); const { toast } = useToast(); + const router = useRouter(); const rowData = row.original; const isOrganizationRow = isProvidersOrganizationRow(rowData); @@ -215,11 +295,23 @@ export function DataTableRowActions({ const providerAlias = provider?.attributes.alias ?? null; const providerSecretId = provider?.relationships.secret.data?.id ?? null; const hasSecret = Boolean(provider?.relationships.secret.data); + const scheduleProvider: ScanScheduleProvider | undefined = provider + ? { + providerId, + providerType, + providerUid, + providerAlias, + } + : undefined; const orgGroupKind = isOrganizationRow ? rowData.groupKind : null; const childTestableIds = isOrganizationRow ? collectTestableChildProviderIds(rowData.subRows) : []; + const childScheduleProviders = isOrganizationRow + ? collectChildScheduleProviders(rowData.subRows) + : []; + const childScheduleProviderIds = isOrganizationRow ? rowData.providerIds : []; const handleBulkTest = async (ids: string[]) => { if (ids.length === 0) return; @@ -283,42 +375,128 @@ export function DataTableRowActions({ await handleBulkTest(childTestableIds); }; + const openScheduleEditor = async ( + targetProviders: ScanScheduleProvider[] = scheduleProvider + ? [scheduleProvider] + : [], + targetProviderIds: string[] = targetProviders.map( + (target) => target.providerId, + ), + ) => { + const targetProviderId = targetProviderIds[0]; + + if (!targetProviderId) { + setScheduleState({ + kind: EDIT_SCAN_SCHEDULE_STATE.ERROR, + message: "Provider ID is not available.", + }); + setIsScheduleOpen(true); + return; + } + + setScheduleState({ kind: EDIT_SCAN_SCHEDULE_STATE.LOADING }); + setIsScheduleOpen(true); + + const response = (await getSchedule(targetProviderId)) as + | ScheduleApiResponse + | { error?: string }; + + if (!response || ("error" in response && response.error)) { + setScheduleState({ + kind: EDIT_SCAN_SCHEDULE_STATE.ERROR, + message: + response && "error" in response && response.error + ? response.error + : "Failed to load scan schedule.", + }); + return; + } + + setScheduleState({ + kind: EDIT_SCAN_SCHEDULE_STATE.LOADED, + schedule: "data" in response ? response.data : null, + }); + }; + // When this row is part of the selection, only show "Test Connection" if (hasSelection && isRowSelected) { const bulkCount = testableProviderIds.length > 1 ? ` (${testableProviderIds.length})` : ""; + const selectedScheduleProviderCount = selectedScheduleProviderIds.length; return ( -
    - - } - label={loading ? "Testing..." : `Test Connection${bulkCount}`} - onSelect={(e) => { - e.preventDefault(); - handleTestConnection(); - }} - disabled={testableProviderIds.length === 0 || loading} - /> - -
    + <> + +
    + + {canEditSchedule && selectedScheduleProviderCount > 0 && ( + } + label={`Edit Scan Schedule (${selectedScheduleProviderCount})`} + onSelect={() => + void openScheduleEditor( + selectedScheduleProviders, + selectedScheduleProviderIds, + ) + } + /> + )} + } + label={loading ? "Testing..." : `Test Connection${bulkCount}`} + onSelect={(e) => { + e.preventDefault(); + handleTestConnection(); + }} + disabled={testableProviderIds.length === 0 || loading} + /> + +
    + ); } // Organization / Organization Unit row actions if (isProvidersOrganizationRow(rowData) && orgGroupKind) { return ( - + <> + + + void openScheduleEditor( + childScheduleProviders, + childScheduleProviderIds, + ) + } + /> + ); } @@ -364,6 +542,12 @@ export function DataTableRowActions({ )} +
    setIsEditOpen(true)} /> + } + label="View Scan Jobs" + onSelect={() => { + // Same key the scans filter bar binds to (`provider__in`, by id) so + // the provider is pre-selected and the filter works on every tab, + // including Scheduled (whose endpoint only accepts provider id). + const params = new URLSearchParams({ + "filter[provider__in]": providerId, + }); + router.push(`/scans?${params.toString()}`); + }} + /> + {canEditSchedule && ( + } + label="Edit Scan Schedule" + onSelect={() => void openScheduleEditor()} + /> + )} } label={hasSecret ? "Update Credentials" : "Add Credentials"} diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index 36c5058120..ec6f60fe64 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -8,6 +8,7 @@ import { OrgSetupForm } from "@/components/providers/organizations/org-setup-for import { Button } from "@/components/shadcn/button/button"; import { DialogHeader, DialogTitle } from "@/components/shadcn/dialog"; import { Modal } from "@/components/shadcn/modal"; +import { useScanScheduleCapability } from "@/hooks/use-scan-schedule-capability"; import { useScrollHint } from "@/hooks/use-scroll-hint"; import { advanceActiveTour, endActiveTour } from "@/lib/tours/use-driver-tour"; import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations"; @@ -15,6 +16,7 @@ import { PROVIDER_WIZARD_MODE, PROVIDER_WIZARD_STEP, } from "@/types/provider-wizard"; +import type { ScanScheduleCapability } from "@/types/schedules"; import { useProviderWizardController } from "./hooks/use-provider-wizard-controller"; import { @@ -40,6 +42,10 @@ interface ProviderWizardModalProps { initialData?: ProviderWizardInitialData; orgInitialData?: OrgWizardInitialData; refreshOnClose?: boolean; + /** Cloud overlay seam; defaults to the environment-resolved capability. */ + scanScheduleCapability?: ScanScheduleCapability; + /** Cloud-only manual scan quota signal. */ + isScanLimitReached?: boolean; } export function ProviderWizardModal({ @@ -48,6 +54,8 @@ export function ProviderWizardModal({ initialData, orgInitialData, refreshOnClose, + scanScheduleCapability, + isScanLimitReached, }: ProviderWizardModalProps) { const { backToProviderFlow, @@ -82,6 +90,10 @@ export function ProviderWizardModal({ enabled: open, refreshToken: scrollHintRefreshToken, }); + const { + capability: resolvedScanScheduleCapability, + isScheduleCapabilityLoading, + } = useScanScheduleCapability(scanScheduleCapability); const docsDestination = getProviderWizardDocsDestination(docsLink); return ( @@ -107,234 +119,248 @@ export function ProviderWizardModal({
    -
    -
    - {isProviderFlow ? ( - - ) : ( - - )} -
    -
    - - {/* Anchors the add-provider tour's final step to the whole right section — - the form inputs AND the footer — so the spotlight covers the Next button - instead of leaving it under the overlay. The popover still pins to the left - of this section, beside the inputs. */} -
    -
    -
    - {isProviderFlow && - currentStep === PROVIDER_WIZARD_STEP.CONNECT && ( - { - setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS); - // Reaching credentials is the tour's handoff point: end it so the - // user continues on their own. No-op off-onboarding. - endActiveTour(); - }} - onSelectOrganizations={openOrganizationsFlow} - onFooterChange={setFooterConfig} - onProviderTypeChange={(providerType) => { - // Picking a type reveals the account-detail inputs. Advance the tour - // to its wizard-body step, pinned beside the form. No-op off-onboarding. - if (providerType) advanceActiveTour(); - setProviderTypeHint(providerType); - }} - /> + {/* Anchors the add-provider tour's final step to the wizard content and + footer, keeping the real form controls clickable under the overlay. */} +
    +
    +
    + {isProviderFlow ? ( + + ) : ( + setCurrentStep(PROVIDER_WIZARD_STEP.TEST)} - onBack={() => setCurrentStep(PROVIDER_WIZARD_STEP.CONNECT)} - onFooterChange={setFooterConfig} - /> - )} - - {isProviderFlow && currentStep === PROVIDER_WIZARD_STEP.TEST && ( - - setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS) - } - onFooterChange={setFooterConfig} - /> - )} - - {isProviderFlow && - currentStep === PROVIDER_WIZARD_STEP.LAUNCH && ( - setCurrentStep(PROVIDER_WIZARD_STEP.TEST)} - onClose={handleClose} - onFooterChange={setFooterConfig} - /> - )} - - {!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.SETUP && ( - { - setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); - }} - onFooterChange={setFooterConfig} - onPhaseChange={setOrgSetupPhase} - initialPhase={orgSetupPhase} - initialValues={ - orgInitialData - ? { - organizationName: orgInitialData.organizationName, - awsOrgId: orgInitialData.externalId, - } - : undefined - } - intent={orgInitialData?.intent} - /> - )} - - {!isProviderFlow && - orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && ( - { - setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); - setOrgSetupPhase(ORG_SETUP_PHASE.ACCESS); - }} - onNext={() => { - setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH); - }} - onSkip={() => { - setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH); - }} - onFooterChange={setFooterConfig} - /> - )} - - {!isProviderFlow && orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && ( - { - setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); - }} - onFooterChange={setFooterConfig} - /> - )} - - {/* Sentinel element for IntersectionObserver scroll detection */} -
    -
    - - {showScrollHint && ( -
    -
    -
    - - Scroll to see more - -
    -
    + /> )}
    - {/* Inside the highlighted right section so the tour spotlight covers the Next - button (no longer dimmed by the overlay). Aligned to the form width so Next - stays at the right edge. */} - {(resolvedFooterConfig.showBack || - resolvedFooterConfig.showSecondaryAction || - resolvedFooterConfig.showAction) && ( -
    -
    -
    - {resolvedFooterConfig.showBack && ( - - )} -
    -
    - {resolvedFooterConfig.showSecondaryAction && ( - +
    + +
    +
    +
    + {isProviderFlow && + currentStep === PROVIDER_WIZARD_STEP.CONNECT && ( + { + setCurrentStep(PROVIDER_WIZARD_STEP.CREDENTIALS); + // Reaching credentials is the tour's handoff point: end it so the + // user continues on their own. No-op off-onboarding. + endActiveTour(); + }} + onSelectOrganizations={openOrganizationsFlow} + onFooterChange={setFooterConfig} + onProviderTypeChange={(providerType) => { + // Picking a type reveals the account-detail inputs. Advance the tour + // to its wizard-body step, pinned beside the form. No-op off-onboarding. + if (providerType) advanceActiveTour(); + setProviderTypeHint(providerType); + }} + /> )} - {resolvedFooterConfig.showAction && ( - + intent={orgInitialData?.intent} + /> )} + + {!isProviderFlow && + orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && ( + { + setOrgCurrentStep(ORG_WIZARD_STEP.SETUP); + setOrgSetupPhase(ORG_SETUP_PHASE.ACCESS); + }} + onNext={() => { + setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH); + }} + onSkip={() => { + setOrgCurrentStep(ORG_WIZARD_STEP.LAUNCH); + }} + onFooterChange={setFooterConfig} + /> + )} + + {!isProviderFlow && + orgCurrentStep === ORG_WIZARD_STEP.LAUNCH && ( + { + setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE); + }} + onFooterChange={setFooterConfig} + capability={resolvedScanScheduleCapability} + isScanLimitReached={isScanLimitReached} + isScheduleCapabilityLoading={isScheduleCapabilityLoading} + /> + )} + + {/* Sentinel element for IntersectionObserver scroll detection */} +
    +
    + + {showScrollHint && ( +
    +
    +
    + + Scroll to see more + +
    + )} +
    +
    +
    + + {(resolvedFooterConfig.showBack || + resolvedFooterConfig.showSecondaryAction || + resolvedFooterConfig.showAction) && ( +
    +
    +
    + {resolvedFooterConfig.showBack && ( + + )} +
    +
    + {resolvedFooterConfig.showSecondaryAction && ( + + )} + + {resolvedFooterConfig.showAction && ( + + )}
    - )} -
    +
    + )}
    ); diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx index ec6bbfe697..dfcf7da5fb 100644 --- a/ui/components/providers/wizard/steps/launch-step.test.tsx +++ b/ui/components/providers/wizard/steps/launch-step.test.tsx @@ -1,21 +1,37 @@ -import { act, render, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ACTION_ERROR_API_MESSAGES, + ACTION_ERROR_MESSAGES, + ACTION_ERROR_STATUS, +} from "@/lib/action-errors"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import { SCAN_JOBS_TAB } from "@/types"; +import { + SCAN_SCHEDULE_CAPABILITY, + SCHEDULE_FREQUENCY, +} from "@/types/schedules"; import { LaunchStep } from "./launch-step"; -const { scheduleDailyMock, scanOnDemandMock, toastMock } = vi.hoisted(() => ({ - scheduleDailyMock: vi.fn(), - scanOnDemandMock: vi.fn(), - toastMock: vi.fn(), -})); +const { scanOnDemandMock, scheduleDailyMock, toastMock, updateScheduleMock } = + vi.hoisted(() => ({ + scanOnDemandMock: vi.fn(), + scheduleDailyMock: vi.fn(), + toastMock: vi.fn(), + updateScheduleMock: vi.fn(), + })); vi.mock("@/actions/scans", () => ({ - scheduleDaily: scheduleDailyMock, scanOnDemand: scanOnDemandMock, + scheduleDaily: scheduleDailyMock, +})); + +vi.mock("@/actions/schedules", () => ({ + updateSchedule: updateScheduleMock, })); vi.mock("@/components/shadcn", async (importOriginal) => ({ @@ -28,64 +44,404 @@ vi.mock("@/components/shadcn", async (importOriginal) => ({ }), })); +const seedConnectedProvider = () => { + useProviderWizardStore.setState({ + providerId: "provider-1", + providerType: "gcp", + providerUid: "project-123", + mode: "add", + }); +}; + +const lastFooterConfig = (onFooterChange: ReturnType) => + onFooterChange.mock.calls.at(-1)?.[0]; + describe("LaunchStep", () => { beforeEach(() => { + vi.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: "Europe/Madrid" }), + } as Intl.DateTimeFormat); sessionStorage.clear(); localStorage.clear(); + updateScheduleMock.mockReset(); scheduleDailyMock.mockReset(); scanOnDemandMock.mockReset(); toastMock.mockReset(); useProviderWizardStore.getState().reset(); }); - it("launches a daily scan and shows toast", async () => { - // Given - const onClose = vi.fn(); - const onFooterChange = vi.fn(); - useProviderWizardStore.setState({ - providerId: "provider-1", - providerType: "gcp", - providerUid: "project-123", - mode: "add", + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("Prowler OSS (non-Cloud)", () => { + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); }); - scheduleDailyMock.mockResolvedValue({ data: { id: "scan-1" } }); + it("defaults to run now and locks schedule mode outside Cloud", async () => { + // Given + const onFooterChange = vi.fn(); + seedConnectedProvider(); - render( - , - ); + render( + , + ); - await waitFor(() => { - expect(onFooterChange).toHaveBeenCalled(); + // Then + expect(screen.getByText("Account Connected!")).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked(); + expect( + screen.getByRole("radio", { name: "On a schedule" }), + ).toBeDisabled(); + expect( + screen.queryByRole("combobox", { name: /repeats/i }), + ).not.toBeInTheDocument(); + + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan"); }); - // When - const initialFooterConfig = onFooterChange.mock.calls.at(-1)?.[0]; - await act(async () => { - initialFooterConfig.onAction?.(); + it("launches only an on-demand scan and never creates a legacy daily schedule", async () => { + // Given + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + + render( + , + ); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + + // When + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); + const sentFormData = scanOnDemandMock.mock.calls[0]?.[0] as FormData; + expect(sentFormData.get("providerId")).toBe("provider-1"); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + expect(updateScheduleMock).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe("Prowler Cloud subscribed", () => { + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } }); }); - // Then - await waitFor(() => { - expect(scheduleDailyMock).toHaveBeenCalledTimes(1); + it("defaults to schedule mode and saves through the new schedule API", async () => { + // Given + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + + render( + , + ); + + // Then advanced cadence selector is enabled + expect( + screen.getByRole("radio", { name: "On a schedule" }), + ).toBeChecked(); + expect( + screen.getByRole("combobox", { name: /repeats/i }), + ).not.toBeDisabled(); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + + // When + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1)); + expect(updateScheduleMock).toHaveBeenCalledWith( + "provider-1", + expect.objectContaining({ + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: expect.any(Number), + scan_timezone: "Europe/Madrid", + scan_day_of_week: null, + scan_day_of_month: null, + }), + ); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + expect(scanOnDemandMock).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + const toastPayload = toastMock.mock.calls[0]?.[0]; + expect(toastPayload.action.props.children.props.href).toBe( + `/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`, + ); }); - const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData; - expect(sentFormData.get("providerId")).toBe("provider-1"); - expect(onClose).toHaveBeenCalledTimes(1); - expect(scanOnDemandMock).not.toHaveBeenCalled(); - expect(toastMock).toHaveBeenCalledWith( - expect.objectContaining({ - title: "Scan Launched", - }), - ); - const toastPayload = toastMock.mock.calls[0]?.[0]; - expect(toastPayload.action.props.children.props.href).toBe( - `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`, - ); + it("also launches an on-demand scan when the checkbox is checked", async () => { + // Given + const user = userEvent.setup(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); + + render( + , + ); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + + // When + await user.click( + screen.getByRole("checkbox", { name: /launch an initial scan now/i }), + ); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1)); + expect(scanOnDemandMock).toHaveBeenCalledTimes(1); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + const toastPayload = toastMock.mock.calls[0]?.[0]; + expect(toastPayload.action.props.children.props.href).toBe( + `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`, + ); + }); + + it("launches only an on-demand scan when run now is selected", async () => { + // Given + const user = userEvent.setup(); + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); + + render( + , + ); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + + // When + await user.click( + screen.getByRole("radio", { + name: "Run now", + }), + ); + await waitFor(() => + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe( + "Launch scan", + ), + ); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); + expect(updateScheduleMock).not.toHaveBeenCalled(); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not launch an initial scan when the schedule save fails", async () => { + // Given + const user = userEvent.setup(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + updateScheduleMock.mockResolvedValue({ error: "Schedule failed" }); + + render( + , + ); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + + // When + await user.click( + screen.getByRole("checkbox", { name: /launch an initial scan now/i }), + ); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1)); + expect(scanOnDemandMock).not.toHaveBeenCalled(); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ title: "Unable to save scan schedule" }), + ); + }); + + it("disables launch actions while schedule capability is loading", async () => { + // Given + const onFooterChange = vi.fn(); + seedConnectedProvider(); + + render( + , + ); + + // When + await screen.findByText("Loading scan options..."); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + expect(lastFooterConfig(onFooterChange)?.backDisabled).toBe(true); + expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true); + expect(scanOnDemandMock).not.toHaveBeenCalled(); + expect(updateScheduleMock).not.toHaveBeenCalled(); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + }); + }); + + describe("Prowler Cloud trial/onboarding (manual scan only)", () => { + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); + }); + + it("defaults to run now, locks schedule mode, and only launches a manual scan", async () => { + // Given + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + seedConnectedProvider(); + + render( + , + ); + + // Then no schedule cadence selector is rendered + expect( + screen.queryByRole("combobox", { name: /repeats/i }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked(); + expect( + screen.getByRole("radio", { name: "On a schedule" }), + ).toBeDisabled(); + expect( + screen.getByText(/scheduled scans are not available/i), + ).toBeInTheDocument(); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(false); + // The action launches a scan here, so it must not be labeled "Save". + expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan"); + + // When + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); + expect(updateScheduleMock).not.toHaveBeenCalled(); + expect(scheduleDailyMock).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("uses the shared subscription error copy when a manual scan is blocked", async () => { + // Given + const onClose = vi.fn(); + const onFooterChange = vi.fn(); + const rawError = + ACTION_ERROR_API_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED]; + seedConnectedProvider(); + scanOnDemandMock.mockResolvedValue({ + error: rawError, + status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED, + }); + + render( + , + ); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + + // When + await act(async () => { + lastFooterConfig(onFooterChange)?.onAction?.(); + }); + + // Then + await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1)); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + title: "Unable to launch scan", + description: + ACTION_ERROR_MESSAGES[ACTION_ERROR_STATUS.PAYMENT_REQUIRED], + }), + ); + expect(toastMock.mock.calls[0]?.[0].description).not.toContain(rawError); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("disables the action and shows the limit copy when over limit", async () => { + // Given + const onFooterChange = vi.fn(); + seedConnectedProvider(); + + render( + , + ); + + // Then + expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument(); + await waitFor(() => expect(onFooterChange).toHaveBeenCalled()); + expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true); + }); }); }); diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx index 064d281b22..80355bf7d2 100644 --- a/ui/components/providers/wizard/steps/launch-step.tsx +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -1,21 +1,47 @@ "use client"; +import { zodResolver } from "@hookform/resolvers/zod"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; -import { scanOnDemand, scheduleDaily } from "@/actions/scans"; -import { ToastAction, useToast } from "@/components/shadcn"; +import { scanOnDemand } from "@/actions/scans"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/shadcn/select/select"; + SAVE_SCHEDULE_STATUS, + saveScheduleWithInitialScan, +} from "@/components/scans/schedule/save-schedule"; +import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields"; +import { Field, FieldLabel } from "@/components/shadcn"; +import { ToastAction, useToast } from "@/components/shadcn"; +import { EntityInfo } from "@/components/shadcn/entities"; +import { + RadioGroup, + RadioGroupItem, +} from "@/components/shadcn/radio-group/radio-group"; import { Spinner } from "@/components/shadcn/spinner/spinner"; import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon"; +import { + CloudFeatureBadge, + CloudFeatureBadgeLink, +} from "@/components/shared/cloud-feature-badge"; +import { + type ActionErrorResult, + getActionErrorMessage, + hasActionError, +} from "@/lib/action-errors"; +import { + getScanScheduleCapability, + getScheduleFormDefaults, + scheduleFormSchema, +} from "@/lib/schedules"; +import { isCloud } from "@/lib/shared/env"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import { SCAN_JOBS_TAB } from "@/types"; +import { + SCAN_SCHEDULE_CAPABILITY, + type ScanScheduleCapability, + type ScheduleFormValues, +} from "@/types/schedules"; import { TREE_ITEM_STATUS } from "@/types/tree"; import { @@ -23,51 +49,112 @@ import { WizardFooterConfig, } from "./footer-controls"; -const SCAN_SCHEDULE = { - DAILY: "daily", - SINGLE: "single", +const LAUNCH_MODE = { + NOW: "now", + SCHEDULE: "schedule", } as const; -type ScanScheduleOption = (typeof SCAN_SCHEDULE)[keyof typeof SCAN_SCHEDULE]; +type LaunchMode = (typeof LAUNCH_MODE)[keyof typeof LAUNCH_MODE]; interface LaunchStepProps { onBack: () => void; onClose: () => void; onFooterChange: (config: WizardFooterConfig) => void; + /** + * Schedule capability override. Absent in Prowler OSS (defaults to a + * Cloud-vs-non-Cloud decision). The prowler-cloud overlay computes a + * billing-aware capability and injects it here so trial/onboarding accounts + * are limited to manual scans. + */ + capability?: ScanScheduleCapability; + /** + * When true, the manual scan action is disabled (account scan quota reached). + * Cloud-only signal; never set in OSS. + */ + isScanLimitReached?: boolean; + /** + * Cloud-only loading state while billing is resolved into a schedule + * capability. OSS leaves it false. + */ + isScheduleCapabilityLoading?: boolean; } export function LaunchStep({ onBack, onClose, onFooterChange, + capability: capabilityProp, + isScanLimitReached = false, + isScheduleCapabilityLoading = false, }: LaunchStepProps) { const { toast } = useToast(); - const { providerId } = useProviderWizardStore(); + const { providerAlias, providerId, providerType, providerUid } = + useProviderWizardStore(); + const capability = capabilityProp ?? getScanScheduleCapability(isCloud()); + const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; + const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED; + const isBlocked = capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED; const [isLaunching, setIsLaunching] = useState(false); - const [scheduleOption, setScheduleOption] = useState( - SCAN_SCHEDULE.DAILY, + const [mode, setMode] = useState( + isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW, ); - const launchActionRef = useRef<() => void>(() => {}); + const form = useForm({ + resolver: zodResolver(scheduleFormSchema), + defaultValues: getScheduleFormDefaults(), + }); - const handleLaunchScan = async () => { - if (!providerId) { + const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE; + const isLimitBlocked = mode === LAUNCH_MODE.NOW && isScanLimitReached; + const isActionBlocked = + isLaunching || + isScheduleCapabilityLoading || + !providerId || + isBlocked || + isLimitBlocked; + const launchInitialScan = useWatch({ + control: form.control, + name: "launchInitialScan", + }); + + const actionLabel = (() => { + if (!isScheduleMode) { + return isLaunching ? "Launching scan..." : "Launch scan"; + } + + if (isLaunching) { + return launchInitialScan ? "Saving and launching..." : "Saving..."; + } + + return launchInitialScan ? "Save and launch scan" : "Save"; + })(); + + useEffect(() => { + if (!isAdvanced && mode !== LAUNCH_MODE.NOW) { + setMode(LAUNCH_MODE.NOW); + } + }, [isAdvanced, mode]); + + const launchOnDemandScan = async (): Promise => { + if (!providerId || isBlocked) return null; + const formData = new FormData(); + formData.set("providerId", providerId); + return scanOnDemand(formData); + }; + + const handleManualScan = async () => { + if (isActionBlocked) { return; } setIsLaunching(true); - const formData = new FormData(); - formData.set("providerId", providerId); - const result = - scheduleOption === SCAN_SCHEDULE.DAILY - ? await scheduleDaily(formData) - : await scanOnDemand(formData); + const scanResult = await launchOnDemandScan(); - if (result?.error) { + if (hasActionError(scanResult)) { setIsLaunching(false); toast({ variant: "destructive", title: "Unable to launch scan", - description: String(result.error), + description: getActionErrorMessage(scanResult), }); return; } @@ -75,11 +162,8 @@ export function LaunchStep({ setIsLaunching(false); onClose(); toast({ - title: "Scan Launched", - description: - scheduleOption === SCAN_SCHEDULE.DAILY - ? "Daily scan scheduled successfully." - : "Single scan launched successfully.", + title: "Scan launched", + description: "The scan was launched successfully.", action: ( Go to scans @@ -88,32 +172,111 @@ export function LaunchStep({ }); }; - launchActionRef.current = () => { - void handleLaunchScan(); + const handleSaveSchedule = form.handleSubmit(async (values) => { + if (!providerId || isBlocked || isScheduleCapabilityLoading) { + return; + } + + setIsLaunching(true); + + const result = await saveScheduleWithInitialScan({ + providerId, + values, + useLegacyDaily: !isAdvanced, + }); + + setIsLaunching(false); + + if (result.status === SAVE_SCHEDULE_STATUS.ERROR) { + toast({ + variant: "destructive", + title: "Unable to save scan schedule", + description: result.message, + }); + return; + } + + onClose(); + + const launched = result.status === SAVE_SCHEDULE_STATUS.SAVED_AND_LAUNCHED; + const targetTab = launched ? SCAN_JOBS_TAB.ACTIVE : SCAN_JOBS_TAB.SCHEDULED; + const goToScans = ( + + Go to scans + + ); + + if (result.status === SAVE_SCHEDULE_STATUS.SAVED_SCAN_FAILED) { + toast({ + title: "Scan schedule saved", + description: `The schedule was saved, but the initial scan could not be launched: ${result.message}`, + action: goToScans, + }); + return; + } + + toast({ + title: launched + ? "Scan schedule saved and initial scan launched" + : "Scan schedule saved", + description: launched + ? "The schedule was saved and the initial scan was launched." + : "The scan schedule was saved successfully.", + action: goToScans, + }); + }); + + // Keep the latest action handler in a ref so the footer (synced via effect) + // always invokes the current closure without re-running on every render. + const actionRef = useRef<() => void>(() => {}); + actionRef.current = () => { + if (isBlocked || isScheduleCapabilityLoading) { + return; + } + + if (!isScheduleMode) { + void handleManualScan(); + return; + } + void handleSaveSchedule(); }; useEffect(() => { onFooterChange({ showBack: true, backLabel: "Back", - backDisabled: isLaunching, + backDisabled: isLaunching || isScheduleCapabilityLoading, onBack, showAction: true, - actionLabel: isLaunching ? "Launching scans..." : "Launch scan", - actionDisabled: isLaunching || !providerId, + actionLabel, + actionDisabled: isActionBlocked, actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, - onAction: () => { - launchActionRef.current(); - }, + onAction: () => actionRef.current(), }); - }, [isLaunching, onBack, onFooterChange, providerId]); + }, [ + isActionBlocked, + isLaunching, + isScheduleCapabilityLoading, + actionLabel, + isScheduleMode, + launchInitialScan, + mode, + onBack, + onFooterChange, + ]); - if (isLaunching) { + if (isLaunching || isScheduleCapabilityLoading) { return (
    -

    Launching scans...

    +

    + {isScheduleCapabilityLoading + ? "Loading scan options..." + : !isScheduleMode + ? "Launching scan..." + : "Saving scan schedule..."} +

    ); @@ -121,13 +284,21 @@ export function LaunchStep({ return (
    + {(providerId || providerUid) && ( + + )} +
    -

    Connection validated!

    +

    Account Connected!

    - Choose how you want to launch scans for this provider. + Your account is connected to Prowler and ready to Scan!

    {!providerId && ( @@ -136,28 +307,62 @@ export function LaunchStep({

    )} -
    -

    Scan schedule

    - -
    + + + + + + {!isAdvanced && !isBlocked && ( +

    + Scheduled scans are not available for this account. Run now to get + immediate findings. +

    + )} + + {(isLimitBlocked || isBlocked) && ( +

    + You have reached your scan limit, so additional scans are not + available right now. +

    + )} + + {isScheduleMode && ( + + )}
    ); } diff --git a/ui/components/runtime-config/runtime-public-config.tsx b/ui/components/runtime-config/runtime-public-config.tsx new file mode 100644 index 0000000000..1069ac58d2 --- /dev/null +++ b/ui/components/runtime-config/runtime-public-config.tsx @@ -0,0 +1,17 @@ +import { getRuntimePublicConfig } from "@/lib/runtime-config"; +import { RUNTIME_CONFIG_SCRIPT_ID } from "@/lib/runtime-config.shared"; +import { serializeForScript } from "@/lib/safe-json"; + +// Inert JSON config island (type="application/json") rendered in before +// the client bundle, so module-load consumers (Sentry init) read it race-free. +export async function RuntimePublicConfig() { + const config = await getRuntimePublicConfig(); + + return ( + breakout so the script tag is not terminated early", () => { + // Given + const value = { sentryDsn: "" }; + + // When + const serialized = serializeForScript(value); + + // Then + expect(serialized).not.toContain(""); + expect(serialized).not.toContain("<"); + expect(serialized).not.toContain(">"); + }); + + it("neutralizes the HTML comment opener " }; + + // When + const serialized = serializeForScript(value); + + // Then + expect(serialized).not.toContain("