From 86cff92d1f6a4ea837eaeedab0015cf0d57a9513 Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Wed, 8 Oct 2025 20:19:43 +0200 Subject: [PATCH 01/22] fix: conventional commit checker (#8879) --- .github/workflows/conventional-commit.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml index cdc3e52de5..ecaf651c34 100644 --- a/.github/workflows/conventional-commit.yml +++ b/.github/workflows/conventional-commit.yml @@ -20,4 +20,5 @@ jobs: id: conventional-commit-check uses: agenthunt/conventional-commit-checker-action@9e552d650d0e205553ec7792d447929fc78e012b # v2.0.0 with: - pr-title-regex: '^([^\s(]+)(?:\(([^)]+)\))?: (.+)' + pr-title-regex: '^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)(\([^)]+\))?!?: .+' + \ No newline at end of file From d6685eec1f9a7efaf1c9218fd073febe37049f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Thu, 9 Oct 2025 11:14:13 +0200 Subject: [PATCH 02/22] feat(api-keys): support include parameter for entity details (#8876) --- api/src/backend/api/specs/v1.yaml | 23 +++++++++++++ api/src/backend/api/tests/test_views.py | 43 +++++++++++++++++++++++++ api/src/backend/api/v1/serializers.py | 21 ++++++++++++ 3 files changed, 87 insertions(+) diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 3d68533d48..daec67a79e 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -86,6 +86,17 @@ paths: description: A search term. schema: type: string + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - entity + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false - name: page[number] required: false in: query @@ -186,6 +197,17 @@ paths: format: uuid description: A UUID string identifying this tenant api key. required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - entity + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false tags: - API Keys security: @@ -4706,6 +4728,7 @@ paths: type: array items: type: string + x-spec-enum-id: 4c1e219dad1cc0e7 enum: - aws - azure diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 416da2e1c9..cfad06c777 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -8024,6 +8024,49 @@ class TestTenantApiKeyViewSet: assert data["relationships"]["entity"]["data"]["type"] == "users" assert data["relationships"]["entity"]["data"]["id"] == str(api_key.entity.id) + def test_api_keys_retrieve_with_entity_include( + self, authenticated_client, api_keys_fixture + ): + """Test retrieving API key with ?include=entity returns user data without memberships.""" + api_key = api_keys_fixture[0] + response = authenticated_client.get( + reverse("api-key-detail", kwargs={"pk": api_key.id}), + {"include": "entity"}, + ) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + + # Verify the main data contains the entity relationship + data = response_data["data"] + assert "entity" in data["relationships"] + assert data["relationships"]["entity"]["data"]["type"] == "users" + assert data["relationships"]["entity"]["data"]["id"] == str(api_key.entity.id) + + # Verify included section exists + assert "included" in response_data + assert len(response_data["included"]) == 1 + + # Verify included user data + included_user = response_data["included"][0] + assert included_user["type"] == "users" + assert included_user["id"] == str(api_key.entity.id) + + # Verify UserIncludeSerializer fields are present + user_attrs = included_user["attributes"] + assert "name" in user_attrs + assert "email" in user_attrs + assert "company_name" in user_attrs + assert "date_joined" in user_attrs + assert user_attrs["name"] == api_key.entity.name + assert user_attrs["email"] == api_key.entity.email + + # Verify memberships field is NOT included (excluded by UserIncludeSerializer) + assert "memberships" not in user_attrs + + # Verify roles relationship is present + assert "relationships" in included_user + assert "roles" in included_user["relationships"] + def test_api_keys_entity_auto_assigned_on_create( self, authenticated_client, create_test_user ): diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index c8e3e02f08..e20fb4fab1 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -317,6 +317,23 @@ class UserSerializer(BaseSerializerV1): ) +class UserIncludeSerializer(UserSerializer): + class Meta: + model = User + fields = [ + "id", + "name", + "email", + "company_name", + "date_joined", + "roles", + ] + + included_serializers = { + "roles": "api.v1.serializers.RoleIncludeSerializer", + } + + class UserCreateSerializer(BaseWriteSerializer): password = serializers.CharField(write_only=True) company_name = serializers.CharField(required=False) @@ -2779,6 +2796,10 @@ class TenantApiKeySerializer(RLSSerializer): "entity", ] + included_serializers = { + "entity": "api.v1.serializers.UserIncludeSerializer", + } + class TenantApiKeyCreateSerializer(RLSSerializer, BaseWriteSerializer): """Serializer for creating new API keys.""" From b630234cdf1cfe82c422d99b14006267b46e877e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Thu, 9 Oct 2025 11:26:21 +0200 Subject: [PATCH 03/22] fix(api-key): use admin connector to validate authentication (#8883) --- api/src/backend/api/authentication.py | 23 +- .../tests/integration/test_authentication.py | 498 ++++++++++++++++++ .../backend/api/tests/test_authentication.py | 384 ++++++++++++++ 3 files changed, 903 insertions(+), 2 deletions(-) create mode 100644 api/src/backend/api/tests/test_authentication.py diff --git a/api/src/backend/api/authentication.py b/api/src/backend/api/authentication.py index a15e543642..499e290bb7 100644 --- a/api/src/backend/api/authentication.py +++ b/api/src/backend/api/authentication.py @@ -10,6 +10,7 @@ from rest_framework.exceptions import AuthenticationFailed from rest_framework.request import Request from rest_framework_simplejwt.authentication import JWTAuthentication +from api.db_router import MainRouter from api.models import TenantAPIKey, TenantAPIKeyManager @@ -19,6 +20,22 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth): def __init__(self): self.key_crypto = get_crypto() + def _authenticate_credentials(self, request, key): + """ + Override to use admin connection, bypassing RLS during authentication. + Delegates to parent after temporarily routing model queries to admin DB. + """ + # Temporarily point the model's manager to admin database + original_objects = self.model.objects + self.model.objects = self.model.objects.using(MainRouter.admin_db) + + try: + # Call parent method which will now use admin database + return super()._authenticate_credentials(request, key) + finally: + # Restore original manager + self.model.objects = original_objects + def authenticate(self, request: Request): prefixed_key = self.get_key(request) @@ -43,13 +60,15 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth): api_key_pk = UUID(api_key_pk) try: - api_key_instance = TenantAPIKey.objects.get(id=api_key_pk, prefix=prefix) + api_key_instance = TenantAPIKey.objects.using(MainRouter.admin_db).get( + id=api_key_pk, prefix=prefix + ) except TenantAPIKey.DoesNotExist: raise AuthenticationFailed("Invalid API Key.") # Update last_used_at api_key_instance.last_used_at = timezone.now() - api_key_instance.save(update_fields=["last_used_at"]) + api_key_instance.save(update_fields=["last_used_at"], using=MainRouter.admin_db) return entity, { "tenant_id": str(api_key_instance.tenant_id), diff --git a/api/src/backend/api/tests/integration/test_authentication.py b/api/src/backend/api/tests/integration/test_authentication.py index 2a71d3585a..2abf725124 100644 --- a/api/src/backend/api/tests/integration/test_authentication.py +++ b/api/src/backend/api/tests/integration/test_authentication.py @@ -1003,6 +1003,504 @@ class TestCombinedAuthentication: assert api_key.last_used_at is not None +@pytest.mark.django_db +class TestAPIKeyRLSBypass: + """Test RLS bypass fix for API key authentication. + + These tests verify that API key authentication works correctly even when + RLS context is not set, which is critical since we don't know the tenant_id + until we look up the API key (which itself is protected by RLS). + + The fix ensures all database operations during authentication use the admin + database, bypassing RLS constraints. + """ + + def test_api_key_authentication_without_rls_context( + self, create_test_user, tenants_fixture, api_keys_fixture + ): + """Verify API key authentication works without pre-existing RLS context. + + This is the core fix: authentication must succeed even when prowler.tenant_id + is not set, since we need to look up the API key to discover the tenant. + """ + client = APIClient() + api_key = api_keys_fixture[0] + + api_key_headers = get_api_key_header(api_key._raw_key) + response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert response.status_code == 200 + assert "data" in response.json() + + def test_api_key_lookup_uses_admin_database( + self, create_test_user, tenants_fixture + ): + """Verify API key lookup uses admin database during authentication. + + The TenantAPIKey model is RLS-protected, so queries against it would + normally fail without prowler.tenant_id set. The fix routes lookups + to the admin database which bypasses RLS. + """ + client = APIClient() + tenant = tenants_fixture[0] + + role = Role.objects.create( + tenant_id=tenant.id, + name="Admin DB Test Role", + unlimited_visibility=True, + manage_account=True, + ) + UserRoleRelationship.objects.create( + user=create_test_user, + role=role, + tenant_id=tenant.id, + ) + + api_key, raw_key = TenantAPIKey.objects.create_api_key( + name="Admin DB Test Key", + tenant_id=tenant.id, + entity=create_test_user, + ) + + api_key_headers = get_api_key_header(raw_key) + response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert response.status_code == 200 + + api_key.refresh_from_db() + assert api_key.last_used_at is not None + + def test_tenant_context_established_after_authentication( + self, create_test_user, tenants_fixture, api_keys_fixture + ): + """Verify correct tenant context is established after API key auth. + + After authentication, the tenant_id from the API key should be used + to set up the proper RLS context for subsequent queries. + """ + client = APIClient() + api_key = api_keys_fixture[0] + + api_key_headers = get_api_key_header(api_key._raw_key) + + # Use tenant-list endpoint to get actual tenant IDs + tenant_response = client.get(reverse("tenant-list"), headers=api_key_headers) + + assert tenant_response.status_code == 200 + tenant_data = tenant_response.json()["data"] + tenant_ids = [t["id"] for t in tenant_data] + + # Verify the API key's tenant is in the list of accessible tenants + assert str(api_key.tenant_id) in tenant_ids + + def test_concurrent_authentication_different_tenants(self, tenants_fixture): + """Verify multiple API keys from different tenants can authenticate simultaneously. + + This tests that the admin database routing works correctly in concurrent + scenarios and doesn't cause tenant isolation issues. + """ + client = APIClient() + + user1 = User.objects.create_user( + name="concurrent_user1", + email="concurrent1@test.com", + password=TEST_PASSWORD, + ) + user2 = User.objects.create_user( + name="concurrent_user2", + email="concurrent2@test.com", + password=TEST_PASSWORD, + ) + + tenant1 = tenants_fixture[0] + tenant2 = tenants_fixture[1] + + Membership.objects.create(user=user1, tenant=tenant1) + Membership.objects.create(user=user2, tenant=tenant2) + + role1 = Role.objects.create( + tenant_id=tenant1.id, + name="Concurrent Role 1", + unlimited_visibility=True, + manage_account=True, + ) + role2 = Role.objects.create( + tenant_id=tenant2.id, + name="Concurrent Role 2", + unlimited_visibility=True, + manage_account=True, + ) + + UserRoleRelationship.objects.create( + user=user1, + role=role1, + tenant_id=tenant1.id, + ) + UserRoleRelationship.objects.create( + user=user2, + role=role2, + tenant_id=tenant2.id, + ) + + api_key1, raw_key1 = TenantAPIKey.objects.create_api_key( + name="Concurrent Key 1", + tenant_id=tenant1.id, + entity=user1, + ) + api_key2, raw_key2 = TenantAPIKey.objects.create_api_key( + name="Concurrent Key 2", + tenant_id=tenant2.id, + entity=user2, + ) + + headers1 = get_api_key_header(raw_key1) + headers2 = get_api_key_header(raw_key2) + + response1 = client.get(reverse("provider-list"), headers=headers1) + response2 = client.get(reverse("provider-list"), headers=headers2) + + assert response1.status_code == 200 + assert response2.status_code == 200 + + api_key1.refresh_from_db() + api_key2.refresh_from_db() + + assert api_key1.last_used_at is not None + assert api_key2.last_used_at is not None + assert api_key1.tenant_id == tenant1.id + assert api_key2.tenant_id == tenant2.id + + def test_api_key_update_last_used_uses_admin_db( + self, create_test_user, tenants_fixture, api_keys_fixture + ): + """Verify last_used_at update uses admin database. + + The update to last_used_at during authentication must also use the + admin database since it occurs before RLS context is established. + """ + client = APIClient() + api_key = api_keys_fixture[0] + + assert api_key.last_used_at is None + + api_key_headers = get_api_key_header(api_key._raw_key) + first_response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert first_response.status_code == 200 + + api_key.refresh_from_db() + first_timestamp = api_key.last_used_at + assert first_timestamp is not None + + time.sleep(0.1) + + second_response = client.get(reverse("provider-list"), headers=api_key_headers) + assert second_response.status_code == 200 + + api_key.refresh_from_db() + second_timestamp = api_key.last_used_at + assert second_timestamp > first_timestamp + + def test_api_key_prefix_lookup_bypasses_rls( + self, create_test_user, tenants_fixture + ): + """Verify prefix-based API key lookup works without RLS context. + + The authentication process splits the key into prefix and encrypted parts, + then looks up by prefix. This lookup must work via admin database. + """ + client = APIClient() + tenant = tenants_fixture[0] + + role = Role.objects.create( + tenant_id=tenant.id, + name="Prefix Test Role", + unlimited_visibility=True, + manage_account=True, + ) + UserRoleRelationship.objects.create( + user=create_test_user, + role=role, + tenant_id=tenant.id, + ) + + api_key, raw_key = TenantAPIKey.objects.create_api_key( + name="Prefix Test Key", + tenant_id=tenant.id, + entity=create_test_user, + ) + + prefix = raw_key.split(".")[0] + assert prefix == api_key.prefix + + api_key_headers = get_api_key_header(raw_key) + response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert response.status_code == 200 + + def test_expired_api_key_check_uses_admin_db( + self, create_test_user, tenants_fixture + ): + """Verify expired API key validation works via admin database. + + Checking if a key is expired requires reading from TenantAPIKey, + which must use admin database during authentication. + """ + client = APIClient() + tenant = tenants_fixture[0] + + expired_key, raw_key = TenantAPIKey.objects.create_api_key( + name="Expired Test Key", + tenant_id=tenant.id, + entity=create_test_user, + expiry_date=datetime.now(timezone.utc) - timedelta(days=1), + ) + + api_key_headers = get_api_key_header(raw_key) + response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert response.status_code == 401 + assert "expired" in response.json()["errors"][0]["detail"].lower() + + def test_revoked_api_key_check_uses_admin_db( + self, create_test_user, tenants_fixture + ): + """Verify revoked API key validation works via admin database. + + Checking if a key is revoked requires reading from TenantAPIKey, + which must use admin database during authentication. + """ + client = APIClient() + tenant = tenants_fixture[0] + + role = Role.objects.create( + tenant_id=tenant.id, + name="Revoked Test Role", + unlimited_visibility=True, + manage_account=True, + ) + UserRoleRelationship.objects.create( + user=create_test_user, + role=role, + tenant_id=tenant.id, + ) + + api_key, raw_key = TenantAPIKey.objects.create_api_key( + name="Revoked Test Key", + tenant_id=tenant.id, + entity=create_test_user, + ) + + api_key.revoked = True + api_key.save() + + api_key_headers = get_api_key_header(raw_key) + response = client.get(reverse("provider-list"), headers=api_key_headers) + + assert response.status_code == 401 + assert "revoked" in response.json()["errors"][0]["detail"].lower() + + +@pytest.mark.django_db +class TestAPIKeyMultiTenantWorkflows: + """Test complete multi-tenant workflows using API keys. + + These integration tests verify end-to-end scenarios where API keys + are used across different tenants and ensure proper isolation. + """ + + def test_user_with_multiple_tenant_memberships_api_keys(self, tenants_fixture): + """User with memberships in multiple tenants can use different API keys. + + Tests that a user can have separate API keys for different tenants + and each key only accesses resources in its tenant. + """ + client = APIClient() + + user = User.objects.create_user( + name="multi_tenant_user", + email="multitenant@test.com", + password=TEST_PASSWORD, + ) + + tenant1 = tenants_fixture[0] + tenant2 = tenants_fixture[1] + + Membership.objects.create(user=user, tenant=tenant1) + Membership.objects.create(user=user, tenant=tenant2) + + role1 = Role.objects.create( + tenant_id=tenant1.id, + name="Multi Tenant Role 1", + unlimited_visibility=True, + manage_account=True, + ) + role2 = Role.objects.create( + tenant_id=tenant2.id, + name="Multi Tenant Role 2", + unlimited_visibility=True, + manage_account=True, + ) + + UserRoleRelationship.objects.create( + user=user, + role=role1, + tenant_id=tenant1.id, + ) + UserRoleRelationship.objects.create( + user=user, + role=role2, + tenant_id=tenant2.id, + ) + + key1, raw_key1 = TenantAPIKey.objects.create_api_key( + name="Tenant 1 Key", + tenant_id=tenant1.id, + entity=user, + ) + key2, raw_key2 = TenantAPIKey.objects.create_api_key( + name="Tenant 2 Key", + tenant_id=tenant2.id, + entity=user, + ) + + headers1 = get_api_key_header(raw_key1) + headers2 = get_api_key_header(raw_key2) + + response1 = client.get(reverse("provider-list"), headers=headers1) + response2 = client.get(reverse("provider-list"), headers=headers2) + + assert response1.status_code == 200 + assert response2.status_code == 200 + + me_response1 = client.get(reverse("user-me"), headers=headers1) + me_response2 = client.get(reverse("user-me"), headers=headers2) + + assert me_response1.status_code == 200 + assert me_response2.status_code == 200 + + assert me_response1.json()["data"]["id"] == str(user.id) + assert me_response2.json()["data"]["id"] == str(user.id) + + def test_api_key_cannot_access_different_tenant_resources( + self, tenants_fixture, providers_fixture + ): + """API key from one tenant cannot access resources from another tenant. + + Verifies RLS enforcement after authentication ensures tenant isolation. + """ + client = APIClient() + + user1 = User.objects.create_user( + name="tenant1_user", + email="tenant1user@test.com", + password=TEST_PASSWORD, + ) + user2 = User.objects.create_user( + name="tenant2_user", + email="tenant2user@test.com", + password=TEST_PASSWORD, + ) + + tenant1 = tenants_fixture[0] + tenant2 = tenants_fixture[1] + + Membership.objects.create(user=user1, tenant=tenant1) + Membership.objects.create(user=user2, tenant=tenant2) + + role1 = Role.objects.create( + tenant_id=tenant1.id, + name="Isolation Test Role 1", + unlimited_visibility=True, + manage_account=True, + ) + role2 = Role.objects.create( + tenant_id=tenant2.id, + name="Isolation Test Role 2", + unlimited_visibility=True, + manage_account=True, + ) + + UserRoleRelationship.objects.create( + user=user1, + role=role1, + tenant_id=tenant1.id, + ) + UserRoleRelationship.objects.create( + user=user2, + role=role2, + tenant_id=tenant2.id, + ) + + key1, raw_key1 = TenantAPIKey.objects.create_api_key( + name="Isolation Key 1", + tenant_id=tenant1.id, + entity=user1, + ) + + headers1 = get_api_key_header(raw_key1) + + provider_response = client.get(reverse("provider-list"), headers=headers1) + assert provider_response.status_code == 200 + + providers_data = provider_response.json()["data"] + + if providers_data: + for provider in providers_data: + provider_tenant_id = str(tenants_fixture[0].id) + assert str(tenant2.id) != provider_tenant_id + + def test_api_key_workflow_create_authenticate_revoke( + self, create_test_user_rbac, tenants_fixture + ): + """Complete workflow: create API key via JWT, use it, then revoke via JWT. + + Tests the full lifecycle using both JWT and API key authentication. + """ + client = APIClient() + tenants_fixture[0] + + jwt_access_token, _ = get_api_tokens( + client, create_test_user_rbac.email, TEST_PASSWORD + ) + jwt_headers = get_authorization_header(jwt_access_token) + + create_response = client.post( + reverse("api-key-list"), + data={ + "data": { + "type": "api-keys", + "attributes": { + "name": "Workflow Test Key", + }, + } + }, + format="vnd.api+json", + headers=jwt_headers, + ) + + assert create_response.status_code == 201 + api_key_data = create_response.json()["data"] + api_key_id = api_key_data["id"] + raw_api_key = api_key_data["attributes"]["api_key"] + + api_key_headers = get_api_key_header(raw_api_key) + auth_response = client.get(reverse("provider-list"), headers=api_key_headers) + assert auth_response.status_code == 200 + + revoke_response = client.delete( + reverse("api-key-revoke", kwargs={"pk": api_key_id}), + headers=jwt_headers, + ) + assert revoke_response.status_code == 200 + + revoked_auth_response = client.get( + reverse("provider-list"), headers=api_key_headers + ) + assert revoked_auth_response.status_code == 401 + assert "revoked" in revoked_auth_response.json()["errors"][0]["detail"].lower() + + def get_api_key_header(api_key: str) -> dict: """Helper to create API key authorization header.""" return {"Authorization": f"Api-Key {api_key}"} diff --git a/api/src/backend/api/tests/test_authentication.py b/api/src/backend/api/tests/test_authentication.py new file mode 100644 index 0000000000..6745c36e91 --- /dev/null +++ b/api/src/backend/api/tests/test_authentication.py @@ -0,0 +1,384 @@ +import time +from datetime import datetime, timedelta, timezone +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from django.test import RequestFactory +from rest_framework.exceptions import AuthenticationFailed + +from api.authentication import TenantAPIKeyAuthentication +from api.db_router import MainRouter +from api.models import TenantAPIKey + + +@pytest.mark.django_db +class TestTenantAPIKeyAuthentication: + @pytest.fixture + def auth_backend(self): + """Create an instance of TenantAPIKeyAuthentication.""" + return TenantAPIKeyAuthentication() + + @pytest.fixture + def request_factory(self): + """Create a Django request factory.""" + return RequestFactory() + + def test_authenticate_credentials_uses_admin_database( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test that _authenticate_credentials routes queries to admin database.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + + # Extract the encrypted key part (after the prefix and separator) + _, encrypted_key = raw_key.split(TenantAPIKey.objects.separator, 1) + + # Create a mock request + request = request_factory.get("/") + + # Call the method + entity, auth_dict = auth_backend._authenticate_credentials( + request, encrypted_key + ) + + # Verify that the entity is the user associated with the API key + assert entity == api_key.entity + assert entity.id == api_key.entity.id + + def test_authenticate_credentials_restores_manager_on_success( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test that the manager is restored after successful authentication.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + _, encrypted_key = raw_key.split(TenantAPIKey.objects.separator, 1) + + # Store the original manager + original_manager = TenantAPIKey.objects + + request = request_factory.get("/") + + # Call the method + auth_backend._authenticate_credentials(request, encrypted_key) + + # Verify the manager was restored + assert TenantAPIKey.objects == original_manager + + def test_authenticate_credentials_restores_manager_on_exception( + self, auth_backend, request_factory + ): + """Test that the manager is restored even when an exception occurs.""" + # Store the original manager + original_manager = TenantAPIKey.objects + + request = request_factory.get("/") + + # Try to authenticate with an invalid key that will raise an exception + with pytest.raises(Exception): + auth_backend._authenticate_credentials(request, "invalid_encrypted_key") + + # Verify the manager was restored despite the exception + assert TenantAPIKey.objects == original_manager + + def test_authenticate_valid_api_key( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test successful authentication with a valid API key.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + + # Create a request with the API key in the Authorization header + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" + + # Authenticate + entity, auth_dict = auth_backend.authenticate(request) + + # Verify the entity and auth dict + assert entity == api_key.entity + assert auth_dict["tenant_id"] == str(api_key.tenant_id) + assert auth_dict["sub"] == str(api_key.entity.id) + assert auth_dict["api_key_prefix"] == api_key.prefix + + # Verify that last_used_at was updated + api_key.refresh_from_db() + assert api_key.last_used_at is not None + assert (datetime.now(timezone.utc) - api_key.last_used_at).seconds < 5 + + def test_authenticate_valid_api_key_uses_admin_database( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test that authenticate uses admin database for API key lookup.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" + + # Mock the manager's using method to verify it's called with admin_db + with patch.object( + TenantAPIKey.objects, "using", wraps=TenantAPIKey.objects.using + ) as mock_using: + auth_backend.authenticate(request) + + # Verify that .using('admin') was called + mock_using.assert_called_with(MainRouter.admin_db) + + def test_authenticate_invalid_key_format_missing_separator( + self, auth_backend, request_factory + ): + """Test authentication fails with invalid API key format (no separator).""" + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = "Api-Key invalid_key_no_separator" + + with pytest.raises(AuthenticationFailed) as exc_info: + auth_backend.authenticate(request) + + assert str(exc_info.value.detail) == "Invalid API Key." + + def test_authenticate_invalid_key_format_empty_prefix( + self, auth_backend, request_factory + ): + """Test authentication fails with empty prefix.""" + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = "Api-Key .encrypted_part" + + with pytest.raises(AuthenticationFailed) as exc_info: + auth_backend.authenticate(request) + + assert str(exc_info.value.detail) == "Invalid API Key." + + def test_authenticate_invalid_encrypted_key( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test authentication fails with invalid encrypted key.""" + api_key = api_keys_fixture[0] + + # Create an invalid key with valid prefix but invalid encryption + invalid_key = f"{api_key.prefix}.invalid_encrypted_data" + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {invalid_key}" + + with pytest.raises(AuthenticationFailed) as exc_info: + auth_backend.authenticate(request) + + assert str(exc_info.value.detail) == "Invalid API Key." + + def test_authenticate_revoked_api_key( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test authentication fails with a revoked API key.""" + # Use the revoked API key (index 2 from fixture) + api_key = api_keys_fixture[2] + raw_key = api_key._raw_key + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" + + # The revoked key should fail during credential validation + with pytest.raises(AuthenticationFailed) as exc_info: + auth_backend.authenticate(request) + + assert str(exc_info.value.detail) == "This API Key has been revoked." + + def test_authenticate_expired_api_key( + self, auth_backend, create_test_user, tenants_fixture, request_factory + ): + """Test authentication fails with an expired API key.""" + tenant = tenants_fixture[0] + user = create_test_user + + # Create an expired API key + api_key, raw_key = TenantAPIKey.objects.create_api_key( + name="Expired API Key", + tenant_id=tenant.id, + entity=user, + expiry_date=datetime.now(timezone.utc) - timedelta(days=1), + ) + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" + + with pytest.raises(AuthenticationFailed) as exc_info: + auth_backend.authenticate(request) + + assert str(exc_info.value.detail) == "API Key has already expired." + + def test_authenticate_nonexistent_api_key( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test authentication fails when API key doesn't exist in database.""" + # Create a valid-looking encrypted key with a non-existent UUID + api_key = api_keys_fixture[0] + non_existent_uuid = str(uuid4()) + + # Manually create an encrypted key with a non-existent ID + payload = { + "_pk": non_existent_uuid, + "_exp": (datetime.now(timezone.utc) + timedelta(days=30)).timestamp(), + } + encrypted_key = auth_backend.key_crypto.generate(payload) + fake_key = f"{api_key.prefix}.{encrypted_key}" + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {fake_key}" + + with pytest.raises(AuthenticationFailed) as exc_info: + auth_backend.authenticate(request) + + assert str(exc_info.value.detail) == "No entity matching this api key." + + def test_authenticate_updates_last_used_at( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test that last_used_at is updated on successful authentication.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + + # Store the original last_used_at + original_last_used = api_key.last_used_at + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" + + # Authenticate + auth_backend.authenticate(request) + + # Refresh from database + api_key.refresh_from_db() + + # Verify last_used_at was updated + assert api_key.last_used_at is not None + if original_last_used: + assert api_key.last_used_at > original_last_used + + def test_authenticate_saves_to_admin_database( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test that the API key save operation uses admin database.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" + + # Mock the save method to verify it's called with using='admin' + with patch.object(TenantAPIKey, "save") as mock_save: + auth_backend.authenticate(request) + + # Verify save was called with using=admin_db + mock_save.assert_called_once_with( + update_fields=["last_used_at"], using=MainRouter.admin_db + ) + + def test_authenticate_returns_correct_auth_dict( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test that the auth dict contains all required fields.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" + + entity, auth_dict = auth_backend.authenticate(request) + + # Verify all required fields are present + assert "tenant_id" in auth_dict + assert "sub" in auth_dict + assert "api_key_prefix" in auth_dict + + # Verify values are correct + assert auth_dict["tenant_id"] == str(api_key.tenant_id) + assert auth_dict["sub"] == str(api_key.entity.id) + assert auth_dict["api_key_prefix"] == api_key.prefix + + def test_authenticate_with_multiple_api_keys_same_tenant( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test that authentication works correctly with multiple API keys for the same tenant.""" + # Test with first API key + api_key1 = api_keys_fixture[0] + raw_key1 = api_key1._raw_key + + request1 = request_factory.get("/") + request1.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key1}" + + entity1, auth_dict1 = auth_backend.authenticate(request1) + + assert entity1 == api_key1.entity + assert auth_dict1["api_key_prefix"] == api_key1.prefix + + # Test with second API key + api_key2 = api_keys_fixture[1] + raw_key2 = api_key2._raw_key + + request2 = request_factory.get("/") + request2.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key2}" + + entity2, auth_dict2 = auth_backend.authenticate(request2) + + assert entity2 == api_key2.entity + assert auth_dict2["api_key_prefix"] == api_key2.prefix + + # Verify they're different keys but same tenant + assert auth_dict1["api_key_prefix"] != auth_dict2["api_key_prefix"] + assert auth_dict1["tenant_id"] == auth_dict2["tenant_id"] + + def test_authenticate_with_wrong_prefix_in_db( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test authentication fails when prefix doesn't match database.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + + # Extract the encrypted part and combine with wrong prefix + _, encrypted_part = raw_key.split(TenantAPIKey.objects.separator, 1) + wrong_key = f"pk_wrong123.{encrypted_part}" + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {wrong_key}" + + with pytest.raises(AuthenticationFailed) as exc_info: + auth_backend.authenticate(request) + + assert str(exc_info.value.detail) == "Invalid API Key." + + def test_authenticate_credentials_exception_handling( + self, auth_backend, request_factory + ): + """Test that exceptions in _authenticate_credentials are properly handled.""" + request = request_factory.get("/") + + # Test with completely invalid data that will cause InvalidToken + with pytest.raises(Exception): + auth_backend._authenticate_credentials(request, "completely_invalid") + + def test_authenticate_with_expired_timestamp( + self, auth_backend, create_test_user, tenants_fixture, request_factory + ): + """Test that expired timestamp in encrypted key causes authentication failure.""" + tenant = tenants_fixture[0] + user = create_test_user + + # Create an API key with a very short expiry + api_key, raw_key = TenantAPIKey.objects.create_api_key( + name="Short-lived API Key", + tenant_id=tenant.id, + entity=user, + expiry_date=datetime.now(timezone.utc) + timedelta(seconds=1), + ) + + # Wait for the key to expire + time.sleep(2) + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" + + # Should fail with expired key + with pytest.raises(AuthenticationFailed) as exc_info: + auth_backend.authenticate(request) + + assert str(exc_info.value.detail) == "API Key has already expired." From 1a7f52fc9c404598909d5199badcdd69e9051cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Thu, 9 Oct 2025 11:50:10 +0200 Subject: [PATCH 04/22] fix(threatscore): improve the way ThreatScore is calculated (#8582) Co-authored-by: Sergio Garcia Co-authored-by: alejandrobailo --- api/CHANGELOG.md | 1 + ...uirementoverview_passed_failed_findings.py | 21 ++++++++ api/src/backend/api/models.py | 2 + api/src/backend/api/v1/serializers.py | 11 ++++ api/src/backend/api/v1/views.py | 21 +++++++- api/src/backend/tasks/jobs/scan.py | 41 ++++++++++++++- prowler/CHANGELOG.md | 3 +- .../prowler_threatscore.py | 50 +++++++++++++++++-- ui/CHANGELOG.md | 4 ++ .../threat-details.tsx | 19 +++++++ ui/lib/compliance/threat.tsx | 43 +++++++++++----- ui/types/compliance.ts | 3 ++ 12 files changed, 198 insertions(+), 21 deletions(-) create mode 100644 api/src/backend/api/migrations/0049_compliancerequirementoverview_passed_failed_findings.py diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 114fbc61c8..d6b692c110 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the **Prowler API** are documented in this file. - Default JWT keys are generated and stored if they are missing from configuration [(#8655)](https://github.com/prowler-cloud/prowler/pull/8655) - `compliance_name` for each compliance [(#7920)](https://github.com/prowler-cloud/prowler/pull/7920) - API Key support [(#8805)](https://github.com/prowler-cloud/prowler/pull/8805) +- Support for `passed_findings` and `total_findings` fields in compliance requirement overview for accurate Prowler ThreatScore calculation [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582) ### Changed - Now the MANAGE_ACCOUNT permission is required to modify or read user permissions instead of MANAGE_USERS [(#8281)](https://github.com/prowler-cloud/prowler/pull/8281) diff --git a/api/src/backend/api/migrations/0049_compliancerequirementoverview_passed_failed_findings.py b/api/src/backend/api/migrations/0049_compliancerequirementoverview_passed_failed_findings.py new file mode 100644 index 0000000000..db4f340a53 --- /dev/null +++ b/api/src/backend/api/migrations/0049_compliancerequirementoverview_passed_failed_findings.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.12 on 2025-10-07 10:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0048_api_key"), + ] + operations = [ + migrations.AddField( + model_name="compliancerequirementoverview", + name="passed_findings", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="compliancerequirementoverview", + name="total_findings", + field=models.IntegerField(default=0), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 0577f22e51..048f1ca7db 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -1293,6 +1293,8 @@ class ComplianceRequirementOverview(RowLevelSecurityProtectedModel): passed_checks = models.IntegerField(default=0) failed_checks = models.IntegerField(default=0) total_checks = models.IntegerField(default=0) + passed_findings = models.IntegerField(default=0) + total_findings = models.IntegerField(default=0) scan = models.ForeignKey( Scan, diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index e20fb4fab1..32ebbb040d 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1991,6 +1991,17 @@ class ComplianceOverviewDetailSerializer(serializers.Serializer): resource_name = "compliance-requirements-details" +class ComplianceOverviewDetailThreatscoreSerializer(ComplianceOverviewDetailSerializer): + """ + Serializer for detailed compliance requirement information for Threatscore. + + Includes additional fields specific to the Threatscore framework. + """ + + passed_findings = serializers.IntegerField() + total_findings = serializers.IntegerField() + + class ComplianceOverviewAttributesSerializer(serializers.Serializer): id = serializers.CharField() compliance_name = serializers.CharField() diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 8097ee2321..777afac907 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -142,6 +142,7 @@ from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin from api.v1.serializers import ( ComplianceOverviewAttributesSerializer, ComplianceOverviewDetailSerializer, + ComplianceOverviewDetailThreatscoreSerializer, ComplianceOverviewMetadataSerializer, ComplianceOverviewSerializer, FindingDynamicFilterSerializer, @@ -3438,7 +3439,12 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): all_requirements = ( filtered_queryset.values( - "requirement_id", "framework", "version", "description" + "requirement_id", + "framework", + "version", + "description", + "passed_findings", + "total_findings", ) .distinct() .annotate( @@ -3463,6 +3469,8 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): total_instances = requirement["total_instances"] passed_count = passed_counts.get(requirement_id, 0) is_manual = requirement["manual_count"] == total_instances + passed_findings = requirement["passed_findings"] + total_findings = requirement["total_findings"] if is_manual: requirement_status = "MANUAL" elif passed_count == total_instances: @@ -3477,10 +3485,19 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): "version": requirement["version"], "description": requirement["description"], "status": requirement_status, + "passed_findings": passed_findings, + "total_findings": total_findings, } ) - serializer = self.get_serializer(requirements_summary, many=True) + # Use different serializer for threatscore framework + if "threatscore" not in compliance_id: + serializer = self.get_serializer(requirements_summary, many=True) + else: + serializer = ComplianceOverviewDetailThreatscoreSerializer( + requirements_summary, many=True + ) + return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=False, methods=["get"], url_name="attributes") diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 311b1478c3..63183d57db 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -590,7 +590,7 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): # Get check status data by region from findings findings = ( Finding.all_objects.filter(scan_id=scan_id, muted=False) - .only("id", "check_id", "status") + .only("id", "check_id", "status", "compliance") .prefetch_related( Prefetch( "resources", @@ -601,7 +601,9 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): .iterator(chunk_size=1000) ) + findings_count_by_compliance = {} check_status_by_region = {} + modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" with rls_transaction(tenant_id): for finding in findings: for resource in finding.small_resources: @@ -609,6 +611,27 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): current_status = check_status_by_region.setdefault(region, {}) if current_status.get(finding.check_id) != "FAIL": current_status[finding.check_id] = finding.status + if modeled_threatscore_compliance_id in finding.compliance: + for requirement_id in finding.compliance[ + modeled_threatscore_compliance_id + ]: + compliance_key = findings_count_by_compliance.setdefault( + region, {} + ).setdefault( + modeled_threatscore_compliance_id.lower().replace( + "-", "" + ), + {}, + ) + if requirement_id not in compliance_key: + compliance_key[requirement_id] = { + "total": 0, + "pass": 0, + } + + compliance_key[requirement_id]["total"] += 1 + if finding.status == "PASS": + compliance_key[requirement_id]["pass"] += 1 try: # Try to get regions from provider @@ -644,6 +667,13 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): compliance_requirement_objects = [] for region, compliance_data in compliance_overview_by_region.items(): for compliance_id, compliance in compliance_data.items(): + modeled_framework = ( + compliance["framework"].lower().replace("-", "").replace("_", "") + ) + modeled_version = ( + compliance["version"].lower().replace("-", "").replace("_", "") + ) + modeled_compliance_id = f"{modeled_framework}{modeled_version}" # Create an overview record for each requirement within each compliance framework for requirement_id, requirement in compliance["requirements"].items(): compliance_requirement_objects.append( @@ -660,9 +690,16 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): failed_checks=requirement["checks_status"]["fail"], total_checks=requirement["checks_status"]["total"], requirement_status=requirement["status"], + passed_findings=findings_count_by_compliance.get(region, {}) + .get(modeled_compliance_id, {}) + .get(requirement_id, {}) + .get("pass", 0), + total_findings=findings_count_by_compliance.get(region, {}) + .get(modeled_compliance_id, {}) + .get(requirement_id, {}) + .get("total", 0), ) ) - # Bulk create requirement records create_objects_in_batches( tenant_id, ComplianceRequirementOverview, compliance_requirement_objects diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index dc8f02e52e..8f6f548e45 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -34,6 +34,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### Fixed - Fix SNS topics showing empty AWS_ResourceID in Quick Inventory output [(#8762)](https://github.com/prowler-cloud/prowler/issues/8762) - Fix HTML Markdown output for long strings [(#8803)](https://github.com/prowler-cloud/prowler/pull/8803) +- Prowler ThreatScore scoring calculation CLI [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582) --- @@ -438,4 +439,4 @@ All notable changes to the **Prowler SDK** are documented in this file. - Handle projects without ID in GCP [(#7496)](https://github.com/prowler-cloud/prowler/pull/7496) - Restore packages location in PyProject [(#7510)](https://github.com/prowler-cloud/prowler/pull/7510) ---- +--- \ No newline at end of file diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py index eb5194074e..2034ee97fa 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py @@ -2,6 +2,7 @@ from colorama import Fore, Style from tabulate import tabulate from prowler.config.config import orange_color +from prowler.lib.check.compliance_models import Compliance def get_prowler_threatscore_table( @@ -23,9 +24,12 @@ def get_prowler_threatscore_table( fail_count = [] muted_count = [] pillars = {} + generic_score = 0 + max_generic_score = 0 + counted_findings_generic = [] score_per_pillar = {} max_score_per_pillar = {} - counted_findings = [] + counted_findings_per_pillar = {} for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -39,12 +43,17 @@ def get_prowler_threatscore_table( [ pillar in score_per_pillar.keys(), pillar in max_score_per_pillar.keys(), + pillar in counted_findings_per_pillar.keys(), ] ): score_per_pillar[pillar] = 0 max_score_per_pillar[pillar] = 0 + counted_findings_per_pillar[pillar] = [] - if index not in counted_findings: + if ( + index not in counted_findings_per_pillar[pillar] + and not finding.muted + ): if finding.status == "PASS": score_per_pillar[pillar] += ( attribute.LevelOfRisk * attribute.Weight @@ -52,7 +61,7 @@ def get_prowler_threatscore_table( max_score_per_pillar[pillar] += ( attribute.LevelOfRisk * attribute.Weight ) - counted_findings.append(index) + counted_findings_per_pillar[pillar].append(index) if pillar not in pillars: pillars[pillar] = {"FAIL": 0, "PASS": 0, "Muted": 0} @@ -69,6 +78,27 @@ def get_prowler_threatscore_table( pass_count.append(index) pillars[pillar]["PASS"] += 1 + # Generic score + if index not in counted_findings_generic and not finding.muted: + if finding.status == "PASS": + generic_score += ( + attribute.LevelOfRisk * attribute.Weight + ) + max_generic_score += ( + attribute.LevelOfRisk * attribute.Weight + ) + counted_findings_generic.append(index) + + no_findings_pillars = [] + bulk_compliance = Compliance.get_bulk(provider=compliance.Provider.lower()).get( + compliance_framework + ) + 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) @@ -88,6 +118,16 @@ def get_prowler_threatscore_table( f"{orange_color}{pillars[pillar]['Muted']}{Style.RESET_ALL}" ) + for pillar in no_findings_pillars: + pillar_table["Provider"].append(compliance.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}") + pillar_table["Muted"].append(f"{orange_color}0{Style.RESET_ALL}") + + # Sort table by pillars + pillar_table["Pillar"] = sorted(pillar_table["Pillar"]) + if ( len(fail_count) + len(pass_count) + len(muted_count) > 1 ): # If there are no resources, don't print the compliance table @@ -108,7 +148,9 @@ def get_prowler_threatscore_table( print( f"\nFramework {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Results:" ) - + print( + f"\nGeneric Threat Score: {generic_score / max_generic_score * 100:.2f}%" + ) print( tabulate( pillar_table, diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 79a4930edf..e0bd99a446 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -26,6 +26,10 @@ All notable changes to the **Prowler UI** are documented in this file. - Migrated from `useFormState` to `useActionState` for React 19 compatibility [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - References display in findings detail page now shows as a proper bulleted list [(#8793)](https://github.com/prowler-cloud/prowler/pull/8793) +### 🐞 Fixed + +- ThreatScore for each pillar in Prowler ThreatScore specific view [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582) + --- ## [1.12.4] (Prowler v5.12.4) diff --git a/ui/components/compliance/compliance-custom-details/threat-details.tsx b/ui/components/compliance/compliance-custom-details/threat-details.tsx index 1cf127bf84..822612234d 100644 --- a/ui/components/compliance/compliance-custom-details/threat-details.tsx +++ b/ui/components/compliance/compliance-custom-details/threat-details.tsx @@ -54,6 +54,25 @@ export const ThreatCustomDetails = ({ conditional={true} /> )} + + {typeof requirement.passedFindings === "number" && + typeof requirement.totalFindings === "number" && ( + <> + + {requirement.totalFindings > 0 && ( + + )} + + )} {requirement.additionalInformation && ( diff --git a/ui/lib/compliance/threat.tsx b/ui/lib/compliance/threat.tsx index 9aae82f7b8..a090087865 100644 --- a/ui/lib/compliance/threat.tsx +++ b/ui/lib/compliance/threat.tsx @@ -52,6 +52,8 @@ export const mapComplianceData = ( const weight = attrs.Weight; const attributeDescription = attrs.AttributeDescription; const additionalInformation = attrs.AdditionalInformation; + const passedFindings = requirementData.attributes.passed_findings || 0; + const totalFindings = requirementData.attributes.total_findings || 0; // Calculate score: if PASS = levelOfRisk * weight, if FAIL = 0 const score = status === "PASS" ? levelOfRisk * weight : 0; @@ -81,6 +83,8 @@ export const mapComplianceData = ( score: score, attributeDescription: attributeDescription, additionalInformation: additionalInformation, + passedFindings: passedFindings, + totalFindings: totalFindings, }; control.requirements.push(requirement); @@ -97,9 +101,10 @@ export const mapComplianceData = ( category.fail = 0; category.manual = 0; - // Calculate total score for this section and maximum possible score - let totalSectionScore = 0; - let maxPossibleSectionScore = 0; + // Calculate ThreatScore using the new formula + let numerator = 0; + let denominator = 0; + let hasFindings = false; category.controls.forEach((control) => { control.pass = 0; @@ -109,13 +114,25 @@ export const mapComplianceData = ( control.requirements.forEach((requirement) => { updateCounters(control, requirement.status); - // Add to total section score (actual score obtained) - totalSectionScore += (requirement.score as number) || 0; + // Extract values for ThreatScore calculation + const pass_i = (requirement.passedFindings as number) || 0; + const total_i = (requirement.totalFindings as number) || 0; - // Add to maximum possible score (weight * levelOfRisk for each requirement) + // Skip if no findings (avoid division by zero) + if (total_i === 0) return; + + hasFindings = true; + const rate_i = pass_i / total_i; + const weight_i = (requirement.weight as number) || 1; const levelOfRisk = (requirement.levelOfRisk as number) || 0; - const weight = (requirement.weight as number) || 0; - maxPossibleSectionScore += levelOfRisk * weight; + + // Map levelOfRisk to risk factor (rfac_i) + // Formula: rfac_i = 1 + (k * levelOfRisk) where k = 0.25 + const rfac_i = 1 + 0.25 * levelOfRisk; + + // Accumulate weighted values + numerator += rate_i * total_i * weight_i * rfac_i; + denominator += total_i * weight_i * rfac_i; }); category.pass += control.pass; @@ -123,10 +140,12 @@ export const mapComplianceData = ( category.manual += control.manual; }); - // Calculate percentualScore for this section: (suma de scores obtenidos / suma de weight * levelOfRisk) * 100 - const percentualScore = - maxPossibleSectionScore > 0 - ? (totalSectionScore / maxPossibleSectionScore) * 100 + // Calculate ThreatScore (percentualScore) for this section + // If no findings exist, consider it 100% (PASS) + const percentualScore = !hasFindings + ? 100 + : denominator > 0 + ? (numerator / denominator) * 100 : 0; // Add percentualScore to category (we can extend the type or use a custom property) diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index 733740749e..17e0189732 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -189,6 +189,9 @@ export interface RequirementItemData { version: string; description: string; status: RequirementStatus; + // For Threat compliance: + passed_findings?: number; + total_findings?: number; }; } From ecf749fce89f4eff9a0d9d2c5e5cc66c9bbfa672 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 9 Oct 2025 12:24:24 +0200 Subject: [PATCH 05/22] chore(m365): deprecate user auth (#8865) --- docs/basic-usage/prowler-cli.md | 3 - docs/tutorials/microsoft365/authentication.md | 89 +--- .../microsoft365/getting-started-m365.md | 6 - docs/tutorials/prowler-app.md | 6 +- prowler/CHANGELOG.md | 1 + prowler/providers/common/provider.py | 1 - .../providers/m365/exceptions/exceptions.py | 55 --- .../providers/m365/lib/arguments/arguments.py | 16 +- .../m365/lib/powershell/m365_powershell.py | 170 +------ prowler/providers/m365/m365_provider.py | 136 +----- prowler/providers/m365/models.py | 3 - .../m365/lib/arguments/m365_arguments_test.py | 60 +-- .../lib/powershell/m365_powershell_test.py | 457 ++++-------------- tests/providers/m365/m365_fixtures.py | 4 +- tests/providers/m365/m365_provider_test.py | 392 ++------------- 15 files changed, 200 insertions(+), 1199 deletions(-) diff --git a/docs/basic-usage/prowler-cli.md b/docs/basic-usage/prowler-cli.md index 3681714b70..a06397d02b 100644 --- a/docs/basic-usage/prowler-cli.md +++ b/docs/basic-usage/prowler-cli.md @@ -182,9 +182,6 @@ Microsoft 365 requires specifying the auth method: # To use service principal authentication for MSGraph and PowerShell modules prowler m365 --sp-env-auth -# To use both service principal (for MSGraph) and user credentials (for PowerShell modules) -prowler m365 --env-auth - # To use az cli authentication prowler m365 --az-cli-auth diff --git a/docs/tutorials/microsoft365/authentication.md b/docs/tutorials/microsoft365/authentication.md index 8e65fd54d7..429245706b 100644 --- a/docs/tutorials/microsoft365/authentication.md +++ b/docs/tutorials/microsoft365/authentication.md @@ -5,17 +5,13 @@ Prowler for Microsoft 365 supports multiple authentication types. Authentication **Prowler App:** - [**Service Principal Application**](#service-principal-authentication-recommended) (**Recommended**) -- [**Service Principal with User Credentials**](#service-principal-and-user-credentials-authentication) (Being deprecated) +- [**Service Principal with User Credentials**](#service-principal-and-user-credentials-authentication) (Deprecated) **Prowler CLI:** - [**Service Principal Application**](#service-principal-authentication-recommended) (**Recommended**) -- [**Service Principal with User Credentials**](#service-principal-and-user-credentials-authentication) (Being deprecated) - [**Interactive browser authentication**](#interactive-browser-authentication) -???+ warning - The Service Principal with User Credentials method will be deprecated in October 2025 when Microsoft enforces MFA in all tenants, which will not allow user authentication without interactive methods. - ## Required Permissions To run the full Prowler provider, including PowerShell checks, two types of permission scopes must be set in **Microsoft Entra ID**. @@ -30,7 +26,6 @@ When using service principal authentication, add these **Application Permissions - `Directory.Read.All`: Required for all services. - `Policy.Read.All`: Required for all services. - `SharePointTenantSettings.Read.All`: Required for SharePoint service. -- `User.Read` (IMPORTANT: this must be set as **delegated**): Required for the sign-in. **External API Permissions:** @@ -43,20 +38,6 @@ When using service principal authentication, add these **Application Permissions ???+ note This is the **recommended authentication method** because it allows running the full M365 provider including PowerShell checks, providing complete coverage of all available security checks. -### Service Principal + User Credentials Authentication Permissions - -When using service principal with user credentials authentication, you need **both** sets of permissions: - -**1. Service Principal Application Permissions**: - -- All the Microsoft Graph API permissions listed above are required. -- External API permissions listed above are **not needed**. - -**2. User-Level Permissions**: These are set at the `M365_USER` level, so the user used to run Prowler must have one of the following roles: - -- `Global Reader` (recommended): Allows reading all required information. -- `Exchange Administrator` and `Teams Administrator`: User needs both roles for the same access as Global Reader. - ### Browser Authentication Permissions When using browser authentication, permissions are delegated to the user, so the user must have the appropriate permissions rather than the application. @@ -144,30 +125,6 @@ When using browser authentication, permissions are delegated to the user, so the ![Grant Admin Consent](../microsoft365/img/grant-external-api-permissions.png) -#### Assign User Roles (For User Authentication) - -When using Service Principal with User Credentials authentication, assign the following roles to the user: - -1. Go to Users > All Users > Click on the email for the user - - ![User Overview](../microsoft365/img/user-info-page.png) - -2. Click "Assigned Roles" - - ![User Roles](../microsoft365/img/user-role-page.png) - -3. Click "Add assignments", then search and select: - - - `Global Reader` (recommended) - - OR `Exchange Administrator` and `Teams Administrator` (both required) - - ![Global Reader Screenshots](../microsoft365/img/global-reader.png) - -4. Click next, assign the role as "Active", and click "Assign" - - ![Grant Admin Consent for Role](../microsoft365/img/grant-admin-consent-for-role.png) - ---- ## Service Principal Authentication (Recommended) @@ -192,48 +149,6 @@ If the external API permissions described in the mentioned section above are not ???+ note In order to scan all the checks from M365 required permissions to the service principal application must be added. Refer to the [PowerShell Module Permissions](#grant-powershell-module-permissions-for-service-principal-authentication) section for more information. -## Service Principal and User Credentials Authentication - -*Available for both Prowler App and Prowler CLI* - -**Authentication flag for CLI:** `--env-auth` - -???+ warning - This method is not recommended and will be deprecated in October 2025. Use the **Service Principal Application** authentication method instead. - -This method builds upon Service Principal authentication by adding User Credentials. Configure the following environment variables: `M365_USER` and `M365_PASSWORD`. - -```console -export AZURE_CLIENT_ID="XXXXXXXXX" -export AZURE_CLIENT_SECRET="XXXXXXXXX" -export AZURE_TENANT_ID="XXXXXXXXX" -export M365_USER="your_email@example.com" -export M365_PASSWORD="examplepassword" -``` - -These two new environment variables are **required** in this authentication method to execute the PowerShell modules needed to retrieve information from M365 services. Prowler uses Service Principal authentication to access Microsoft Graph and user credentials to authenticate to Microsoft PowerShell modules. - -- `M365_USER` should be your Microsoft account email using the **assigned domain in the tenant**. This means it must look like `example@YourCompany.onmicrosoft.com` or `example@YourCompany.com`, but it must be the exact domain assigned to that user in the tenant. - - ???+ warning - Newly created users must sign in with the account first, as Microsoft prompts for password change. Without completing this step, user authentication fails because Microsoft marks the initial password as expired. - - ???+ warning - The user must not be MFA capable. Microsoft does not allow MFA capable users to authenticate programmatically. See [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity-platform/scenario-desktop-acquire-token-username-password?tabs=dotnet) for more information. - - ???+ warning - Using a tenant domain other than the one assigned — even if it belongs to the same tenant — will cause Prowler to fail, as Microsoft authentication will not succeed. - - Ensure the correct domain is used for the authenticating user. - - ![User Domains](img/user-domains.png) - -- `M365_PASSWORD` must be the user password. - - ???+ note - Previously an encrypted password was required, but now the user password is accepted directly. Prowler handles the password encryption. - - ## Interactive Browser Authentication @@ -471,7 +386,7 @@ The required modules are automatically installed when running Prowler with the ` Example command: ```console -python3 prowler-cli.py m365 --verbose --log-level ERROR --env-auth --init-modules +python3 prowler-cli.py m365 --verbose --log-level ERROR --sp-env-auth --init-modules ``` If the modules are already installed, running this command will not cause issues—it will simply verify that the necessary modules are available. diff --git a/docs/tutorials/microsoft365/getting-started-m365.md b/docs/tutorials/microsoft365/getting-started-m365.md index 27fbe901c4..8be16e755e 100644 --- a/docs/tutorials/microsoft365/getting-started-m365.md +++ b/docs/tutorials/microsoft365/getting-started-m365.md @@ -55,11 +55,6 @@ Configure authentication for Microsoft 365 by following the [Microsoft 365 Authe - Tenant ID - `AZURE_CLIENT_SECRET` from the Service Principal setup - If using user authentication, also add: - - - `M365_USER` (email using the assigned domain in tenant) - - `M365_PASSWORD` (user password) - ![Prowler Cloud M365 Credentials](./img/m365-credentials.png) 3. Click "Next" @@ -85,7 +80,6 @@ PowerShell 7.4+ is required for comprehensive Microsoft 365 security coverage. I Select an authentication method from the [Microsoft 365 Authentication](authentication.md) guide: - **Service Principal Application** (recommended): `--sp-env-auth` -- **Service Principal with User Credentials**: `--env-auth` - **Interactive Browser Authentication**: `--browser-auth` ### Basic Usage diff --git a/docs/tutorials/prowler-app.md b/docs/tutorials/prowler-app.md index c9675a88f1..0b5e2373a3 100644 --- a/docs/tutorials/prowler-app.md +++ b/docs/tutorials/prowler-app.md @@ -194,10 +194,10 @@ If you are adding an **EKS**, **GKE**, **AKS** or external cluster, follow these For M365, you must enter your Domain ID and choose the authentication method you want to use: - Service Principal Authentication (Recommended) -- User Authentication (only works if the user does not have MFA enabled) +- User Authentication (Deprecated) -???+ note - User authentication with M365_USER and M365_PASSWORD is optional and will only work if the account does not enforce MFA. +???+ warning + User authentication with M365_USER and M365_PASSWORD is deprecated and will be removed. For full setup instructions and requirements, check the [Microsoft 365 provider requirements](./microsoft365/getting-started-m365.md). diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 8f6f548e45..c5f899e656 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update AWS Athena service metadata to new format [(#8790)](https://github.com/prowler-cloud/prowler/pull/8790) - Update AWS CloudFormation service metadata to new format [(#8828)](https://github.com/prowler-cloud/prowler/pull/8828) - Update AWS Lambda service metadata to new format [(#8825)](https://github.com/prowler-cloud/prowler/pull/8825) +- Deprecate user authentication for M365 provider [(#8865)](https://github.com/prowler-cloud/prowler/pull/8865) ### Fixed - Fix SNS topics showing empty AWS_ResourceID in Quick Inventory output [(#8762)](https://github.com/prowler-cloud/prowler/issues/8762) diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 4440257754..e71808abcc 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -220,7 +220,6 @@ class Provider(ABC): config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, sp_env_auth=arguments.sp_env_auth, - env_auth=arguments.env_auth, az_cli_auth=arguments.az_cli_auth, browser_auth=arguments.browser_auth, certificate_auth=arguments.certificate_auth, diff --git a/prowler/providers/m365/exceptions/exceptions.py b/prowler/providers/m365/exceptions/exceptions.py index 871607aeff..444b9df1a5 100644 --- a/prowler/providers/m365/exceptions/exceptions.py +++ b/prowler/providers/m365/exceptions/exceptions.py @@ -94,26 +94,6 @@ class M365BaseException(ProwlerException): "message": "Tenant Id is required for Microsoft 365 static credentials. Make sure you are using the correct credentials.", "remediation": "Check the Microsoft 365 Tenant ID and ensure it is properly set up.", }, - (6022, "M365MissingEnvironmentCredentialsError"): { - "message": "User and Password environment variables are needed to use Credentials authentication method.", - "remediation": "Ensure your environment variables are properly set up.", - }, - (6023, "M365UserCredentialsError"): { - "message": "The provided User credentials are not valid.", - "remediation": "Check the User credentials and ensure they are valid.", - }, - (6024, "M365NotValidUserError"): { - "message": "The provided User is not valid.", - "remediation": "Check the User and ensure it is a valid user.", - }, - (6025, "M365NotValidPasswordError"): { - "message": "The provided Password is not valid.", - "remediation": "Check the Password and ensure it is a valid password.", - }, - (6026, "M365UserNotBelongingToTenantError"): { - "message": "The provided User does not belong to the specified tenant.", - "remediation": "Check the User email domain and ensure it belongs to the specified tenant.", - }, (6027, "M365GraphConnectionError"): { "message": "Failed to establish connection to Microsoft Graph API.", "remediation": "Check your Microsoft Application credentials and ensure the app has proper permissions.", @@ -315,41 +295,6 @@ class M365NotTenantIdButClientIdAndClientSecretError(M365CredentialsError): ) -class M365MissingEnvironmentCredentialsError(M365CredentialsError): - def __init__(self, file=None, original_exception=None, message=None): - super().__init__( - 6022, file=file, original_exception=original_exception, message=message - ) - - -class M365UserCredentialsError(M365CredentialsError): - def __init__(self, file=None, original_exception=None, message=None): - super().__init__( - 6023, file=file, original_exception=original_exception, message=message - ) - - -class M365NotValidUserError(M365CredentialsError): - def __init__(self, file=None, original_exception=None, message=None): - super().__init__( - 6024, file=file, original_exception=original_exception, message=message - ) - - -class M365NotValidPasswordError(M365CredentialsError): - def __init__(self, file=None, original_exception=None, message=None): - super().__init__( - 6025, file=file, original_exception=original_exception, message=message - ) - - -class M365UserNotBelongingToTenantError(M365CredentialsError): - def __init__(self, file=None, original_exception=None, message=None): - super().__init__( - 6026, file=file, original_exception=original_exception, message=message - ) - - class M365GraphConnectionError(M365CredentialsError): def __init__(self, file=None, original_exception=None, message=None): super().__init__( diff --git a/prowler/providers/m365/lib/arguments/arguments.py b/prowler/providers/m365/lib/arguments/arguments.py index fffbc3e5bb..ada11abd8e 100644 --- a/prowler/providers/m365/lib/arguments/arguments.py +++ b/prowler/providers/m365/lib/arguments/arguments.py @@ -1,3 +1,6 @@ +import argparse + + def init_parser(self): """Init the M365 Provider CLI parser""" m365_parser = self.subparsers.add_parser( @@ -13,15 +16,16 @@ def init_parser(self): action="store_true", help="Use Azure CLI authentication to log in against Microsoft 365", ) - m365_auth_modes_group.add_argument( - "--env-auth", - action="store_true", - help="Use User and Password environment variables authentication to log in against Microsoft 365", - ) m365_auth_modes_group.add_argument( "--sp-env-auth", action="store_true", - help="Use Azure Service Principal environment variables authentication to log in against Microsoft 365", + help="Use Service Principal environment variables authentication to log in against Microsoft 365", + ) + m365_auth_modes_group.add_argument( + "--env-auth", + dest="sp_env_auth", + action="store_true", + help=argparse.SUPPRESS, ) m365_auth_modes_group.add_argument( "--browser-auth", diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index be36964b11..ef3c72cc71 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -1,13 +1,10 @@ import os -import platform from prowler.lib.logger import logger from prowler.lib.powershell.powershell import PowerShellSession from prowler.providers.m365.exceptions.exceptions import ( M365CertificateCreationError, M365GraphConnectionError, - M365UserCredentialsError, - M365UserNotBelongingToTenantError, ) from prowler.providers.m365.lib.jwt.jwt_decoder import decode_jwt, decode_msal_token from prowler.providers.m365.models import M365Credentials, M365IdentityInfo @@ -76,10 +73,9 @@ class M365PowerShell(PowerShellSession): """ Initialize PowerShell credential object for Microsoft 365 authentication. - Supports three authentication methods: - 1. User authentication (username/password) - Will be deprecated in October 2025 - 2. Application authentication (client_id/client_secret) - 3. Certificate authentication (certificate_content in base64/application_id) + Supports two authentication methods: + 1. Application authentication (client_id/client_secret) + 2. Certificate authentication (certificate_content in base64/client_id) Args: credentials (M365Credentials): The credentials object containing @@ -115,22 +111,6 @@ class M365PowerShell(PowerShellSession): self.execute(f'$tenantID = "{sanitized_tenant_id}"') self.execute(f'$tenantDomain = "{credentials.tenant_domains[0]}"') - # User Auth (Will be deprecated in October 2025) - elif credentials.user and credentials.passwd: - credentials.encrypted_passwd = self.encrypt_password(credentials.passwd) - - # Sanitize user and password - sanitized_user = self.sanitize(credentials.user) - sanitized_encrypted_passwd = self.sanitize(credentials.encrypted_passwd) - - # Securely convert encrypted password to SecureString - self.execute(f'$user = "{sanitized_user}"') - self.execute( - f'$secureString = "{sanitized_encrypted_passwd}" | ConvertTo-SecureString' - ) - self.execute( - "$credential = New-Object System.Management.Automation.PSCredential ($user, $secureString)" - ) else: # Application Auth self.execute(f'$clientID = "{credentials.client_id}"') @@ -143,51 +123,13 @@ class M365PowerShell(PowerShellSession): '$graphToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $graphtokenBody | Select-Object -ExpandProperty Access_Token' ) - def encrypt_password(self, password: str) -> str: - """ - Encrypts a password using Windows CryptProtectData on Windows systems - or UTF-16LE encoding on other systems. - - Args: - password (str): The password to encrypt - - Returns: - str: The encrypted password in hexadecimal format - - Raises: - ValueError: If password is None or empty - """ - try: - if platform.system() == "Windows": - import win32crypt - - encrypted_blob = win32crypt.CryptProtectData( - password.encode("utf-16le"), None, None, None, None, 0 - ) - - encrypted_bytes = encrypted_blob - if isinstance(encrypted_blob, tuple): - encrypted_bytes = encrypted_blob[1] - elif hasattr(encrypted_blob, "data"): - encrypted_bytes = encrypted_blob.data - - return encrypted_bytes.hex() - - else: - return password.encode("utf-16le").hex() - except Exception as error: - raise Exception( - f"[{os.path.basename(__file__)}] Error encrypting password: {str(error)}" - ) - def test_credentials(self, credentials: M365Credentials) -> bool: """ Test Microsoft 365 credentials by attempting to authenticate against Entra ID. - Supports testing three authentication methods: - 1. User authentication (username/password) - 2. Application authentication (client_id/client_secret) - 3. Certificate authentication (certificate_content in base64/application_id) + Supports testing two authentication methods: + 1. Application authentication (client_id/client_secret) + 2. Certificate authentication (certificate_content in base64/client_id) Args: credentials (M365Credentials): The credentials object containing @@ -204,50 +146,6 @@ class M365PowerShell(PowerShellSession): except Exception as e: logger.error(f"Exchange Online Certificate connection failed: {e}") - # Test User Auth - elif credentials.user and credentials.passwd: - self.execute( - f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' # encrypted password already sanitized - ) - self.execute( - f'$credential = New-Object System.Management.Automation.PSCredential("{self.sanitize(credentials.user)}", $securePassword)' - ) - - user_domain = credentials.user.split("@")[1] - if not any( - user_domain.endswith(domain) - for domain in self.tenant_identity.tenant_domains - ): - raise M365UserNotBelongingToTenantError( - file=os.path.basename(__file__), - message=f"The user domain {user_domain} does not match any of the tenant domains: {', '.join(self.tenant_identity.tenant_domains)}", - ) - - # Validate credentials - # Test Exchange Online connection - result = self.execute("Connect-ExchangeOnline -Credential $credential") - if "https://aka.ms/exov3-module" not in result: - if "AADSTS" in result: # Entra Security Token Service Error - raise M365UserCredentialsError( - file=os.path.basename(__file__), - message=result, - ) - # Test Microsoft Teams connection - result = self.execute("Connect-MicrosoftTeams -Credential $credential") - if self.tenant_identity.user not in result: - if "AADSTS" in result: # Entra Security Token Service Error - raise M365UserCredentialsError( - file=os.path.basename(__file__), - message=result, - ) - else: # Unknown error, could be a permission issue or modules not installed - raise M365UserCredentialsError( - file=os.path.basename(__file__), - message=f"Error connecting to PowerShell modules: {result if result else 'Unknown error'}", - ) - - return True - else: # Test Microsoft Graph connection try: @@ -317,21 +215,6 @@ class M365PowerShell(PowerShellSession): return False return True - def test_teams_user_connection(self) -> bool: - """Test Microsoft Teams API connection using user authentication and raise exception if it fails.""" - result = self.execute("Connect-MicrosoftTeams -Credential $credential") - if self.tenant_identity.user not in result: - logger.error(f"Microsoft Teams User Auth connection failed: {result}.") - return False - - connection = self.execute("Get-CsTeamsClientConfiguration") - if not connection: - logger.error( - "Microsoft Teams User Auth connection failed: Please check your permissions and try again." - ) - return False - return True - def test_exchange_connection(self) -> bool: """Test Exchange Online API connection and raise exception if it fails.""" try: @@ -368,30 +251,14 @@ class M365PowerShell(PowerShellSession): return False return True - def test_exchange_user_connection(self) -> bool: - """Test Exchange Online API connection using user authentication and raise exception if it fails.""" - result = self.execute("Connect-ExchangeOnline -Credential $credential") - if "https://aka.ms/exov3-module" not in result: - logger.error(f"Exchange Online User Auth connection failed: {result}.") - return False - - connection = self.execute("Get-OrganizationConfig") - if not connection: - logger.error( - "Exchange Online User Auth connection failed: Please check your permissions and try again." - ) - return False - return True - def connect_microsoft_teams(self) -> dict: """ Connect to Microsoft Teams Module PowerShell Module. Establishes a connection to Microsoft Teams using the initialized credentials. - Supports three authentication methods: - 1. User authentication (username/password) - 2. Application authentication (client_id/client_secret) - 3. Certificate authentication (certificate_content in base64/application_id) + Supports two authentication methods: + 1. Application authentication (client_id/client_secret) + 2. Certificate authentication (certificate_content in base64/client_id) Returns: dict: Connection status information in JSON format. @@ -402,12 +269,8 @@ class M365PowerShell(PowerShellSession): # Certificate Auth if self.execute("Write-Output $certificate") != "": return self.test_teams_certificate_connection() - # User Auth - if self.execute("Write-Output $credential") != "": - return self.test_teams_user_connection() # Application Auth - else: - return self.test_teams_connection() + return self.test_teams_connection() def get_teams_settings(self) -> dict: """ @@ -494,10 +357,9 @@ class M365PowerShell(PowerShellSession): Connect to Exchange Online PowerShell Module. Establishes a connection to Exchange Online using the initialized credentials. - Supports three authentication methods: - 1. User authentication (username/password) - 2. Application authentication (client_id/client_secret) - 3. Certificate authentication (certificate_content in base64/application_id) + Supports two authentication methods: + 1. Application authentication (client_id/client_secret) + 2. Certificate authentication (certificate_content in base64/client_id) Returns: dict: Connection status information in JSON format. @@ -508,12 +370,8 @@ class M365PowerShell(PowerShellSession): # Certificate Auth if self.execute("Write-Output $certificate") != "": return self.test_exchange_certificate_connection() - # User Auth - if self.execute("Write-Output $credential") != "": - return self.test_exchange_user_connection() # Application Auth - else: - return self.test_exchange_connection() + return self.test_exchange_connection() def get_audit_log_config(self) -> dict: """ diff --git a/prowler/providers/m365/m365_provider.py b/prowler/providers/m365/m365_provider.py index 4be03f5a70..801b02027f 100644 --- a/prowler/providers/m365/m365_provider.py +++ b/prowler/providers/m365/m365_provider.py @@ -40,7 +40,6 @@ from prowler.providers.m365.exceptions.exceptions import ( M365HTTPResponseError, M365InteractiveBrowserCredentialError, M365InvalidProviderIdError, - M365MissingEnvironmentCredentialsError, M365NoAuthenticationMethodError, M365NotTenantIdButClientIdAndClientSecretError, M365NotValidCertificateContentError, @@ -52,7 +51,6 @@ from prowler.providers.m365.exceptions.exceptions import ( M365SetUpSessionError, M365TenantIdAndClientIdNotBelongingToClientSecretError, M365TenantIdAndClientSecretNotBelongingToClientIdError, - M365UserCredentialsError, ) from prowler.providers.m365.lib.mutelist.mutelist import M365Mutelist from prowler.providers.m365.lib.powershell.m365_powershell import ( @@ -96,7 +94,7 @@ class M365Provider(Provider): mutelist(self) -> M365Mutelist: Returns the mutelist object associated with the M365 provider. setup_region_config(cls, region): Sets up the region configuration for the M365 provider. print_credentials(self): Prints the M365 credentials information. - setup_session(cls, az_cli_auth, app_env_auth, browser_auth, managed_identity_auth, tenant_id, region_config): Set up the M365 session with the specified authentication method. + setup_session(cls, az_cli_auth, sp_env_auth, browser_auth, managed_identity_auth, tenant_id, region_config): Set up the M365 session with the specified authentication method. """ _type: str = "m365" @@ -109,10 +107,12 @@ class M365Provider(Provider): # TODO: this is not optional, enforce for all providers audit_metadata: Audit_Metadata + # TODO: The user and password parameters are deprecated and will be removed in a future version. + # They are currently only kept for backwards compatibility with the API. + # Use client credentials or certificate authentication instead. def __init__( self, sp_env_auth: bool = False, - env_auth: bool = False, az_cli_auth: bool = False, browser_auth: bool = False, certificate_auth: bool = False, @@ -163,14 +163,11 @@ class M365Provider(Provider): self.validate_arguments( az_cli_auth, sp_env_auth, - env_auth, browser_auth, certificate_auth, tenant_id, client_id, client_secret, - user, - password, certificate_content, certificate_path, ) @@ -185,8 +182,6 @@ class M365Provider(Provider): tenant_id=tenant_id, client_id=client_id, client_secret=client_secret, - user=user, - password=password, certificate_content=certificate_content, certificate_path=certificate_path, ) @@ -195,7 +190,6 @@ class M365Provider(Provider): self._session = self.setup_session( az_cli_auth, sp_env_auth, - env_auth, browser_auth, certificate_auth, certificate_path, @@ -207,7 +201,6 @@ class M365Provider(Provider): # Set up the identity self._identity = self.setup_identity( sp_env_auth, - env_auth, browser_auth, az_cli_auth, certificate_auth, @@ -216,7 +209,6 @@ class M365Provider(Provider): # Set up PowerShell session credentials self._credentials = self.setup_powershell( - env_auth=env_auth, sp_env_auth=sp_env_auth, certificate_auth=certificate_auth, certificate_path=certificate_path, @@ -294,14 +286,11 @@ class M365Provider(Provider): def validate_arguments( az_cli_auth: bool, sp_env_auth: bool, - env_auth: bool, browser_auth: bool, certificate_auth: bool, tenant_id: str, client_id: str, client_secret: str, - user: str, - password: str, certificate_content: str, certificate_path: str, ): @@ -311,14 +300,11 @@ class M365Provider(Provider): Args: az_cli_auth (bool): Flag indicating whether Azure CLI authentication is enabled. sp_env_auth (bool): Flag indicating whether application authentication with environment variables is enabled. - env_auth: (bool): Flag indicating whether to use application and PowerShell authentication with environment variables. browser_auth (bool): Flag indicating whether browser authentication is enabled. certificate_auth (bool): Flag indicating whether certificate authentication is enabled. tenant_id (str): The M365 Tenant ID. client_id (str): The M365 Client ID. client_secret (str): The M365 Client Secret. - user (str): The M365 User Account. - password (str): The M365 User Password. certificate_content (str): The M365 Certificate Content. certificate_path (str): The path to the certificate file. @@ -327,7 +313,7 @@ class M365Provider(Provider): """ if not client_id and not client_secret: - if not browser_auth and tenant_id and not env_auth: + if not browser_auth and tenant_id: raise M365BrowserAuthNoFlagError( file=os.path.basename(__file__), message="M365 tenant ID error: browser authentication flag (--browser-auth) not found", @@ -336,12 +322,11 @@ class M365Provider(Provider): not az_cli_auth and not sp_env_auth and not browser_auth - and not env_auth and not certificate_auth ): raise M365NoAuthenticationMethodError( file=os.path.basename(__file__), - message="M365 provider requires at least one authentication method set: [--env-auth | --az-cli-auth | --sp-env-auth | --browser-auth | --certificate-auth]", + message="M365 provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --certificate-auth]", ) elif browser_auth and not tenant_id: raise M365BrowserAuthNoTenantIDError( @@ -354,12 +339,7 @@ class M365Provider(Provider): file=os.path.basename(__file__), message="Tenant Id is required for M365 static credentials. Make sure you are using the correct credentials.", ) - if ( - not certificate_content - and not certificate_path - and not (user and password) - and not client_secret - ): + if not certificate_content and not certificate_path and not client_secret: raise M365ConfigCredentialsError( file=os.path.basename(__file__), message="You must provide a valid set of credentials. Please check your credentials and try again.", @@ -404,7 +384,6 @@ class M365Provider(Provider): @staticmethod def setup_powershell( - env_auth: bool = False, sp_env_auth: bool = False, certificate_auth: bool = False, certificate_path: str = None, @@ -415,51 +394,22 @@ class M365Provider(Provider): """Gets the M365 credentials. Args: - env_auth: (bool): Flag indicating whether to use application and PowerShell authentication with environment variables. + sp_env_auth (bool): Flag indicating whether to use application authentication with environment variables. Returns: - M365Credentials: Object containing the user credentials. - If env_auth is True, retrieves from environment variables. - If False, returns empty credentials. + M365Credentials: Object containing the credentials for PowerShell operations. """ logger.info("M365 provider: Setting up PowerShell session...") credentials = None if m365_credentials: credentials = M365Credentials( - user=m365_credentials.get("user", None), - passwd=m365_credentials.get("password", None), client_id=m365_credentials.get("client_id", ""), client_secret=m365_credentials.get("client_secret", ""), tenant_id=m365_credentials.get("tenant_id", ""), certificate_content=m365_credentials.get("certificate_content", ""), tenant_domains=identity.tenant_domains, ) - elif env_auth: - m365_user = getenv("M365_USER") - m365_password = getenv("M365_PASSWORD") - client_id = getenv("AZURE_CLIENT_ID") - client_secret = getenv("AZURE_CLIENT_SECRET") - tenant_id = getenv("AZURE_TENANT_ID") - - if not m365_user or not m365_password: - logger.critical( - "M365 provider: Missing M365_USER or M365_PASSWORD environment variables needed for credentials authentication" - ) - raise M365MissingEnvironmentCredentialsError( - file=os.path.basename(__file__), - message="Missing M365_USER or M365_PASSWORD environment variables required for credentials authentication.", - ) - - credentials = M365Credentials( - client_id=client_id, - client_secret=client_secret, - tenant_id=tenant_id, - tenant_domains=identity.tenant_domains, - user=m365_user, - passwd=m365_password, - ) - elif sp_env_auth: client_id = getenv("AZURE_CLIENT_ID") client_secret = getenv("AZURE_CLIENT_SECRET") @@ -488,9 +438,6 @@ class M365Provider(Provider): ) if credentials: - if identity and credentials.user: - identity.user = credentials.user - identity.identity_type = "Service Principal and User Credentials" if identity and credentials.certificate_content: identity.identity_type = "Service Principal with Certificate" test_session = M365PowerShell(credentials, identity) @@ -499,9 +446,9 @@ class M365Provider(Provider): initialize_m365_powershell_modules() if test_session.test_credentials(credentials): return credentials - raise M365UserCredentialsError( + raise M365ConfigCredentialsError( file=os.path.basename(__file__), - message="The provided User credentials are not valid.", + message="The provided credentials are not valid.", ) finally: test_session.close() @@ -523,11 +470,7 @@ class M365Provider(Provider): f"M365 Tenant Domain: {Fore.YELLOW}{self._identity.tenant_domain}{Style.RESET_ALL} M365 Tenant ID: {Fore.YELLOW}{self._identity.tenant_id}{Style.RESET_ALL}", f"M365 Identity Type: {Fore.YELLOW}{self._identity.identity_type}{Style.RESET_ALL} M365 Identity ID: {Fore.YELLOW}{self._identity.identity_id}{Style.RESET_ALL}", ] - if self.credentials and self.credentials.user: - report_lines.append( - f"M365 User: {Fore.YELLOW}{self.credentials.user}{Style.RESET_ALL}" - ) - elif self.credentials and self.credentials.certificate_content: + if self.credentials and self.credentials.certificate_content: report_lines.append( f"M365 Certificate Thumbprint: {Fore.YELLOW}{self._identity.certificate_thumbprint}{Style.RESET_ALL}" ) @@ -542,7 +485,6 @@ class M365Provider(Provider): def setup_session( az_cli_auth: bool, sp_env_auth: bool, - env_auth: bool, browser_auth: bool, certificate_auth: bool, certificate_path: str, @@ -563,8 +505,6 @@ class M365Provider(Provider): - tenant_id: The M365 Active Directory tenant ID. - client_id: The M365 client ID. - client_secret: The M365 client secret - - user: The M365 user email - - password: The M365 user password - certificate_content: The M365 certificate content - certificate_path: The path to the certificate file. - provider_id: The M365 provider ID (in this case the Tenant ID). @@ -579,7 +519,7 @@ class M365Provider(Provider): """ logger.info("M365 provider: Setting up session...") if not browser_auth: - if sp_env_auth or env_auth: + if sp_env_auth: try: M365Provider.check_service_principal_creds_env_vars() except M365EnvironmentVariableError as environment_credentials_error: @@ -623,8 +563,6 @@ class M365Provider(Provider): tenant_id=m365_credentials["tenant_id"], client_id=m365_credentials["client_id"], client_secret=m365_credentials["client_secret"], - user=m365_credentials["user"], - password=m365_credentials["password"], ) return credentials except ClientAuthenticationError as error: @@ -675,7 +613,7 @@ class M365Provider(Provider): try: credentials = DefaultAzureCredential( exclude_environment_credential=not ( - sp_env_auth or env_auth or certificate_auth + sp_env_auth or certificate_auth ), exclude_cli_credential=not az_cli_auth, # M365 Auth using Managed Identity is not supported @@ -738,7 +676,6 @@ class M365Provider(Provider): def test_connection( az_cli_auth: bool = False, sp_env_auth: bool = False, - env_auth: bool = False, browser_auth: bool = False, certificate_auth: bool = False, tenant_id: str = None, @@ -746,8 +683,6 @@ class M365Provider(Provider): raise_on_exception: bool = True, client_id: str = None, client_secret: str = None, - user: str = None, - password: str = None, certificate_content: str = None, certificate_path: str = None, provider_id: str = None, @@ -760,7 +695,6 @@ class M365Provider(Provider): az_cli_auth (bool): Flag indicating whether to use Azure CLI authentication. sp_env_auth (bool): Flag indicating whether to use application authentication with environment variables. - env_auth: (bool): Flag indicating whether to use application and PowerShell authentication with environment variables. browser_auth (bool): Flag indicating whether to use interactive browser authentication. certificate_auth (bool): Flag indicating whether to use certificate authentication. tenant_id (str): The M365 Active Directory tenant ID. @@ -768,8 +702,6 @@ class M365Provider(Provider): raise_on_exception (bool): Flag indicating whether to raise an exception if the connection fails. client_id (str): The M365 client ID. client_secret (str): The M365 client secret. - user (str): The M365 user email. - password (str): The M365 password. provider_id (str): The M365 provider ID (in this case the Tenant ID). @@ -797,14 +729,11 @@ class M365Provider(Provider): M365Provider.validate_arguments( az_cli_auth, sp_env_auth, - env_auth, browser_auth, certificate_auth, tenant_id, client_id, client_secret, - user, - password, certificate_content, certificate_path, ) @@ -813,20 +742,11 @@ class M365Provider(Provider): # Get the dict from the static credentials m365_credentials = None if tenant_id and client_id and client_secret: - if not user and not password: - m365_credentials = M365Provider.validate_static_credentials( - tenant_id=tenant_id, - client_id=client_id, - client_secret=client_secret, - ) - else: - m365_credentials = M365Provider.validate_static_credentials( - tenant_id=tenant_id, - client_id=client_id, - client_secret=client_secret, - user=user, - password=password, - ) + m365_credentials = M365Provider.validate_static_credentials( + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + ) elif tenant_id and client_id and certificate_content: m365_credentials = M365Provider.validate_static_credentials( tenant_id=tenant_id, @@ -838,7 +758,6 @@ class M365Provider(Provider): session = M365Provider.setup_session( az_cli_auth, sp_env_auth, - env_auth, browser_auth, certificate_auth, certificate_path, @@ -854,7 +773,6 @@ class M365Provider(Provider): # Set up Identity identity = M365Provider.setup_identity( sp_env_auth, - env_auth, browser_auth, az_cli_auth, certificate_auth, @@ -877,7 +795,6 @@ class M365Provider(Provider): # Set up PowerShell credentials M365Provider.setup_powershell( - env_auth, sp_env_auth, certificate_auth, certificate_path, @@ -1046,7 +963,6 @@ class M365Provider(Provider): @staticmethod def setup_identity( sp_env_auth, - env_auth, browser_auth, az_cli_auth, certificate_auth, @@ -1058,7 +974,6 @@ class M365Provider(Provider): Args: az_cli_auth (bool): Flag indicating if Azure CLI authentication is used. sp_env_auth (bool): Flag indicating if application authentication with environment variables is used. - env_auth: (bool): Flag indicating whether to use application and PowerShell authentication with environment variables. browser_auth (bool): Flag indicating if interactive browser authentication is used. client_id (str): The M365 client ID. @@ -1116,13 +1031,6 @@ class M365Provider(Provider): or session.credentials[0]._credential.client_id or "Unknown user id (Missing AAD permissions)" ) - elif env_auth: - identity.identity_type = "Service Principal and User Credentials" - identity.identity_id = ( - getenv("AZURE_CLIENT_ID") - or session.credentials[0]._credential.client_id - or "Unknown user id (Missing AAD permissions)" - ) elif certificate_auth: identity.identity_type = "Service Principal with Certificate" identity.identity_id = ( @@ -1174,8 +1082,6 @@ class M365Provider(Provider): tenant_id: str = None, client_id: str = None, client_secret: str = None, - user: str = None, - password: str = None, certificate_content: str = None, certificate_path: str = None, ) -> dict: @@ -1186,8 +1092,6 @@ class M365Provider(Provider): tenant_id (str): The M365 Active Directory tenant ID. client_id (str): The M365 client ID. client_secret (str): The M365 client secret. - user (str): The M365 user email. - password (str): The M365 user password. certificate_content (str): The M365 Certificate Content. certificate_path (str): The path to the certificate file. @@ -1257,8 +1161,6 @@ class M365Provider(Provider): "tenant_id": tenant_id, "client_id": client_id, "client_secret": client_secret, - "user": user, - "password": password, "certificate_content": certificate_content, "certificate_path": certificate_path, } diff --git a/prowler/providers/m365/models.py b/prowler/providers/m365/models.py index 3459b31688..ef5e5b695b 100644 --- a/prowler/providers/m365/models.py +++ b/prowler/providers/m365/models.py @@ -25,9 +25,6 @@ class M365RegionConfig(BaseModel): class M365Credentials(BaseModel): - user: Optional[str] = None - passwd: Optional[str] = None - encrypted_passwd: Optional[str] = None client_id: str = "" client_secret: Optional[str] = None tenant_id: str = "" diff --git a/tests/providers/m365/lib/arguments/m365_arguments_test.py b/tests/providers/m365/lib/arguments/m365_arguments_test.py index 220ff40f8f..82fac31597 100644 --- a/tests/providers/m365/lib/arguments/m365_arguments_test.py +++ b/tests/providers/m365/lib/arguments/m365_arguments_test.py @@ -147,29 +147,6 @@ class TestM365Arguments: assert kwargs["action"] == "store_true" assert "Azure CLI authentication" in kwargs["help"] - def test_env_auth_argument_configuration(self): - """Test that env-auth argument is configured correctly""" - mock_m365_args = MagicMock() - mock_m365_args.subparsers = self.mock_subparsers - mock_m365_args.common_providers_parser = MagicMock() - - arguments.init_parser(mock_m365_args) - - # Find the env-auth argument call - calls = self.mock_auth_modes_group.add_argument.call_args_list - env_auth_call = None - for call in calls: - if call[0][0] == "--env-auth": - env_auth_call = call - break - - assert env_auth_call is not None - - # Check argument configuration - kwargs = env_auth_call[1] - assert kwargs["action"] == "store_true" - assert "User and Password environment variables" in kwargs["help"] - def test_sp_env_auth_argument_configuration(self): """Test that sp-env-auth argument is configured correctly""" mock_m365_args = MagicMock() @@ -191,7 +168,7 @@ class TestM365Arguments: # Check argument configuration kwargs = sp_env_call[1] assert kwargs["action"] == "store_true" - assert "Azure Service Principal environment variables" in kwargs["help"] + assert "Service Principal environment variables" in kwargs["help"] def test_browser_auth_argument_configuration(self): """Test that browser-auth argument is configured correctly""" @@ -336,28 +313,7 @@ class TestM365ArgumentsIntegration: args = parser.parse_args(["m365", "--az-cli-auth"]) assert args.az_cli_auth is True - assert args.env_auth is False - assert args.sp_env_auth is False - assert args.browser_auth is False - assert args.certificate_auth is False - - def test_real_argument_parsing_env_auth(self): - """Test parsing arguments with environment authentication""" - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers() - common_parser = argparse.ArgumentParser(add_help=False) - - mock_m365_args = MagicMock() - mock_m365_args.subparsers = subparsers - mock_m365_args.common_providers_parser = common_parser - - arguments.init_parser(mock_m365_args) - - # Parse arguments with environment auth - args = parser.parse_args(["m365", "--env-auth"]) - - assert args.az_cli_auth is False - assert args.env_auth is True + assert not hasattr(args, "env_auth") assert args.sp_env_auth is False assert args.browser_auth is False assert args.certificate_auth is False @@ -378,7 +334,7 @@ class TestM365ArgumentsIntegration: args = parser.parse_args(["m365", "--sp-env-auth"]) assert args.az_cli_auth is False - assert args.env_auth is False + assert not hasattr(args, "env_auth") assert args.sp_env_auth is True assert args.browser_auth is False assert args.certificate_auth is False @@ -406,7 +362,7 @@ class TestM365ArgumentsIntegration: ) assert args.az_cli_auth is False - assert args.env_auth is False + assert not hasattr(args, "env_auth") assert args.sp_env_auth is False assert args.browser_auth is True assert args.certificate_auth is False @@ -428,7 +384,7 @@ class TestM365ArgumentsIntegration: args = parser.parse_args(["m365", "--certificate-auth"]) assert args.az_cli_auth is False - assert args.env_auth is False + assert not hasattr(args, "env_auth") assert args.sp_env_auth is False assert args.browser_auth is False assert args.certificate_auth is True @@ -495,7 +451,7 @@ class TestM365ArgumentsIntegration: args = parser.parse_args(["m365"]) assert args.az_cli_auth is False - assert args.env_auth is False + assert not hasattr(args, "env_auth") assert args.sp_env_auth is False assert args.browser_auth is False assert args.certificate_auth is False @@ -547,7 +503,7 @@ class TestM365ArgumentsIntegration: # This should raise SystemExit due to mutually exclusive group try: - parser.parse_args(["m365", "--az-cli-auth", "--env-auth"]) + parser.parse_args(["m365", "--az-cli-auth", "--sp-env-auth"]) assert False, "Expected SystemExit due to mutually exclusive arguments" except SystemExit: # This is expected @@ -636,6 +592,6 @@ class TestM365ArgumentsIntegration: assert args.certificate_path == "/home/user/cert.pem" assert args.tenant_id == "12345678-1234-1234-1234-123456789012" assert args.az_cli_auth is False - assert args.env_auth is False + assert not hasattr(args, "env_auth") assert args.sp_env_auth is False assert args.browser_auth is False diff --git a/tests/providers/m365/lib/powershell/m365_powershell_test.py b/tests/providers/m365/lib/powershell/m365_powershell_test.py index bd45956de0..6f6a7e0b95 100644 --- a/tests/providers/m365/lib/powershell/m365_powershell_test.py +++ b/tests/providers/m365/lib/powershell/m365_powershell_test.py @@ -7,8 +7,6 @@ from prowler.lib.powershell.powershell import PowerShellSession from prowler.providers.m365.exceptions.exceptions import ( M365CertificateCreationError, M365GraphConnectionError, - M365UserCredentialsError, - M365UserNotBelongingToTenantError, ) from prowler.providers.m365.lib.powershell.m365_powershell import M365PowerShell from prowler.providers.m365.models import M365Credentials, M365IdentityInfo @@ -19,7 +17,11 @@ class Testm365PowerShell: def test_init(self, mock_popen): mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="User", @@ -40,7 +42,11 @@ class Testm365PowerShell: @patch("subprocess.Popen") def test_sanitize(self, _): - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="User", @@ -77,180 +83,50 @@ class Testm365PowerShell: mock_process = MagicMock() mock_popen.return_value = mock_process credentials = M365Credentials( - user="test@example.com", - passwd="test_password", - encrypted_passwd="test_password", client_id="test_client_id", client_secret="test_client_secret", tenant_id="test_tenant_id", ) identity = M365IdentityInfo( identity_id="test_id", - identity_type="User", + identity_type="Service Principal", tenant_id="test_tenant", tenant_domain="example.com", tenant_domains=["example.com"], location="test_location", ) - session = M365PowerShell(credentials, identity) + with patch.object(M365PowerShell, "init_credential") as mock_init: + session = M365PowerShell(credentials, identity) + mock_init.assert_called_once_with(credentials) - # Mock encrypt_password to return a known value - session.encrypt_password = MagicMock(return_value="encrypted_password") session.execute = MagicMock() - session.init_credential(credentials) + # Call original init_credential to verify application authentication setup + M365PowerShell.init_credential(session, credentials) - # Verify encrypt_password was called - session.encrypt_password.assert_any_call(credentials.passwd) - - # Verify execute was called with the correct commands - session.execute.assert_any_call(f'$user = "{credentials.user}"') + session.execute.assert_any_call('$clientID = "test_client_id"') + session.execute.assert_any_call('$clientSecret = "test_client_secret"') + session.execute.assert_any_call('$tenantID = "test_tenant_id"') session.execute.assert_any_call( - f'$secureString = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' + '$graphtokenBody = @{ Grant_Type = "client_credentials"; Scope = "https://graph.microsoft.com/.default"; Client_Id = $clientID; Client_Secret = $clientSecret }' ) session.execute.assert_any_call( - "$credential = New-Object System.Management.Automation.PSCredential ($user, $secureString)" + '$graphToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $graphtokenBody | Select-Object -ExpandProperty Access_Token' ) session.close() - @patch("subprocess.Popen") - def test_test_credentials_exchange_success(self, mock_popen): - mock_process = MagicMock() - mock_popen.return_value = mock_process - - credentials = M365Credentials( - user="test@contoso.onmicrosoft.com", - passwd="test_password", - encrypted_passwd="test_encrypted_password", - client_id="test_client_id", - client_secret="test_client_secret", - tenant_id="test_tenant_id", - ) - identity = M365IdentityInfo( - identity_id="test_id", - identity_type="User", - tenant_id="test_tenant", - tenant_domain="contoso.onmicrosoft.com", - tenant_domains=["contoso.onmicrosoft.com"], - location="test_location", - user="test@contoso.onmicrosoft.com", - ) - session = M365PowerShell(credentials, identity) - - # Mock encrypt_password to return a known value - session.encrypt_password = MagicMock(return_value="encrypted_password") - - # Mock execute to simulate successful Exchange connection - def mock_execute_side_effect(command): - if "Connect-ExchangeOnline" in command: - return "Connected successfully https://aka.ms/exov3-module" - return "" - - session.execute = MagicMock(side_effect=mock_execute_side_effect) - - # Execute the test - result = session.test_credentials(credentials) - assert result is True - - # Verify execute was called with the correct commands - session.execute.assert_any_call( - f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' - ) - session.execute.assert_any_call( - f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)' - ) - # Exchange connection should be tested - session.execute.assert_any_call( - "Connect-ExchangeOnline -Credential $credential" - ) - - # Verify Teams connection was NOT called (since Exchange succeeded) - teams_calls = [ - call - for call in session.execute.call_args_list - if "Connect-MicrosoftTeams" in str(call) - ] - assert ( - len(teams_calls) == 0 - ), "Teams connection should not be called when Exchange succeeds" - - session.close() - - @patch("subprocess.Popen") - def test_test_credentials_exchange_fail_teams_success(self, mock_popen): - mock_process = MagicMock() - mock_popen.return_value = mock_process - - credentials = M365Credentials( - user="test@contoso.onmicrosoft.com", - passwd="test_password", - encrypted_passwd="test_encrypted_password", - client_id="test_client_id", - client_secret="test_client_secret", - tenant_id="test_tenant_id", - ) - identity = M365IdentityInfo( - identity_id="test_id", - identity_type="User", - tenant_id="test_tenant", - tenant_domain="contoso.onmicrosoft.com", - tenant_domains=["contoso.onmicrosoft.com"], - location="test_location", - user="test@contoso.onmicrosoft.com", - ) - session = M365PowerShell(credentials, identity) - - # Mock encrypt_password to return a known value - session.encrypt_password = MagicMock(return_value="encrypted_password") - - # Mock execute to simulate Exchange fail and Teams success - def mock_execute_side_effect(command): - if "Connect-ExchangeOnline" in command: - return ( - "Connection failed" # No "https://aka.ms/exov3-module" in response - ) - elif "Connect-MicrosoftTeams" in command: - return "Connected successfully test@contoso.onmicrosoft.com" - return "" - - session.execute = MagicMock(side_effect=mock_execute_side_effect) - - # Execute the test - result = session.test_credentials(credentials) - assert result is True - - # Verify execute was called with the correct commands - session.execute.assert_any_call( - f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' - ) - session.execute.assert_any_call( - f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)' - ) - # Both Exchange and Teams connections should be tested - session.execute.assert_any_call( - "Connect-ExchangeOnline -Credential $credential" - ) - session.execute.assert_any_call( - "Connect-MicrosoftTeams -Credential $credential" - ) - - session.close() - @patch("subprocess.Popen") def test_test_credentials_application_auth(self, mock_popen): mock_process = MagicMock() mock_popen.return_value = mock_process credentials = M365Credentials( - user="", - passwd="", - encrypted_passwd="", client_id="test_client_id", client_secret="test_client_secret", tenant_id="test_tenant_id", ) identity = M365IdentityInfo( identity_id="test_id", - identity_type="Application", + identity_type="Service Principal", tenant_id="test_tenant", tenant_domain="contoso.onmicrosoft.com", tenant_domains=["contoso.onmicrosoft.com"], @@ -264,162 +140,13 @@ class Testm365PowerShell: session.execute.assert_any_call("Write-Output $graphToken") session.close() - @patch("subprocess.Popen") - @patch("msal.ConfidentialClientApplication") - def test_test_credentials_user_not_belonging_to_tenant(self, mock_msal, mock_popen): - mock_process = MagicMock() - mock_popen.return_value = mock_process - mock_msal_instance = MagicMock() - mock_msal.return_value = mock_msal_instance - mock_msal_instance.acquire_token_by_username_password.return_value = { - "access_token": "test_token" - } - - credentials = M365Credentials( - user="user@otherdomain.com", - passwd="test_password", - client_id="test_client_id", - client_secret="test_client_secret", - tenant_id="test_tenant_id", - ) - identity = M365IdentityInfo( - identity_id="test_id", - identity_type="User", - tenant_id="test_tenant", - tenant_domain="contoso.onmicrosoft.com", - tenant_domains=["contoso.onmicrosoft.com"], - location="test_location", - ) - session = M365PowerShell(credentials, identity) - - # Mock the execute method to return the decrypted password - def mock_execute(command, *args, **kwargs): - if "Write-Output" in command: - return "decrypted_password" - return None - - session.execute = MagicMock(side_effect=mock_execute) - session.process.stdin.write = MagicMock() - session.read_output = MagicMock(return_value="decrypted_password") - - with pytest.raises(M365UserNotBelongingToTenantError) as exception: - session.test_credentials(credentials) - - assert exception.type == M365UserNotBelongingToTenantError - assert ( - "The user domain otherdomain.com does not match any of the tenant domains: contoso.onmicrosoft.com" - in str(exception.value) - ) - - # Verify MSAL was not called since domain validation failed first - mock_msal.assert_not_called() - mock_msal_instance.acquire_token_by_username_password.assert_not_called() - - session.close() - - @patch("subprocess.Popen") - def test_test_credentials_auth_failure_aadsts_error(self, mock_popen): - mock_process = MagicMock() - mock_popen.return_value = mock_process - - credentials = M365Credentials( - user="test@contoso.onmicrosoft.com", - passwd="test_password", - encrypted_passwd="test_encrypted_password", - client_id="test_client_id", - client_secret="test_client_secret", - tenant_id="test_tenant_id", - ) - identity = M365IdentityInfo( - identity_id="test_id", - identity_type="User", - tenant_id="test_tenant", - tenant_domain="contoso.onmicrosoft.com", - tenant_domains=["contoso.onmicrosoft.com"], - location="test_location", - ) - session = M365PowerShell(credentials, identity) - - # Mock encrypt_password and execute to simulate AADSTS error - session.encrypt_password = MagicMock(return_value="encrypted_password") - session.execute = MagicMock( - return_value="AADSTS50126: Error validating credentials due to invalid username or password" - ) - - with pytest.raises(M365UserCredentialsError) as exc_info: - session.test_credentials(credentials) - - assert ( - "AADSTS50126: Error validating credentials due to invalid username or password" - in str(exc_info.value) - ) - - # Verify execute was called with the correct commands - session.execute.assert_any_call( - f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' - ) - session.execute.assert_any_call( - f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)' - ) - session.execute.assert_any_call( - "Connect-ExchangeOnline -Credential $credential" - ) - - session.close() - - @patch("subprocess.Popen") - def test_test_credentials_auth_failure_no_access_token(self, mock_popen): - mock_process = MagicMock() - mock_popen.return_value = mock_process - - credentials = M365Credentials( - user="test@contoso.onmicrosoft.com", - passwd="test_password", - encrypted_passwd="test_encrypted_password", - client_id="test_client_id", - client_secret="test_client_secret", - tenant_id="test_tenant_id", - ) - identity = M365IdentityInfo( - identity_id="test_id", - identity_type="User", - tenant_id="test_tenant", - tenant_domain="contoso.onmicrosoft.com", - tenant_domains=["contoso.onmicrosoft.com"], - location="test_location", - ) - session = M365PowerShell(credentials, identity) - - # Mock encrypt_password and execute to simulate AADSTS invalid grant error - session.encrypt_password = MagicMock(return_value="encrypted_password") - session.execute = MagicMock( - return_value="AADSTS70002: The request body must contain the following parameter: 'client_secret' or 'client_assertion'." - ) - - with pytest.raises(M365UserCredentialsError) as exc_info: - session.test_credentials(credentials) - - assert ( - "AADSTS70002: The request body must contain the following parameter: 'client_secret' or 'client_assertion'." - in str(exc_info.value) - ) - - # Verify execute was called with the correct commands - session.execute.assert_any_call( - f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' - ) - session.execute.assert_any_call( - f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)' - ) - session.execute.assert_any_call( - "Connect-ExchangeOnline -Credential $credential" - ) - - session.close() - @patch("subprocess.Popen") def test_remove_ansi(self, mock_popen): - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="User", @@ -446,7 +173,11 @@ class Testm365PowerShell: def test_execute(self, mock_popen): mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="User", @@ -469,7 +200,11 @@ class Testm365PowerShell: """Test the read_output method with various scenarios""" mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="User", @@ -516,7 +251,11 @@ class Testm365PowerShell: def test_json_parse_output(self, mock_popen): mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="User", @@ -549,7 +288,11 @@ class Testm365PowerShell: def test_close(self, mock_popen): mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="User", @@ -718,7 +461,11 @@ class Testm365PowerShell: """Test test_graph_connection when token is valid""" mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="Application", @@ -743,7 +490,11 @@ class Testm365PowerShell: """Test test_graph_connection when token is empty""" mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="Application", @@ -769,7 +520,11 @@ class Testm365PowerShell: """Test test_graph_connection when an exception occurs""" mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="Application", @@ -797,7 +552,11 @@ class Testm365PowerShell: """Test test_teams_connection when token is valid""" mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="Application", @@ -835,7 +594,11 @@ class Testm365PowerShell: """Test test_teams_connection when token lacks required permissions""" mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="Application", @@ -870,7 +633,11 @@ class Testm365PowerShell: """Test test_teams_connection when an exception occurs""" mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="Application", @@ -899,7 +666,11 @@ class Testm365PowerShell: """Test test_exchange_connection when token is valid""" mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="Application", @@ -937,7 +708,11 @@ class Testm365PowerShell: """Test test_exchange_connection when token lacks required permissions""" mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="Application", @@ -972,7 +747,11 @@ class Testm365PowerShell: """Test test_exchange_connection when an exception occurs""" mock_process = MagicMock() mock_popen.return_value = mock_process - credentials = M365Credentials(user="test@example.com", passwd="test_password") + credentials = M365Credentials( + client_id="test_client_id", + client_secret="test_client_secret", + tenant_id="test_tenant_id", + ) identity = M365IdentityInfo( identity_id="test_id", identity_type="Application", @@ -995,58 +774,6 @@ class Testm365PowerShell: ) session.close() - @patch("subprocess.Popen") - def test_encrypt_password(self, mock_popen): - credentials = M365Credentials(user="test@example.com", passwd="test_password") - identity = M365IdentityInfo( - identity_id="test_id", - identity_type="User", - tenant_id="test_tenant", - tenant_domain="example.com", - tenant_domains=["example.com"], - location="test_location", - ) - session = M365PowerShell(credentials, identity) - - # Test non-Windows system (should use utf-16le hex encoding) - from unittest import mock - - with mock.patch("platform.system", return_value="Linux"): - result = session.encrypt_password("password123") - expected = "password123".encode("utf-16le").hex() - assert result == expected - - # Test Windows system with tuple return - with mock.patch("platform.system", return_value="Windows"): - import sys - - win32crypt_mock = mock.MagicMock() - win32crypt_mock.CryptProtectData.return_value = (None, b"encrypted_bytes") - sys.modules["win32crypt"] = win32crypt_mock - - result = session.encrypt_password("password123") - assert result == b"encrypted_bytes".hex() - - # Clean up mock - del sys.modules["win32crypt"] - - # Test error handling - with mock.patch("platform.system", return_value="Windows"): - import sys - - win32crypt_mock = mock.MagicMock() - win32crypt_mock.CryptProtectData.side_effect = Exception("Test error") - sys.modules["win32crypt"] = win32crypt_mock - - with pytest.raises(Exception) as exc_info: - session.encrypt_password("password123") - assert "Error encrypting password: Test error" in str(exc_info.value) - - # Clean up mock - del sys.modules["win32crypt"] - - session.close() - @patch("subprocess.Popen") def test_clean_certificate_content(self, mock_popen): """Test clean_certificate_content method with various certificate content formats""" diff --git a/tests/providers/m365/m365_fixtures.py b/tests/providers/m365/m365_fixtures.py index 2c1a322ece..299ec40205 100644 --- a/tests/providers/m365/m365_fixtures.py +++ b/tests/providers/m365/m365_fixtures.py @@ -23,7 +23,9 @@ LOCATION = "global" def set_mocked_m365_provider( session_credentials: DefaultAzureCredential = DefaultAzureCredential(), credentials: M365Credentials = M365Credentials( - user="user@email.com", passwd="111111aa111111aaa1111" + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + tenant_id=TENANT_ID, ), identity: M365IdentityInfo = M365IdentityInfo( identity_id=IDENTITY_ID, diff --git a/tests/providers/m365/m365_provider_test.py b/tests/providers/m365/m365_provider_test.py index 0af2abc7c8..53223cc22a 100644 --- a/tests/providers/m365/m365_provider_test.py +++ b/tests/providers/m365/m365_provider_test.py @@ -29,20 +29,15 @@ from prowler.providers.m365.exceptions.exceptions import ( M365GetTokenIdentityError, M365HTTPResponseError, M365InvalidProviderIdError, - M365MissingEnvironmentCredentialsError, M365NoAuthenticationMethodError, M365NotTenantIdButClientIdAndClientSecretError, M365NotValidCertificateContentError, M365NotValidCertificatePathError, M365NotValidClientIdError, M365NotValidClientSecretError, - M365NotValidPasswordError, M365NotValidTenantIdError, - M365NotValidUserError, M365TenantIdAndClientIdNotBelongingToClientSecretError, M365TenantIdAndClientSecretNotBelongingToClientIdError, - M365UserCredentialsError, - M365UserNotBelongingToTenantError, ) from prowler.providers.m365.m365_provider import M365Provider from prowler.providers.m365.models import ( @@ -97,8 +92,6 @@ class TestM365Provider: client_id=CLIENT_ID, tenant_id=TENANT_ID, client_secret=CLIENT_SECRET, - user="", - passwd="", ), ), ): @@ -106,71 +99,6 @@ class TestM365Provider: sp_env_auth=True, az_cli_auth=False, browser_auth=False, - env_auth=False, - tenant_id=tenant_id, - client_id=client_id, - client_secret=client_secret, - region=azure_region, - config_path=default_config_file_path, - fixer_config=fixer_config, - ) - - assert m365_provider.region_config == M365RegionConfig( - name="M365Global", - authority=None, - base_url="https://graph.microsoft.com", - credential_scopes=["https://graph.microsoft.com/.default"], - ) - assert m365_provider.identity == M365IdentityInfo( - identity_id=IDENTITY_ID, - identity_type=IDENTITY_TYPE, - tenant_id=TENANT_ID, - tenant_domain=DOMAIN, - location=LOCATION, - ) - - def test_m365_provider_env_auth(self): - tenant_id = None - client_id = None - client_secret = None - - fixer_config = load_and_validate_config_file( - "m365", default_fixer_config_file_path - ) - azure_region = "M365Global" - - with ( - patch( - "prowler.providers.m365.m365_provider.M365Provider.setup_session", - return_value=ClientSecretCredential( - client_id=CLIENT_ID, - tenant_id=TENANT_ID, - client_secret=CLIENT_SECRET, - ), - ), - patch( - "prowler.providers.m365.m365_provider.M365Provider.setup_identity", - return_value=M365IdentityInfo( - identity_id=IDENTITY_ID, - identity_type=IDENTITY_TYPE, - tenant_id=TENANT_ID, - tenant_domain=DOMAIN, - location=LOCATION, - ), - ), - patch( - "prowler.providers.m365.m365_provider.M365Provider.setup_powershell", - return_value=M365Credentials( - user="test@test.com", - passwd="password", - ), - ), - ): - m365_provider = M365Provider( - sp_env_auth=False, - az_cli_auth=False, - browser_auth=False, - env_auth=True, tenant_id=tenant_id, client_id=client_id, client_secret=client_secret, @@ -228,7 +156,6 @@ class TestM365Provider: sp_env_auth=False, az_cli_auth=True, browser_auth=False, - env_auth=False, region=azure_region, config_path=default_config_file_path, fixer_config=fixer_config, @@ -278,7 +205,6 @@ class TestM365Provider: sp_env_auth=False, az_cli_auth=False, browser_auth=True, - env_auth=False, tenant_id=TENANT_ID, region=azure_region, config_path=default_config_file_path, @@ -398,68 +324,6 @@ class TestM365Provider: assert test_connection.is_connected assert test_connection.error is None - def test_test_connection_tenant_id_client_id_client_secret_no_user_password( - self, - ): - with ( - patch( - "prowler.providers.m365.m365_provider.M365Provider.validate_static_credentials" - ) as mock_validate_static_credentials, - patch( - "prowler.providers.m365.m365_provider.M365Provider.validate_arguments" - ), - patch("prowler.providers.m365.m365_provider.M365Provider.setup_powershell"), - ): - mock_validate_static_credentials.side_effect = M365NotValidUserError( - file=os.path.basename(__file__), - message="The provided M365 User is not valid.", - ) - - with pytest.raises(M365NotValidUserError) as exception: - M365Provider.test_connection( - tenant_id=str(uuid4()), - region="M365Global", - raise_on_exception=True, - client_id=str(uuid4()), - client_secret=str(uuid4()), - user=None, - password="test_password", - ) - - assert exception.type == M365NotValidUserError - assert "The provided M365 User is not valid." in str(exception.value) - - def test_test_connection_tenant_id_client_id_client_secret_user_no_password( - self, - ): - with ( - patch( - "prowler.providers.m365.m365_provider.M365Provider.validate_static_credentials" - ) as mock_validate_static_credentials, - patch( - "prowler.providers.m365.m365_provider.M365Provider.validate_arguments" - ), - patch("prowler.providers.m365.m365_provider.M365Provider.setup_powershell"), - ): - mock_validate_static_credentials.side_effect = M365NotValidPasswordError( - file=os.path.basename(__file__), - message="The provided M365 Password is not valid.", - ) - - with pytest.raises(M365NotValidPasswordError) as exception: - M365Provider.test_connection( - tenant_id=str(uuid4()), - region="M365Global", - raise_on_exception=True, - client_id=str(uuid4()), - client_secret=str(uuid4()), - user="test@example.com", - password=None, - ) - - assert exception.type == M365NotValidPasswordError - assert "The provided M365 Password is not valid." in str(exception.value) - def test_test_connection_with_httpresponseerror(self): with ( patch( @@ -513,27 +377,24 @@ class TestM365Provider: assert exception.type == M365NoAuthenticationMethodError assert ( - "M365 provider requires at least one authentication method set: [--env-auth | --az-cli-auth | --sp-env-auth | --browser-auth | --certificate-auth]" + "M365 provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --certificate-auth]" in exception.value.args[0] ) def test_setup_powershell_valid_credentials(self): credentials_dict = { - "user": "test@example.com", - "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", } - with ( - patch( - "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials", - return_value=True, - ), - ): + with patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps: + mock_session = MagicMock() + mock_session.test_credentials.return_value = True + mock_session.close = MagicMock() + mock_ps.return_value = mock_session + result = M365Provider.setup_powershell( - env_auth=False, m365_credentials=credentials_dict, identity=M365IdentityInfo( identity_id=IDENTITY_ID, @@ -544,42 +405,9 @@ class TestM365Provider: location=LOCATION, ), ) - assert result.user == credentials_dict["user"] - assert result.passwd == credentials_dict["password"] - def test_test_connection_user_not_belonging_to_tenant( - self, - ): - with ( - patch( - "prowler.providers.m365.m365_provider.M365Provider.validate_static_credentials" - ) as mock_validate_static_credentials, - patch( - "prowler.providers.m365.m365_provider.M365Provider.validate_arguments" - ), - patch("prowler.providers.m365.m365_provider.M365Provider.setup_powershell"), - ): - mock_validate_static_credentials.side_effect = M365UserNotBelongingToTenantError( - file=os.path.basename(__file__), - message="The provided M365 User does not belong to the specified tenant.", - ) - - with pytest.raises(M365UserNotBelongingToTenantError) as exception: - M365Provider.test_connection( - tenant_id="contoso.onmicrosoft.com", - region="M365Global", - raise_on_exception=True, - client_id=str(uuid4()), - client_secret=str(uuid4()), - user="user@otherdomain.com", - password="test_password", - ) - - assert exception.type == M365UserNotBelongingToTenantError - assert ( - "The provided M365 User does not belong to the specified tenant." - in str(exception.value) - ) + assert result.client_id == credentials_dict["client_id"] + assert result.client_secret == credentials_dict["client_secret"] def test_validate_static_credentials_invalid_tenant_id(self): with pytest.raises(M365NotValidTenantIdError) as exception: @@ -587,8 +415,6 @@ class TestM365Provider: tenant_id="invalid-tenant-id", client_id="12345678-1234-5678-1234-567812345678", client_secret="test_secret", - user="test@example.com", - password="test_password", ) assert "The provided Tenant ID is not valid." in str(exception.value) @@ -598,8 +424,6 @@ class TestM365Provider: tenant_id="12345678-1234-5678-1234-567812345678", client_id="", client_secret="test_secret", - user="test@example.com", - password="test_password", ) assert "The provided Client ID is not valid." in str(exception.value) @@ -609,36 +433,12 @@ class TestM365Provider: tenant_id="12345678-1234-5678-1234-567812345678", client_id="12345678-1234-5678-1234-567812345678", client_secret="", - user="test@example.com", - password="test_password", ) assert ( "You must provide a client secret, certificate content or certificate path. Please check your credentials and try again." in str(exception.value) ) - def test_validate_arguments_missing_env_credentials(self): - with pytest.raises(M365ConfigCredentialsError) as exception: - M365Provider.validate_arguments( - az_cli_auth=False, - sp_env_auth=False, - env_auth=True, - browser_auth=False, - certificate_auth=False, - tenant_id="test_tenant_id", - client_id="test_client_id", - client_secret=None, - user=None, - password=None, - certificate_content=None, - certificate_path=None, - ) - - assert ( - "You must provide a valid set of credentials. Please check your credentials and try again." - in str(exception.value) - ) - def test_test_connection_invalid_provider_id(self): with ( patch( @@ -680,8 +480,6 @@ class TestM365Provider: raise_on_exception=True, client_id=str(uuid4()), client_secret=str(uuid4()), - user=f"user@{user_domain}", - password="test_password", provider_id=provider_id, ) @@ -694,24 +492,23 @@ class TestM365Provider: def test_provider_init_modules_false(self): """Test that initialize_m365_powershell_modules is not called when init_modules is False""" credentials_dict = { - "user": "test@example.com", - "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", } with ( - patch( - "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials", - return_value=True, - ), + patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps, patch( "prowler.providers.m365.m365_provider.initialize_m365_powershell_modules" ) as mock_init_modules, ): + mock_session = MagicMock() + mock_session.test_credentials.return_value = True + mock_session.close = MagicMock() + mock_ps.return_value = mock_session + M365Provider.setup_powershell( - env_auth=False, m365_credentials=credentials_dict, identity=M365IdentityInfo( identity_id=IDENTITY_ID, @@ -728,24 +525,23 @@ class TestM365Provider: def test_provider_init_modules_true(self): """Test that initialize_m365_powershell_modules is called when init_modules is True""" credentials_dict = { - "user": "test@example.com", - "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", } with ( - patch( - "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials", - return_value=True, - ), + patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps, patch( "prowler.providers.m365.m365_provider.initialize_m365_powershell_modules" ) as mock_init_modules, ): + mock_session = MagicMock() + mock_session.test_credentials.return_value = True + mock_session.close = MagicMock() + mock_ps.return_value = mock_session + M365Provider.setup_powershell( - env_auth=False, m365_credentials=credentials_dict, identity=M365IdentityInfo( identity_id=IDENTITY_ID, @@ -762,26 +558,25 @@ class TestM365Provider: def test_setup_powershell_init_modules_failure(self): """Test that setup_powershell handles initialization failures correctly""" credentials_dict = { - "user": "test@example.com", - "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", } with ( - patch( - "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials", - return_value=True, - ), + patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps, patch( "prowler.providers.m365.m365_provider.initialize_m365_powershell_modules", side_effect=Exception("Module initialization failed"), ), ): + mock_session = MagicMock() + mock_session.test_credentials.return_value = True + mock_session.close = MagicMock() + mock_ps.return_value = mock_session + with pytest.raises(Exception) as exc_info: M365Provider.setup_powershell( - env_auth=False, m365_credentials=credentials_dict, identity=M365IdentityInfo( identity_id=IDENTITY_ID, @@ -837,8 +632,6 @@ class TestM365Provider: raise_on_exception=True, client_id=str(uuid4()), client_secret=str(uuid4()), - user="user@contoso.onmicrosoft.com", - password="test_password", provider_id=provider_id, ) @@ -893,7 +686,6 @@ class TestM365Provider: sp_env_auth=False, az_cli_auth=False, browser_auth=False, - env_auth=False, certificate_auth=True, tenant_id=tenant_id, client_id=client_id, @@ -1067,64 +859,6 @@ class TestM365Provider: check_certificate_content=True ) - def test_setup_powershell_env_auth_missing_credentials(self): - """Test setup_powershell with env_auth but missing environment variables""" - with ( - patch.dict(os.environ, {}, clear=True), - pytest.raises(M365MissingEnvironmentCredentialsError) as exception, - ): - M365Provider.setup_powershell( - env_auth=True, - identity=M365IdentityInfo( - identity_id=IDENTITY_ID, - identity_type="User", - tenant_id=TENANT_ID, - tenant_domain=DOMAIN, - tenant_domains=["test.onmicrosoft.com"], - location=LOCATION, - ), - ) - - assert exception.type == M365MissingEnvironmentCredentialsError - assert ( - "Missing M365_USER or M365_PASSWORD environment variables required for credentials authentication." - in str(exception.value) - ) - - def test_setup_powershell_env_auth_success(self): - """Test setup_powershell with env_auth and valid environment variables""" - with ( - patch.dict( - os.environ, - { - "M365_USER": "test@example.com", - "M365_PASSWORD": "password", - "AZURE_CLIENT_ID": CLIENT_ID, - "AZURE_CLIENT_SECRET": CLIENT_SECRET, - "AZURE_TENANT_ID": TENANT_ID, - }, - ), - patch( - "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials", - return_value=True, - ), - ): - result = M365Provider.setup_powershell( - env_auth=True, - identity=M365IdentityInfo( - identity_id=IDENTITY_ID, - identity_type="User", - tenant_id=TENANT_ID, - tenant_domain=DOMAIN, - tenant_domains=["test.onmicrosoft.com"], - location=LOCATION, - ), - ) - - assert result.user == "test@example.com" - assert result.passwd == "password" - assert result.client_id == CLIENT_ID - def test_setup_powershell_sp_env_auth_success(self): """Test setup_powershell with sp_env_auth and valid environment variables""" with ( @@ -1136,11 +870,13 @@ class TestM365Provider: "AZURE_TENANT_ID": TENANT_ID, }, ), - patch( - "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials", - return_value=True, - ), + patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps, ): + mock_session = MagicMock() + mock_session.test_credentials.return_value = True + mock_session.close = MagicMock() + mock_ps.return_value = mock_session + result = M365Provider.setup_powershell( sp_env_auth=True, identity=M365IdentityInfo( @@ -1170,11 +906,13 @@ class TestM365Provider: "M365_CERTIFICATE_CONTENT": certificate_content, }, ), - patch( - "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials", - return_value=True, - ), + patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps, ): + mock_session = MagicMock() + mock_session.test_credentials.return_value = True + mock_session.close = MagicMock() + mock_ps.return_value = mock_session + identity = M365IdentityInfo( identity_id=IDENTITY_ID, identity_type="Service Principal with Certificate", @@ -1197,22 +935,21 @@ class TestM365Provider: def test_setup_powershell_invalid_credentials(self): """Test setup_powershell with invalid credentials""" credentials_dict = { - "user": "test@example.com", - "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", } with ( - patch( - "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.test_credentials", - return_value=False, - ), - pytest.raises(M365UserCredentialsError) as exception, + patch("prowler.providers.m365.m365_provider.M365PowerShell") as mock_ps, + pytest.raises(M365ConfigCredentialsError) as exception, ): + mock_session = MagicMock() + mock_session.test_credentials.return_value = False + mock_session.close = MagicMock() + mock_ps.return_value = mock_session + M365Provider.setup_powershell( - env_auth=False, m365_credentials=credentials_dict, identity=M365IdentityInfo( identity_id=IDENTITY_ID, @@ -1223,9 +960,8 @@ class TestM365Provider: location=LOCATION, ), ) - - assert exception.type == M365UserCredentialsError - assert "The provided User credentials are not valid." in str(exception.value) + assert exception.type == M365ConfigCredentialsError + assert "The provided credentials are not valid." in str(exception.value) def test_validate_arguments_browser_auth_without_tenant_id(self): """Test validate_arguments with browser_auth but missing tenant_id""" @@ -1233,14 +969,11 @@ class TestM365Provider: M365Provider.validate_arguments( az_cli_auth=False, sp_env_auth=False, - env_auth=False, browser_auth=True, certificate_auth=False, tenant_id=None, client_id=None, client_secret=None, - user=None, - password=None, certificate_content=None, certificate_path=None, ) @@ -1257,14 +990,11 @@ class TestM365Provider: M365Provider.validate_arguments( az_cli_auth=False, sp_env_auth=False, - env_auth=False, browser_auth=False, certificate_auth=False, tenant_id=TENANT_ID, client_id=None, client_secret=None, - user=None, - password=None, certificate_content=None, certificate_path=None, ) @@ -1280,14 +1010,11 @@ class TestM365Provider: M365Provider.validate_arguments( az_cli_auth=False, sp_env_auth=False, - env_auth=False, browser_auth=False, certificate_auth=False, tenant_id=None, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, - user=None, - password=None, certificate_content=None, certificate_path=None, ) @@ -1326,8 +1053,6 @@ class TestM365Provider: "tenant_id": TENANT_ID, "client_id": CLIENT_ID, "client_secret": None, - "user": None, - "password": None, "certificate_content": certificate_content, }, ), @@ -1392,8 +1117,6 @@ class TestM365Provider: tenant_id=str(uuid4()), client_id=str(uuid4()), client_secret="test_secret", - user="test@example.com", - password="test_password", ) assert exception.type == M365ClientIdAndClientSecretNotBelongingToTenantIdError @@ -1419,8 +1142,6 @@ class TestM365Provider: tenant_id=str(uuid4()), client_id=str(uuid4()), client_secret="test_secret", - user="test@example.com", - password="test_password", ) assert exception.type == M365TenantIdAndClientSecretNotBelongingToClientIdError @@ -1446,8 +1167,6 @@ class TestM365Provider: tenant_id=str(uuid4()), client_id=str(uuid4()), client_secret="test_secret", - user="test@example.com", - password="test_password", ) assert exception.type == M365TenantIdAndClientIdNotBelongingToClientSecretError @@ -1529,7 +1248,6 @@ class TestM365Provider: result = M365Provider.setup_session( az_cli_auth=False, sp_env_auth=False, - env_auth=False, browser_auth=False, certificate_auth=True, certificate_path=None, @@ -1580,7 +1298,6 @@ class TestM365Provider: M365Provider.setup_session( az_cli_auth=False, sp_env_auth=False, - env_auth=False, browser_auth=False, certificate_auth=True, certificate_path=None, @@ -1600,8 +1317,6 @@ class TestM365Provider: "tenant_id": TENANT_ID, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, - "user": None, - "password": None, "certificate_content": certificate_content, } @@ -1623,7 +1338,6 @@ class TestM365Provider: result = M365Provider.setup_session( az_cli_auth=False, sp_env_auth=False, - env_auth=False, browser_auth=False, certificate_auth=False, certificate_path=None, @@ -1671,14 +1385,11 @@ class TestM365Provider: M365Provider.validate_arguments( az_cli_auth=False, sp_env_auth=False, - env_auth=False, browser_auth=False, certificate_auth=True, tenant_id=TENANT_ID, client_id=CLIENT_ID, client_secret=None, - user=None, - password=None, certificate_content=certificate_content, certificate_path=None, ) @@ -1689,14 +1400,11 @@ class TestM365Provider: M365Provider.validate_arguments( az_cli_auth=False, sp_env_auth=False, - env_auth=False, browser_auth=False, certificate_auth=False, tenant_id=TENANT_ID, client_id=CLIENT_ID, client_secret=None, - user=None, - password=None, certificate_content=None, certificate_path=None, ) @@ -1991,7 +1699,6 @@ class TestM365Provider: result = M365Provider.setup_session( az_cli_auth=False, sp_env_auth=False, - env_auth=False, browser_auth=False, certificate_auth=True, certificate_path=certificate_path, @@ -2018,8 +1725,6 @@ class TestM365Provider: "tenant_id": TENANT_ID, "client_id": CLIENT_ID, "client_secret": None, - "user": None, - "password": None, "certificate_content": None, "certificate_path": certificate_path, } @@ -2036,7 +1741,6 @@ class TestM365Provider: result = M365Provider.setup_session( az_cli_auth=False, sp_env_auth=False, - env_auth=False, browser_auth=False, certificate_auth=False, certificate_path=None, From cc9aa7f7ee525ab696cd39195290bb5ba0134350 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 9 Oct 2025 12:31:31 +0200 Subject: [PATCH 06/22] feat(jira): support of `ADF` for MarkDown metadata fields (#8878) --- prowler/CHANGELOG.md | 1 + prowler/lib/outputs/jira/jira.py | 331 ++++++++++++++++++++++++---- tests/lib/outputs/jira/jira_test.py | 196 ++++++++++++++-- 3 files changed, 462 insertions(+), 66 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index c5f899e656..2eaec5e7bd 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Add explicit "name" field for each compliance framework and include "FRAMEWORK" and "NAME" in CSV output [(#7920)](https://github.com/prowler-cloud/prowler/pull/7920) - Equality validation for CheckID, filename and classname [(#8690)](https://github.com/prowler-cloud/prowler/pull/8690) - Improve logging for Security Hub integration [(#8608)](https://github.com/prowler-cloud/prowler/pull/8608) +- Support for Atlassian Document Format (ADF) in Jira integration [(#8878)](https://github.com/prowler-cloud/prowler/pull/8878) ### Changed diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index fa847d5fbc..6a80519b51 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -2,10 +2,12 @@ import base64 import os from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Dict +from typing import Dict, List, Optional import requests import requests.compat +from markdown_it import MarkdownIt +from markdown_it.token import Token from prowler.lib.logger import logger from prowler.lib.outputs.finding import Finding @@ -47,6 +49,204 @@ class JiraConnection(Connection): projects: dict = None +class MarkdownToADFConverter: + """Helper to convert Markdown strings into Atlassian Document Format blocks.""" + + def __init__(self) -> None: + self._parser = MarkdownIt("commonmark", {"html": False}) + + def convert(self, text: Optional[str]) -> List[Dict]: + if text is None: + text = "" + + tokens = self._parser.parse(text) + if not tokens: + return [self._paragraph_with_text(text)] + + content_stack: List[List[Dict]] = [[]] + node_stack: List[Dict] = [] + + for token in tokens: + token_type = token.type + + if token_type == "paragraph_open": + node = {"type": "paragraph", "content": []} + node_stack.append(node) + content_stack.append(node["content"]) + elif token_type == "inline": + inline_nodes = self._convert_inline(token.children or []) + content_stack[-1].extend(inline_nodes) + elif token_type == "paragraph_close": + node = node_stack.pop() + content_stack.pop() + content_stack[-1].append(node) + elif token_type == "bullet_list_open": + node = {"type": "bulletList", "content": []} + node_stack.append(node) + content_stack.append(node["content"]) + elif token_type == "bullet_list_close": + node = node_stack.pop() + content_stack.pop() + content_stack[-1].append(node) + elif token_type == "ordered_list_open": + node: Dict = {"type": "orderedList", "content": []} + start_attr = token.attrGet("start") + if start_attr and start_attr.isdigit(): + start = int(start_attr) + if start != 1: + node["attrs"] = {"order": start} + node_stack.append(node) + content_stack.append(node["content"]) + elif token_type == "ordered_list_close": + node = node_stack.pop() + content_stack.pop() + content_stack[-1].append(node) + elif token_type == "list_item_open": + node = {"type": "listItem", "content": []} + node_stack.append(node) + content_stack.append(node["content"]) + elif token_type == "list_item_close": + node = node_stack.pop() + content_stack.pop() + content_stack[-1].append(node) + elif token_type == "heading_open": + level = self._safe_heading_level(token.tag) + node = {"type": "heading", "attrs": {"level": level}, "content": []} + node_stack.append(node) + content_stack.append(node["content"]) + elif token_type == "heading_close": + node = node_stack.pop() + content_stack.pop() + content_stack[-1].append(node) + elif token_type == "blockquote_open": + node = {"type": "blockquote", "content": []} + node_stack.append(node) + content_stack.append(node["content"]) + elif token_type == "blockquote_close": + node = node_stack.pop() + content_stack.pop() + content_stack[-1].append(node) + elif token_type in {"fence", "code_block"}: + language = None + if token_type == "fence": + info = (token.info or "").strip() + if info: + language = info.split()[0] + code_text = token.content.rstrip("\n") + code_node: Dict = { + "type": "codeBlock", + "content": [self._create_text_node(code_text, None)], + } + if language: + code_node["attrs"] = {"language": language} + content_stack[-1].append(code_node) + elif token_type in {"hr", "thematic_break"}: + content_stack[-1].append({"type": "rule"}) + elif token_type == "html_block": + html_text = token.content.strip() + if html_text: + content_stack[-1].append(self._paragraph_with_text(html_text)) + + result = content_stack[0] + if not result: + return [self._paragraph_with_text(text)] + + return result + + def _convert_inline(self, tokens: List[Token]) -> List[Dict]: + result: List[Dict] = [] + marks_stack: List[Dict] = [] + + for token in tokens: + token_type = token.type + + if token_type == "text": + result.extend(self._text_to_nodes(token.content, marks_stack)) + elif token_type == "code_inline": + marks = self._clone_marks(marks_stack) + marks.append({"type": "code"}) + result.append(self._create_text_node(token.content, marks)) + elif token_type in {"softbreak", "hardbreak"}: + result.append({"type": "hardBreak"}) + elif token_type == "strong_open": + marks_stack.append({"type": "strong"}) + elif token_type == "strong_close": + self._pop_mark(marks_stack, "strong") + elif token_type == "em_open": + marks_stack.append({"type": "em"}) + elif token_type == "em_close": + self._pop_mark(marks_stack, "em") + elif token_type == "link_open": + href = token.attrGet("href") or "" + mark: Dict = {"type": "link", "attrs": {"href": href}} + title = token.attrGet("title") + if title: + mark["attrs"]["title"] = title + marks_stack.append(mark) + elif token_type == "link_close": + self._pop_mark(marks_stack, "link") + elif token_type == "html_inline": + result.extend(self._text_to_nodes(token.content, marks_stack)) + elif token_type == "image": + alt_text = token.attrGet("alt") or token.content or "" + result.extend(self._text_to_nodes(alt_text, marks_stack)) + + return result + + @staticmethod + def _clone_marks(marks_stack: List[Dict]) -> List[Dict]: + cloned: List[Dict] = [] + for mark in marks_stack: + mark_copy = {"type": mark["type"]} + if "attrs" in mark: + mark_copy["attrs"] = dict(mark["attrs"]) + cloned.append(mark_copy) + return cloned + + def _text_to_nodes(self, text: str, marks_stack: List[Dict]) -> List[Dict]: + if not text: + return [] + + nodes: List[Dict] = [] + marks = self._clone_marks(marks_stack) + parts = text.split("\n") + + for index, part in enumerate(parts): + if part: + nodes.append(self._create_text_node(part, marks)) + if index < len(parts) - 1: + nodes.append({"type": "hardBreak"}) + + return nodes + + @staticmethod + def _create_text_node(text: str, marks: Optional[List[Dict]]) -> Dict: + node: Dict = {"type": "text", "text": text} + if marks: + node["marks"] = marks + return node + + def _paragraph_with_text(self, text: str) -> Dict: + return {"type": "paragraph", "content": [self._create_text_node(text, None)]} + + @staticmethod + def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None: + for index in range(len(marks_stack) - 1, -1, -1): + if marks_stack[index]["type"] == mark_type: + marks_stack.pop(index) + break + + @staticmethod + def _safe_heading_level(tag: Optional[str]) -> int: + if tag and tag.startswith("h"): + try: + level = int(tag[1]) + return max(1, min(level, 6)) + except (ValueError, IndexError): + return 1 + return 1 + + class Jira: """ Jira class to interact with the Jira API @@ -112,6 +312,7 @@ class Jira: jira.send_findings(findings=findings, project_key="KEY") """ + _markdown_converter = MarkdownToADFConverter() _redirect_uri: str = None _client_id: str = None _client_secret: str = None @@ -173,6 +374,45 @@ class Jira: message=init_error, file=os.path.basename(__file__) ) + @staticmethod + def _build_code_block_content(code_value: str) -> Optional[Dict]: + if not code_value: + return None + + lines = code_value.splitlines() + if not lines: + return None + + language = None + first_line = lines[0].strip() + if first_line.startswith("```"): + language = first_line[3:].strip() or None + lines = lines[1:] + + while lines and not lines[0].strip(): + lines = lines[1:] + + if lines and lines[-1].strip().startswith("```"): + lines = lines[:-1] + + while lines and not lines[-1].strip(): + lines = lines[:-1] + + if not lines: + return None + + sanitized_text = "\n".join(lines) + + code_block: Dict = { + "type": "codeBlock", + "content": [{"type": "text", "text": sanitized_text}], + } + + if language: + code_block["attrs"] = {"language": language} + + return code_block + @property def redirect_uri(self): return self._redirect_uri @@ -837,8 +1077,8 @@ class Jira: return "#0000FF" return "#000000" # Default black color for unknown severities - @staticmethod def get_adf_description( + self, check_id: str = "", check_title: str = "", severity: str = "", @@ -1231,17 +1471,7 @@ class Jira: { "type": "tableCell", "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": risk, - } - ], - } - ], + "content": self._markdown_converter.convert(risk), }, ], }, @@ -1340,6 +1570,34 @@ class Jira: ) # Add recommendation row + recommendation_content = self._markdown_converter.convert(recommendation_text) + if recommendation_url: + link_node = { + "type": "text", + "text": recommendation_url, + "marks": [{"type": "link", "attrs": {"href": recommendation_url}}], + } + + if ( + recommendation_content + and recommendation_content[-1].get("type") == "paragraph" + ): + paragraph = recommendation_content[-1] + paragraph_content = paragraph.setdefault("content", []) + if paragraph_content: + last_inline = paragraph_content[-1] + if last_inline.get("type") == "text" and not last_inline.get( + "text", "" + ).endswith(" "): + paragraph_content.append({"type": "text", "text": " "}) + elif last_inline.get("type") != "text": + paragraph_content.append({"type": "text", "text": " "}) + paragraph_content.append(link_node) + else: + recommendation_content.append( + {"type": "paragraph", "content": [link_node]} + ) + table_rows.append( { "type": "tableRow", @@ -1363,27 +1621,7 @@ class Jira: { "type": "tableCell", "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": recommendation_text + " ", - }, - { - "type": "text", - "text": recommendation_url, - "marks": [ - { - "type": "link", - "attrs": {"href": recommendation_url}, - } - ], - }, - ], - } - ], + "content": recommendation_content, }, ], } @@ -1399,6 +1637,14 @@ class Jira: for code_type, code_value in remediation_codes: if code_value and code_value.strip(): + if code_type == "Other": + formatted_content = self._markdown_converter.convert(code_value) + else: + code_block = self._build_code_block_content(code_value) + if not code_block: + continue + formatted_content = [code_block] + table_rows.append( { "type": "tableRow", @@ -1422,18 +1668,7 @@ class Jira: { "type": "tableCell", "attrs": {"colwidth": [3]}, - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": code_value, - "marks": [{"type": "code"}], - } - ], - } - ], + "content": formatted_content, }, ], } @@ -1645,9 +1880,11 @@ class Jira: payload = { "fields": { "project": {"key": project_key}, - "summary": summary, + "summary": f"[Prowler] {finding.metadata.Severity.value.upper()} - {finding.metadata.CheckID} - {finding.resource_uid}", "description": adf_description, "issuetype": {"name": issue_type}, + "customfield_10148": {"value": "SDK"}, + "customfield_10088": {"value": "Core"}, } } if issue_labels: diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 452b4a2a51..6e7f9f8220 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -1,5 +1,6 @@ import base64 from datetime import datetime, timedelta +from typing import List, Optional from unittest.mock import MagicMock, PropertyMock, patch from urllib.parse import parse_qs, urlparse @@ -60,6 +61,49 @@ class TestJiraIntegration: domain=self.domain, ) + @staticmethod + def _collect_text_from_cell(cell: dict) -> str: + pieces: List[str] = [] + + def walk(node: dict) -> None: + node_type = node.get("type") + if node_type == "text": + pieces.append(node.get("text", "")) + elif node_type == "hardBreak": + pieces.append(" ") + else: + for child in node.get("content", []): + walk(child) + if node_type in {"paragraph", "listItem"}: + pieces.append(" ") + + for child in cell.get("content", []): + walk(child) + + flattened = "".join(pieces) + return " ".join(flattened.split()) + + @staticmethod + def _find_link_mark(nodes: List[dict]) -> Optional[dict]: + for node in nodes: + if node.get("type") == "text": + for mark in node.get("marks", []): + if mark.get("type") == "link": + return mark + found = TestJiraIntegration._find_link_mark(node.get("content", [])) + if found: + return found + return None + + @staticmethod + def _find_table_row(rows: List[dict], header: str) -> dict: + for row in rows: + header_cell = row.get("content", [])[0] + header_text = TestJiraIntegration._collect_text_from_cell(header_cell) + if header_text == header: + return row + raise AssertionError(f"Row with header '{header}' not found") + @patch.object(Jira, "get_auth", return_value=None) def test_auth_code_url(self, mock_get_auth): """Test to verify the authorization URL generation with correct query parameters""" @@ -747,24 +791,24 @@ class TestJiraIntegration: assert table["type"] == "table" table_rows = table["content"] - row_texts = [] + row_entries = {} + recommendation_cell = None + for row in table_rows: - if row["type"] == "tableRow": - cells = row["content"] - if len(cells) == 2: - key_cell = cells[0]["content"][0]["content"][0]["text"] + if row["type"] != "tableRow": + continue - value_content = cells[1]["content"][0]["content"] - if len(value_content) > 1: - value_texts = [] - for content_item in value_content: - if content_item["type"] == "text": - value_texts.append(content_item["text"]) - value_cell = "".join(value_texts) - else: - value_cell = value_content[0]["text"] + cells = row["content"] + if len(cells) != 2: + continue - row_texts.append((key_cell, value_cell)) + key_text = self._collect_text_from_cell(cells[0]) + value_text = self._collect_text_from_cell(cells[1]) + + if key_text: + row_entries[key_text] = value_text + if key_text == "Recommendation": + recommendation_cell = cells[1] expected_keys = [ "Check Id", @@ -788,12 +832,15 @@ class TestJiraIntegration: "Tenant Info", ] - actual_keys = [key for key, _ in row_texts] - for expected_key in expected_keys: - assert expected_key in actual_keys, f"Missing row key: {expected_key}" + assert expected_key in row_entries, f"Missing row key: {expected_key}" - row_dict = dict(row_texts) + row_dict = row_entries + + assert recommendation_cell is not None + link_mark = self._find_link_mark(recommendation_cell.get("content", [])) + assert link_mark is not None + assert link_mark.get("attrs", {}).get("href") == "remediation_url" assert row_dict["Check Id"] == "CHECK-1" assert row_dict["Check Title"] == "Check Title" assert row_dict["Status"] == "FAIL" @@ -828,6 +875,117 @@ class TestJiraIntegration: assert "https://prowler-cloud-link/findings/12345" in row_dict["Finding URL"] assert "Tenant Info" in row_dict["Tenant Info"] + def test_get_adf_description_renders_markdown(self): + status_extended_md = "Finding uses **bold** text and `code` snippets." + risk_md = "High risk:\n- Item one\n- Item two" + recommendation_md = "Apply fixes:\n- Step one\n- Step two" + recommendation_url = "https://example.com/fix" + + adf_description = self.jira_integration.get_adf_description( + check_id="CHECK-1", + check_title="Sample check", + severity="HIGH", + severity_color="#FF0000", + status="FAIL", + status_color="#00FF00", + status_extended=status_extended_md, + provider="aws", + region="us-east-1", + resource_uid="resource-1", + resource_name="resource-name", + risk=risk_md, + recommendation_text=recommendation_md, + recommendation_url=recommendation_url, + ) + + assert adf_description["type"] == "doc" + table = adf_description["content"][1] + assert table["type"] == "table" + + rows = {} + for row in table["content"]: + if row.get("type") != "tableRow": + continue + key_cell, value_cell = row["content"] + key_text = self._collect_text_from_cell(key_cell) + rows[key_text] = value_cell + + assert "Status Extended" in rows + assert "Risk" in rows + assert "Recommendation" in rows + + def walk_nodes(nodes: List[dict]): + stack = list(nodes) + while stack: + current = stack.pop() + yield current + stack.extend(current.get("content", [])) + + status_text = self._collect_text_from_cell(rows["Status Extended"]) + assert status_text == status_extended_md + + risk_nodes = list(walk_nodes(rows["Risk"].get("content", []))) + assert any(node.get("type") == "bulletList" for node in risk_nodes) + + recommendation_cell = rows["Recommendation"] + recommendation_nodes = list(walk_nodes(recommendation_cell.get("content", []))) + assert any(node.get("type") == "bulletList" for node in recommendation_nodes) + link_mark = self._find_link_mark(recommendation_cell.get("content", [])) + assert link_mark is not None + assert link_mark.get("attrs", {}).get("href") == recommendation_url + + def test_get_adf_description_code_blocks_strip_fences(self): + code_block_value = """```hcl\nresource \"aws_s3_bucket\" \"example\" {\n bucket = \"my-bucket\"\n}\n```""" + + adf_description = self.jira_integration.get_adf_description( + check_id="CHECK-1", + check_title="Sample check", + severity="HIGH", + severity_color="#FF0000", + status="FAIL", + status_color="#00FF00", + recommendation_text="", + remediation_code_native_iac=code_block_value, + ) + + table = adf_description["content"][1] + code_row = self._find_table_row(table["content"], "Remediation Native IaC") + code_cell = code_row["content"][1] + code_block = code_cell["content"][0] + + assert code_block["type"] == "codeBlock" + assert code_block.get("attrs", {}).get("language") == "hcl" + expected_text = ( + 'resource "aws_s3_bucket" "example" {\n bucket = "my-bucket"\n}' + ) + assert code_block["content"][0]["text"] == expected_text + + def test_get_adf_description_other_remediation_uses_markdown(self): + other_value = "Use **bold** text" + + adf_description = self.jira_integration.get_adf_description( + check_id="CHECK-1", + check_title="Sample check", + severity="HIGH", + severity_color="#FF0000", + status="FAIL", + status_color="#00FF00", + recommendation_text="", + remediation_code_other=other_value, + ) + + table = adf_description["content"][1] + other_row = self._find_table_row(table["content"], "Remediation Other") + other_cell = other_row["content"][1] + + paragraph = other_cell["content"][0] + assert paragraph["type"] == "paragraph" + assert any( + mark.get("type") == "strong" + for node in paragraph.get("content", []) + for mark in node.get("marks", []) + ) + @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] From da6b7b89cb90d907608413c913b2b1d3ae32c851 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:44:01 +0200 Subject: [PATCH 07/22] fix(tests): jira test double lines (#8886) --- tests/lib/outputs/jira/jira_test.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 6e7f9f8220..5e93d5c7d0 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -755,10 +755,6 @@ class TestJiraIntegration: call_args = mock_post.call_args - mock_post.assert_called_once() - - call_args = mock_post.call_args - expected_url = ( "https://api.atlassian.com/ex/jira/valid_cloud_id/rest/api/3/issue" ) From 1ba22f6f45ff6c320ee5138ac0d8748ebde7cc98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Jes=C3=BAs=20Pe=C3=B1a=20Rodr=C3=ADguez?= Date: Thu, 9 Oct 2025 14:30:26 +0200 Subject: [PATCH 08/22] feat(api): update role mapping logic in TenantFinishACSView to handle single/manage account users (#8882) --- api/CHANGELOG.md | 1 + api/src/backend/api/tests/test_views.py | 182 ++++++++++++++++++++++++ api/src/backend/api/v1/views.py | 66 +++++---- 3 files changed, 222 insertions(+), 27 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index d6b692c110..1a67cd741a 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the **Prowler API** are documented in this file. - Default JWT keys are generated and stored if they are missing from configuration [(#8655)](https://github.com/prowler-cloud/prowler/pull/8655) - `compliance_name` for each compliance [(#7920)](https://github.com/prowler-cloud/prowler/pull/7920) - API Key support [(#8805)](https://github.com/prowler-cloud/prowler/pull/8805) +- SAML role mapping protection for single-admin tenants to prevent accidental lockout [(#8882)](https://github.com/prowler-cloud/prowler/pull/8882) - Support for `passed_findings` and `total_findings` fields in compliance requirement overview for accurate Prowler ThreatScore calculation [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582) ### Changed diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index cfad06c777..d94caa41f7 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -6892,6 +6892,188 @@ class TestTenantFinishACSView: assert response.status_code == 302 assert "sso_saml_failed=true" in response.url + def test_dispatch_skips_role_mapping_when_single_manage_account_user( + self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch + ): + """Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + tenant = tenants_fixture[0] + + # Create a single role with manage_account=True for the user + admin_role = Role.objects.using(MainRouter.admin_db).create( + name="admin", + tenant=tenant, + manage_account=True, + manage_users=True, + manage_billing=True, + manage_providers=True, + manage_integrations=True, + manage_scans=True, + unlimited_visibility=True, + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, role=admin_role, tenant_id=tenant.id + ) + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + "userType": ["no_permissions"], # This should be ignored + }, + ) + + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + 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.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + 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={} + ) + 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_user_get.return_value = user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") + + assert response.status_code == 302 + + # Verify the admin role is still assigned (not changed to no_permissions) + assert ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, role=admin_role, tenant_id=tenant.id) + .exists() + ) + + # Verify no_permissions role was NOT created in the database + assert ( + not Role.objects.using(MainRouter.admin_db) + .filter(name="no_permissions", tenant=tenant) + .exists() + ) + + # Verify no_permissions role was NOT assigned to the user + assert not ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, role__name="no_permissions", tenant_id=tenant.id) + .exists() + ) + + def test_dispatch_applies_role_mapping_when_multiple_manage_account_users( + self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch + ): + """Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + tenant = tenants_fixture[0] + + # Create a second user with manage_account=True + second_admin = User.objects.using(MainRouter.admin_db).create( + email="admin2@prowler.com", name="Second Admin" + ) + admin_role = Role.objects.using(MainRouter.admin_db).create( + name="admin", + tenant=tenant, + manage_account=True, + manage_users=True, + manage_billing=True, + manage_providers=True, + manage_integrations=True, + manage_scans=True, + unlimited_visibility=True, + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, role=admin_role, tenant_id=tenant.id + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=second_admin, role=admin_role, tenant_id=tenant.id + ) + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + "userType": ["viewer"], # This SHOULD be applied + }, + ) + + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + 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.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + 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={} + ) + 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_user_get.return_value = user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") + + assert response.status_code == 302 + + # Verify the viewer role was created and assigned (role mapping was applied) + viewer_role = Role.objects.using(MainRouter.admin_db).get( + name="viewer", tenant=tenant + ) + assert ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, role=viewer_role, tenant_id=tenant.id) + .exists() + ) + + # Verify the admin role was removed (replaced by viewer) + assert not ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, role=admin_role, tenant_id=tenant.id) + .exists() + ) + @pytest.mark.django_db class TestLighthouseConfigViewSet: diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 777afac907..8be9f3aed3 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -666,36 +666,48 @@ class TenantFinishACSView(FinishACSView): .get(email_domain=email_domain) .tenant ) - role_name = ( - extra.get("userType", ["no_permissions"])[0].strip() - if extra.get("userType") - else "no_permissions" + + # Check if tenant has only one user with MANAGE_ACCOUNT role + users_with_manage_account = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(role__manage_account=True, tenant_id=tenant.id) + .values("user") + .distinct() + .count() ) - try: - role = Role.objects.using(MainRouter.admin_db).get( - name=role_name, tenant=tenant + + # Only apply role mapping from userType if tenant does NOT have exactly one user with MANAGE_ACCOUNT + if users_with_manage_account != 1: + role_name = ( + extra.get("userType", ["no_permissions"])[0].strip() + if extra.get("userType") + else "no_permissions" ) - except Role.DoesNotExist: - role = Role.objects.using(MainRouter.admin_db).create( - name=role_name, - tenant=tenant, - manage_users=False, - manage_account=False, - manage_billing=False, - manage_providers=False, - manage_integrations=False, - manage_scans=False, - unlimited_visibility=False, + try: + role = Role.objects.using(MainRouter.admin_db).get( + name=role_name, tenant=tenant + ) + except Role.DoesNotExist: + role = Role.objects.using(MainRouter.admin_db).create( + name=role_name, + tenant=tenant, + manage_users=False, + manage_account=False, + manage_billing=False, + manage_providers=False, + manage_integrations=False, + manage_scans=False, + unlimited_visibility=False, + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).filter( + user=user, + tenant_id=tenant.id, + ).delete() + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, + role=role, + tenant_id=tenant.id, ) - UserRoleRelationship.objects.using(MainRouter.admin_db).filter( - user=user, - tenant_id=tenant.id, - ).delete() - UserRoleRelationship.objects.using(MainRouter.admin_db).create( - user=user, - role=role, - tenant_id=tenant.id, - ) membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create( user=user, tenant=tenant, From e80eed6baf9e4612df29aa0a296d6e49434f511e Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Thu, 9 Oct 2025 15:21:12 +0200 Subject: [PATCH 09/22] chore(ui): remove .env.template (#8887) --- ui/.env.template | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 ui/.env.template diff --git a/ui/.env.template b/ui/.env.template deleted file mode 100644 index ba212ad68a..0000000000 --- a/ui/.env.template +++ /dev/null @@ -1,6 +0,0 @@ -SITE_URL=http://localhost:3000 -API_BASE_URL=http://localhost:8080/api/v1 -AUTH_TRUST_HOST=true - -# openssl rand -base64 32 -AUTH_SECRET=your-secret-key From b74744b13512cf0cef08e466ae53c3db3b3e14c7 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:50:28 +0200 Subject: [PATCH 10/22] feat(m365): add M365 certificate auth to API (#8538) --- api/CHANGELOG.md | 1 + api/src/backend/api/specs/v1.yaml | 116 +++++++++++++++++- api/src/backend/api/tests/test_views.py | 73 +++++++---- .../api/v1/serializer_utils/providers.py | 36 +++++- api/src/backend/api/v1/serializers.py | 31 ++++- 5 files changed, 228 insertions(+), 29 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 1a67cd741a..3b78e84bb1 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler API** are documented in this file. ### Added - Default JWT keys are generated and stored if they are missing from configuration [(#8655)](https://github.com/prowler-cloud/prowler/pull/8655) - `compliance_name` for each compliance [(#7920)](https://github.com/prowler-cloud/prowler/pull/7920) +- Support for M365 Certificate authentication [(#8538)](https://github.com/prowler-cloud/prowler/pull/8538) - API Key support [(#8805)](https://github.com/prowler-cloud/prowler/pull/8805) - SAML role mapping protection for single-admin tenants to prevent accidental lockout [(#8882)](https://github.com/prowler-cloud/prowler/pull/8882) - Support for `passed_findings` and `total_findings` fields in compliance requirement overview for accurate Prowler ThreatScore calculation [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582) diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index daec67a79e..d282021d49 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -12018,6 +12018,33 @@ components: type: string description: The Azure tenant ID, representing the directory where the application is registered. + user: + type: email + description: 'Deprecated: User microsoft email address.' + password: + type: string + description: 'Deprecated: User password.' + required: + - client_id + - client_secret + - tenant_id + - user + - password + - type: object + title: M365 Certificate Credentials + properties: + client_id: + type: string + description: The Azure application (client) ID for authentication + in Azure AD. + tenant_id: + type: string + description: The Azure tenant ID, representing the directory + where the application is registered. + certificate_content: + type: string + description: The certificate content in base64 format for + certificate-based authentication. user: type: email description: User microsoft email address. @@ -12026,8 +12053,8 @@ components: description: User password. required: - client_id - - client_secret - tenant_id + - certificate_content - user - password - type: object @@ -13882,6 +13909,33 @@ components: type: string description: The Azure tenant ID, representing the directory where the application is registered. + user: + type: email + description: 'Deprecated: User microsoft email address.' + password: + type: string + description: 'Deprecated: User password.' + required: + - client_id + - client_secret + - tenant_id + - user + - password + - type: object + title: M365 Certificate Credentials + properties: + client_id: + type: string + description: The Azure application (client) ID for authentication + in Azure AD. + tenant_id: + type: string + description: The Azure tenant ID, representing the directory where + the application is registered. + certificate_content: + type: string + description: The certificate content in base64 format for certificate-based + authentication. user: type: email description: User microsoft email address. @@ -13890,8 +13944,8 @@ components: description: User password. required: - client_id - - client_secret - tenant_id + - certificate_content - user - password - type: object @@ -14130,6 +14184,33 @@ components: type: string description: The Azure tenant ID, representing the directory where the application is registered. + user: + type: email + description: 'Deprecated: User microsoft email address.' + password: + type: string + description: 'Deprecated: User password.' + required: + - client_id + - client_secret + - tenant_id + - user + - password + - type: object + title: M365 Certificate Credentials + properties: + client_id: + type: string + description: The Azure application (client) ID for authentication + in Azure AD. + tenant_id: + type: string + description: The Azure tenant ID, representing the directory + where the application is registered. + certificate_content: + type: string + description: The certificate content in base64 format for + certificate-based authentication. user: type: email description: User microsoft email address. @@ -14138,8 +14219,8 @@ components: description: User password. required: - client_id - - client_secret - tenant_id + - certificate_content - user - password - type: object @@ -14394,6 +14475,33 @@ components: type: string description: The Azure tenant ID, representing the directory where the application is registered. + user: + type: email + description: 'Deprecated: User microsoft email address.' + password: + type: string + description: 'Deprecated: User password.' + required: + - client_id + - client_secret + - tenant_id + - user + - password + - type: object + title: M365 Certificate Credentials + properties: + client_id: + type: string + description: The Azure application (client) ID for authentication + in Azure AD. + tenant_id: + type: string + description: The Azure tenant ID, representing the directory where + the application is registered. + certificate_content: + type: string + description: The certificate content in base64 format for certificate-based + authentication. user: type: email description: User microsoft email address. @@ -14402,8 +14510,8 @@ components: description: User password. required: - client_id - - client_secret - tenant_id + - certificate_content - user - password - type: object diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index d94caa41f7..71f946116c 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -1870,17 +1870,7 @@ class TestProviderSecretViewSet: "kubeconfig_content": "kubeconfig-content", }, ), - # M365 with STATIC secret - no user or password - ( - Provider.ProviderChoices.M365.value, - ProviderSecret.TypeChoices.STATIC, - { - "client_id": "client-id", - "client_secret": "client-secret", - "tenant_id": "tenant-id", - }, - ), - # M365 with user only + # M365 client secret credentials ( Provider.ProviderChoices.M365.value, ProviderSecret.TypeChoices.STATIC, @@ -1889,27 +1879,17 @@ class TestProviderSecretViewSet: "client_secret": "client-secret", "tenant_id": "tenant-id", "user": "test@domain.com", - }, - ), - # M365 with password only - ( - Provider.ProviderChoices.M365.value, - ProviderSecret.TypeChoices.STATIC, - { - "client_id": "client-id", - "client_secret": "client-secret", - "tenant_id": "tenant-id", "password": "supersecret", }, ), - # M365 with user and password + # M365 certificate credentials (valid base64) ( Provider.ProviderChoices.M365.value, ProviderSecret.TypeChoices.STATIC, { "client_id": "client-id", - "client_secret": "client-secret", "tenant_id": "tenant-id", + "certificate_content": "VGVzdCBjZXJ0aWZpY2F0ZSBjb250ZW50", "user": "test@domain.com", "password": "supersecret", }, @@ -2279,6 +2259,50 @@ class TestProviderSecretViewSet: ) assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_m365_provider_secrets_invalid_certificate_base64( + self, authenticated_client, providers_fixture + ): + """Test M365 provider secret creation with invalid base64 certificate content""" + # Find M365 provider from fixture + m365_provider = None + for provider in providers_fixture: + if provider.provider == Provider.ProviderChoices.M365.value: + m365_provider = provider + break + + assert m365_provider is not None, "M365 provider not found in fixture" + + data = { + "data": { + "type": "provider-secrets", + "attributes": { + "name": "M365 Certificate Invalid Base64", + "secret_type": "static", + "secret": { + "client_id": "client-id", + "tenant_id": "tenant-id", + "certificate_content": "invalid-base64-content!@#$%", + "user": "test@domain.com", + "password": "supersecret", + }, + }, + "relationships": { + "provider": { + "data": {"type": "providers", "id": str(m365_provider.id)} + } + }, + } + } + response = authenticated_client.post( + reverse("providersecret-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "certificate content is not valid base64 encoded data" in str( + response.json() + ) + @pytest.mark.django_db class TestScanViewSet: @@ -5781,10 +5805,12 @@ class TestScheduleViewSet: ) assert response.status_code == status.HTTP_404_NOT_FOUND + @patch("tasks.beat.perform_scheduled_scan_task.apply_async") @patch("api.v1.views.Task.objects.get") def test_schedule_daily_already_scheduled( self, mock_task_get, + mock_apply_async, authenticated_client, providers_fixture, tasks_fixture, @@ -5792,6 +5818,7 @@ class TestScheduleViewSet: provider, *_ = providers_fixture prowler_task = tasks_fixture[0] mock_task_get.return_value = prowler_task + mock_apply_async.return_value.id = prowler_task.id json_payload = { "provider_id": str(provider.id), } diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index 76fa0b4911..522df89ee6 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -115,6 +115,40 @@ from rest_framework_json_api import serializers "description": "The Azure tenant ID, representing the directory where the application is " "registered.", }, + "user": { + "type": "email", + "description": "Deprecated: User microsoft email address.", + }, + "password": { + "type": "string", + "description": "Deprecated: User password.", + }, + }, + "required": [ + "client_id", + "client_secret", + "tenant_id", + "user", + "password", + ], + }, + { + "type": "object", + "title": "M365 Certificate Credentials", + "properties": { + "client_id": { + "type": "string", + "description": "The Azure application (client) ID for authentication in Azure AD.", + }, + "tenant_id": { + "type": "string", + "description": "The Azure tenant ID, representing the directory where the application is " + "registered.", + }, + "certificate_content": { + "type": "string", + "description": "The certificate content in base64 format for certificate-based authentication.", + }, "user": { "type": "email", "description": "User microsoft email address.", @@ -126,8 +160,8 @@ from rest_framework_json_api import serializers }, "required": [ "client_id", - "client_secret", "tenant_id", + "certificate_content", "user", "password", ], diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 32ebbb040d..70e002cd10 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1,3 +1,4 @@ +import base64 import json from datetime import datetime, timedelta, timezone @@ -1395,10 +1396,38 @@ class AzureProviderSecret(serializers.Serializer): class M365ProviderSecret(serializers.Serializer): client_id = serializers.CharField() - client_secret = serializers.CharField() + client_secret = serializers.CharField(required=False) tenant_id = serializers.CharField() user = serializers.EmailField(required=False) password = serializers.CharField(required=False) + certificate_content = serializers.CharField(required=False) + + def validate(self, attrs): + if attrs.get("client_secret") and attrs.get("certificate_content"): + raise serializers.ValidationError( + "You cannot provide both client_secret and certificate_content." + ) + if not attrs.get("client_secret") and not attrs.get("certificate_content"): + raise serializers.ValidationError( + "You must provide either client_secret or certificate_content." + ) + return super().validate(attrs) + + def validate_certificate_content(self, certificate_content): + """Validate that M365 certificate content is valid base64 encoded data.""" + if certificate_content: + try: + base64.b64decode(certificate_content, validate=True) + except Exception as e: + raise ValidationError( + { + "certificate_content": [ + f"The provided certificate content is not valid base64 encoded data: {str(e)}" + ] + }, + code="m365-certificate-content", + ) + return certificate_content class Meta: resource_name = "provider-secrets" From 1483efa18e8387520ad699beb0712c50c1c4dcb3 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Fri, 10 Oct 2025 10:43:11 +0200 Subject: [PATCH 11/22] feat(m365): add M365 certificate auth to API (#8538) From ef60ea99c324face7cf323045f624ac2555af5aa Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 10 Oct 2025 10:47:04 +0200 Subject: [PATCH 12/22] fix(api): throw errors for all non-ok responses (#8880) --- ui/CHANGELOG.md | 7 ++++--- ui/actions/integrations/saml.ts | 13 ++++++++++++- ui/types/authFormSchema.ts | 2 +- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index e0bd99a446..357a5ee90a 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,8 +7,8 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added - Support for Markdown and AdditionalURLs in findings detail page [(#8704)](https://github.com/prowler-cloud/prowler/pull/8704) -- `Prowler Hub` menu item with tooltip [(#8692)] (https://github.com/prowler-cloud/prowler/pull/8692) -- Copy link button to finding detail page [(#8685)] (https://github.com/prowler-cloud/prowler/pull/8685) +- `Prowler Hub` menu item with tooltip [(#8692)](https://github.com/prowler-cloud/prowler/pull/8692) +- Copy link button to finding detail page [(#8685)](https://github.com/prowler-cloud/prowler/pull/8685) - React Compiler support for automatic optimization [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - Turbopack support for faster development builds [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - Add compliance name in compliance detail view [(#8775)](https://github.com/prowler-cloud/prowler/pull/8775) @@ -28,6 +28,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed +- SAML configuration errors are now properly caught and displayed [(#8880)](https://github.com/prowler-cloud/prowler/pull/8880) - ThreatScore for each pillar in Prowler ThreatScore specific view [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582) --- @@ -96,7 +97,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Disable `See Compliance` button until scan completes [(#8487)](https://github.com/prowler-cloud/prowler/pull/8487) - Provider connection filter now shows "Connected/Disconnected" instead of "true/false" for better UX [(#8520)](https://github.com/prowler-cloud/prowler/pull/8520) -- Provider Uid filter on scan page to list all UIDs regardless of connection status [(#8375)] (https://github.com/prowler-cloud/prowler/pull/8375) +- Provider Uid filter on scan page to list all UIDs regardless of connection status [(#8375)](https://github.com/prowler-cloud/prowler/pull/8375) ### 🐞 Fixed diff --git a/ui/actions/integrations/saml.ts b/ui/actions/integrations/saml.ts index d7e2750028..88febcbc44 100644 --- a/ui/actions/integrations/saml.ts +++ b/ui/actions/integrations/saml.ts @@ -40,7 +40,18 @@ export const createSamlConfig = async (_prevState: any, formData: FormData) => { }), }); - await handleApiResponse(response, "/integrations", false); + const result = await handleApiResponse(response, "/integrations", false); + if (result.error) { + return { + errors: { + general: + result.error instanceof Error + ? result.error.message + : "Error creating SAML configuration. Please try again.", + }, + }; + } + return { success: "SAML configuration created successfully!" }; } catch (error) { console.error("Error creating SAML config:", error); diff --git a/ui/types/authFormSchema.ts b/ui/types/authFormSchema.ts index 72ba742413..42e1d1d594 100644 --- a/ui/types/authFormSchema.ts +++ b/ui/types/authFormSchema.ts @@ -75,7 +75,7 @@ const baseAuthSchema = z.object({ export const signInSchema = baseAuthSchema .extend({ - password: z.string().min(1, { message: "Password is required." }), + password: z.string(), }) .refine( (data) => { From 046baa8eb9f334e4c19c444818e2fa3aae70642c Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:02:10 +0200 Subject: [PATCH 13/22] feat(ui): refreshToken implementation (#8864) --- ui/CHANGELOG.md | 1 + ui/auth.config.ts | 320 ++++++++++++++------ ui/components/auth/oss/sign-in-form.tsx | 32 ++ ui/middleware.ts | 13 +- ui/nextauth.d.ts | 25 +- ui/tests/auth-login.spec.ts | 10 +- ui/tests/auth-middleware-error.spec.ts | 92 ++++++ ui/tests/auth-refresh-token.spec.ts | 124 ++++++++ ui/tests/auth-session-error-message.spec.ts | 102 +++++++ ui/tests/helpers.ts | 16 +- 10 files changed, 618 insertions(+), 117 deletions(-) create mode 100644 ui/tests/auth-middleware-error.spec.ts create mode 100644 ui/tests/auth-refresh-token.spec.ts create mode 100644 ui/tests/auth-session-error-message.spec.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 357a5ee90a..e03ba49540 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file. - React Compiler support for automatic optimization [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - Turbopack support for faster development builds [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - Add compliance name in compliance detail view [(#8775)](https://github.com/prowler-cloud/prowler/pull/8775) +- Refresh access token error handling [(#8864)](https://github.com/prowler-cloud/prowler/pull/8864) ### 🔄 Changed diff --git a/ui/auth.config.ts b/ui/auth.config.ts index f2b9db8811..ff3875b8ef 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -1,55 +1,199 @@ -import { jwtDecode, JwtPayload } from "jwt-decode"; -import NextAuth, { type NextAuthConfig, User } from "next-auth"; +import { jwtDecode, type JwtPayload } from "jwt-decode"; +import NextAuth, { + type DefaultSession, + type NextAuthConfig, + type Session, + User, +} from "next-auth"; +import type { JWT } from "next-auth/jwt"; import Credentials from "next-auth/providers/credentials"; import { z } from "zod"; import { getToken, getUserByMe } from "./actions/auth"; import { apiBaseUrl } from "./lib"; +import type { RolePermissionAttributes } from "./types/users"; interface CustomJwtPayload extends JwtPayload { user_id: string; tenant_id: string; } -const refreshAccessToken = async (token: JwtPayload) => { +type DefaultSessionUser = NonNullable; + +type TokenUser = DefaultSessionUser & { + companyName?: string; + dateJoined?: string; + permissions: RolePermissionAttributes; +}; + +type AuthToken = JWT & { + accessToken?: string; + refreshToken?: string; + accessTokenExpires?: number; + user_id?: string; + tenant_id?: string; + user?: TokenUser; + error?: string; +}; + +type ExtendedSession = Session & { + user?: TokenUser; + userId?: string; + tenantId?: string; + accessToken?: string; + refreshToken?: string; + error?: string; +}; + +const DEFAULT_PERMISSIONS: RolePermissionAttributes = { + manage_users: false, + manage_account: false, + manage_providers: false, + manage_scans: false, + manage_integrations: false, + manage_billing: false, + unlimited_visibility: false, +}; + +type TokenUserInput = Partial & { company?: string }; + +const toTokenUser = (user?: TokenUserInput): TokenUser => + ({ + name: user?.name ?? undefined, + email: user?.email ?? undefined, + companyName: user?.companyName ?? user?.company, + dateJoined: user?.dateJoined, + permissions: user?.permissions ?? { ...DEFAULT_PERMISSIONS }, + }) as TokenUser; + +type UserMeResponse = Awaited>; + +const tokenUserFromApi = (user: UserMeResponse) => + toTokenUser({ + name: user.name, + email: user.email, + companyName: user.company, + dateJoined: user.dateJoined, + permissions: user.permissions, + }); + +const applyDecodedClaims = ( + target: AuthToken, + accessToken?: string, + logContext = "access token", +) => { + if (!accessToken) return; + + try { + const decodedToken = jwtDecode(accessToken); + target.accessTokenExpires = decodedToken.exp + ? decodedToken.exp * 1000 + : target.accessTokenExpires; + target.user_id = decodedToken.user_id ?? target.user_id; + target.tenant_id = decodedToken.tenant_id ?? target.tenant_id; + } catch (decodeError) { + // eslint-disable-next-line no-console + console.warn(`Unable to decode ${logContext}`, decodeError); + } +}; + +const refreshTokenPromises = new Map>(); + +const refreshAccessToken = async (token: AuthToken): Promise => { + const refreshToken = token.refreshToken; + + if (!refreshToken) { + return { + ...token, + error: "MissingRefreshToken", + }; + } + + const existingPromise = refreshTokenPromises.get(refreshToken); + + if (existingPromise) { + return existingPromise; + } + const url = new URL(`${apiBaseUrl}/tokens/refresh`); const bodyData = { data: { type: "tokens-refresh", attributes: { - refresh: (token as any).refreshToken, + refresh: refreshToken, }, }, }; - try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/vnd.api+json", - Accept: "application/vnd.api+json", - }, - body: JSON.stringify(bodyData), - }); + const refreshPromise = (async () => { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + }, + body: JSON.stringify(bodyData), + }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const payload = await response.json().catch(() => undefined); + + if (!response.ok) { + const detail = payload?.errors?.[0]?.detail; + // eslint-disable-next-line no-console + console.warn( + "Failed to refresh access token:", + detail || `HTTP error ${response.status}`, + ); + return { + ...token, + error: "RefreshAccessTokenError", + }; + } + + const newAccessToken = payload?.data?.attributes?.access as + | string + | undefined; + const nextRefreshToken = + (payload?.data?.attributes?.refresh as string | undefined) ?? + refreshToken; + + if (!newAccessToken) { + // eslint-disable-next-line no-console + console.warn("Missing access token in refresh response"); + return { + ...token, + error: "RefreshAccessTokenError", + }; + } + + const nextToken: AuthToken = { + ...token, + accessToken: newAccessToken, + refreshToken: nextRefreshToken, + error: undefined, + }; + + applyDecodedClaims(nextToken, newAccessToken, "refreshed access token"); + + return nextToken; + } catch (error) { + // eslint-disable-next-line no-console + console.warn("Error refreshing access token:", error); + return { + ...token, + error: "RefreshAccessTokenError", + }; } + })(); - const newTokens = await response.json(); + refreshTokenPromises.set(refreshToken, refreshPromise); - return { - ...token, - accessToken: newTokens.data.attributes.access, - refreshToken: newTokens.data.attributes.refresh, - }; - } catch (error) { - // eslint-disable-next-line no-console - console.error("Error refreshing access token:", error); - return { - error: "RefreshAccessTokenError", - }; + try { + return await refreshPromise; + } finally { + refreshTokenPromises.delete(refreshToken); } }; @@ -90,13 +234,7 @@ export const authConfig = { const userMeResponse = await getUserByMe(tokenResponse.accessToken); - const user = { - name: userMeResponse.name, - email: userMeResponse.email, - company: userMeResponse?.company, - dateJoined: userMeResponse.dateJoined, - permissions: userMeResponse.permissions, - }; + const user = tokenUserFromApi(userMeResponse); return { ...user, @@ -122,14 +260,7 @@ export const authConfig = { try { const userMeResponse = await getUserByMe(accessToken as string); - const user = { - name: userMeResponse.name, - email: userMeResponse.email, - company: userMeResponse?.company, - dateJoined: userMeResponse.dateJoined, - - permissions: userMeResponse.permissions, - }; + const user = tokenUserFromApi(userMeResponse); return { ...user, @@ -137,7 +268,6 @@ export const authConfig = { refreshToken: credentials.refreshToken, }; } catch (error) { - // eslint-disable-next-line no-console console.error("Error in authorize:", error); return null; } @@ -162,74 +292,68 @@ export const authConfig = { }, jwt: async ({ token, account, user }) => { - if (token?.accessToken) { - const decodedToken = jwtDecode( - token.accessToken as string, - ) as CustomJwtPayload; - // eslint-disable-next-line no-console - // console.log("decodedToken", decodedToken); - token.accessTokenExpires = (decodedToken.exp as number) * 1000; - token.user_id = decodedToken.user_id; - token.tenant_id = decodedToken.tenant_id; - } + const authToken = token as AuthToken; - const userInfo = { - name: user?.name, - companyName: user?.company, - email: user?.email, - dateJoined: user?.dateJoined, - permissions: user?.permissions || { - manage_users: false, - manage_account: false, - manage_providers: false, - manage_scans: false, - manage_integrations: false, - manage_billing: false, - unlimited_visibility: false, - }, - }; + applyDecodedClaims(authToken, authToken.accessToken); if (account && user) { - return { - ...token, - userId: token.user_id, - tenantId: token.tenant_id, - accessToken: (user as User & { accessToken: JwtPayload }).accessToken, - refreshToken: (user as User & { refreshToken: JwtPayload }) - .refreshToken, - user: userInfo, + const signedInUser = user as User & + TokenUserInput & { + accessToken: string; + refreshToken: string; + }; + + const nextAuthToken: AuthToken = { + ...authToken, + accessToken: signedInUser.accessToken, + refreshToken: signedInUser.refreshToken, + user: toTokenUser(signedInUser), + error: undefined, }; + + applyDecodedClaims( + nextAuthToken, + signedInUser.accessToken, + "access token on sign-in", + ); + + return nextAuthToken; } - // eslint-disable-next-line no-console - // console.log( - // "Access token expires", - // token.accessTokenExpires, - // new Date(Number(token.accessTokenExpires)), - // ); - - // If the access token is not expired, return the token if ( - typeof token.accessTokenExpires === "number" && - Date.now() < token.accessTokenExpires - ) - return token; + typeof authToken.accessTokenExpires === "number" && + Date.now() < authToken.accessTokenExpires + ) { + return authToken; + } - // If the access token is expired, try to refresh it - return refreshAccessToken(token as JwtPayload); + return refreshAccessToken(authToken); }, session: async ({ session, token }) => { - if (token) { - session.userId = token?.user_id as string; - session.tenantId = token?.tenant_id as string; - session.accessToken = token?.accessToken as string; - session.refreshToken = token?.refreshToken as string; - session.user = token.user as any; + const authToken = token as AuthToken; + const nextSession = { ...session } as ExtendedSession; + + if (authToken?.error) { + nextSession.error = authToken.error; + nextSession.user = undefined; + nextSession.userId = undefined; + nextSession.tenantId = undefined; + nextSession.accessToken = undefined; + nextSession.refreshToken = undefined; + return nextSession; } - // console.log("session", session); - return session; + nextSession.error = undefined; + nextSession.userId = authToken.user_id ?? nextSession.userId; + nextSession.tenantId = authToken.tenant_id ?? nextSession.tenantId; + nextSession.accessToken = + authToken.accessToken ?? nextSession.accessToken; + nextSession.refreshToken = + authToken.refreshToken ?? nextSession.refreshToken; + nextSession.user = authToken.user ?? nextSession.user; + + return nextSession; }, }, } satisfies NextAuthConfig; diff --git a/ui/components/auth/oss/sign-in-form.tsx b/ui/components/auth/oss/sign-in-form.tsx index 0f5a109040..4c2ce66a93 100644 --- a/ui/components/auth/oss/sign-in-form.tsx +++ b/ui/components/auth/oss/sign-in-form.tsx @@ -35,6 +35,7 @@ export const SignInForm = ({ useEffect(() => { const samlError = searchParams.get("sso_saml_failed"); + const sessionError = searchParams.get("error"); if (samlError) { setTimeout(() => { @@ -46,6 +47,37 @@ export const SignInForm = ({ }); }, 100); } + + if (sessionError) { + setTimeout(() => { + const errorMessages: Record< + string, + { title: string; description: string } + > = { + RefreshAccessTokenError: { + title: "Session Expired", + description: + "Your session has expired. Please sign in again to continue.", + }, + MissingRefreshToken: { + title: "Session Error", + description: + "There was a problem with your session. Please sign in again.", + }, + }; + + const errorConfig = errorMessages[sessionError] || { + title: "Authentication Error", + description: "Please sign in again to continue.", + }; + + toast({ + variant: "destructive", + title: errorConfig.title, + description: errorConfig.description, + }); + }, 100); + } }, [searchParams, toast]); const form = useForm({ diff --git a/ui/middleware.ts b/ui/middleware.ts index d332bc03e8..83e5bb9c92 100644 --- a/ui/middleware.ts +++ b/ui/middleware.ts @@ -18,9 +18,20 @@ const isPublicRoute = (pathname: string): boolean => { export default auth((req: NextRequest & { auth: any }) => { const { pathname } = req.nextUrl; const user = req.auth?.user; + const sessionError = req.auth?.error; + + // If there's a session error (e.g., RefreshAccessTokenError), redirect to login with error info + if (sessionError && !isPublicRoute(pathname)) { + const signInUrl = new URL("/sign-in", req.url); + signInUrl.searchParams.set("error", sessionError); + signInUrl.searchParams.set("callbackUrl", pathname); + return NextResponse.redirect(signInUrl); + } if (!user && !isPublicRoute(pathname)) { - return NextResponse.redirect(new URL("/sign-in", req.url)); + const signInUrl = new URL("/sign-in", req.url); + signInUrl.searchParams.set("callbackUrl", pathname); + return NextResponse.redirect(signInUrl); } if (user?.permissions) { diff --git a/ui/nextauth.d.ts b/ui/nextauth.d.ts index ffb22bc89b..7a70b19558 100644 --- a/ui/nextauth.d.ts +++ b/ui/nextauth.d.ts @@ -1,4 +1,4 @@ -import { DefaultSession } from "next-auth"; +import type { DefaultSession, User as NextAuthUser } from "next-auth"; import { RolePermissionAttributes } from "./types/users"; @@ -11,17 +11,18 @@ declare module "next-auth" { permissions?: RolePermissionAttributes; } + type SessionUser = NonNullable & { + companyName?: string; + dateJoined?: string; + permissions: RolePermissionAttributes; + }; + interface Session extends DefaultSession { - user: { - name: string; - email: string; - companyName?: string; - dateJoined: string; - permissions: RolePermissionAttributes; - } & DefaultSession["user"]; - userId: string; - tenantId: string; - accessToken: string; - refreshToken: string; + user?: SessionUser; + userId?: string; + tenantId?: string; + accessToken?: string; + refreshToken?: string; + error?: string; } } diff --git a/ui/tests/auth-login.spec.ts b/ui/tests/auth-login.spec.ts index 84dbc9b68f..fbe3fedebb 100644 --- a/ui/tests/auth-login.spec.ts +++ b/ui/tests/auth-login.spec.ts @@ -137,8 +137,8 @@ test.describe("Session Persistence", () => { }) => { // Try to access protected route without login await page.goto(URLS.DASHBOARD); - // Should be redirected to login page - await expect(page).toHaveURL(URLS.LOGIN); + // Should be redirected to login page (may include callbackUrl) + await expect(page).toHaveURL(/\/sign-in/); await expect(page.getByText("Sign in", { exact: true })).toBeVisible(); }); @@ -154,7 +154,7 @@ test.describe("Session Persistence", () => { // Verify cannot access protected route after logout await page.goto(URLS.DASHBOARD); - await expect(page).toHaveURL(URLS.LOGIN); + await expect(page).toHaveURL(/\/sign-in/); }); test("should handle session timeout gracefully", async ({ browser }) => { @@ -184,8 +184,8 @@ test.describe("Session Persistence", () => { waitUntil: "networkidle", }); - // Should be redirected to login since this context has no auth - await expect(unauthPage).toHaveURL(URLS.LOGIN); + // Should be redirected to login since this context has no auth (may include callbackUrl) + await expect(unauthPage).toHaveURL(/\/sign-in/); // Verify session is null in unauthenticated context const unauthResponse = await unauthPage.request.get("/api/auth/session"); diff --git a/ui/tests/auth-middleware-error.spec.ts b/ui/tests/auth-middleware-error.spec.ts new file mode 100644 index 0000000000..a69fa2ccdc --- /dev/null +++ b/ui/tests/auth-middleware-error.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from "@playwright/test"; +import { + goToLogin, + login, + verifySuccessfulLogin, + verifySessionValid, + TEST_CREDENTIALS, + URLS, +} from "./helpers"; + +test.describe("Middleware Error Handling", () => { + test("should allow access to public routes without session", async ({ + page, + context, + }) => { + // Ensure no session exists + await context.clearCookies(); + + // Try to access login page (public route) + await page.goto(URLS.LOGIN); + await expect(page).toHaveURL(URLS.LOGIN); + await expect(page.getByText("Sign in", { exact: true })).toBeVisible(); + + // Try to access sign-up page (public route) + await page.goto(URLS.SIGNUP); + await expect(page).toHaveURL(URLS.SIGNUP); + }); + + test("should maintain protection after session error", async ({ + page, + context, + }) => { + // Login + await goToLogin(page); + await login(page, TEST_CREDENTIALS.VALID); + await verifySuccessfulLogin(page); + + // Navigate to a protected page + await page.goto("/providers"); + await expect(page).toHaveURL("/providers"); + + // Simulate session error by corrupting cookie + const cookies = await context.cookies(); + const sessionCookie = cookies.find((c) => + c.name.includes("authjs.session-token"), + ); + + if (sessionCookie) { + await context.clearCookies(); + await context.addCookies([ + { + ...sessionCookie, + value: "invalid-session-token", + }, + ]); + + // Try to navigate to another protected page + await page.goto("/scans", { waitUntil: "networkidle" }); + + // Should be redirected to login (may include callbackUrl) + await expect(page).toHaveURL(/\/sign-in/); + } + }); + + test("should handle permission-based redirects", async ({ page }) => { + // Login with valid credentials + await goToLogin(page); + await login(page, TEST_CREDENTIALS.VALID); + await verifySuccessfulLogin(page); + + // Get user permissions using helper + const session = await verifySessionValid(page); + const permissions = session.user.permissions; + + // Test billing route if user doesn't have permission + if (!permissions.manage_billing) { + await page.goto("/billing", { waitUntil: "networkidle" }); + + // Should be redirected to profile (as per middleware logic) + await expect(page).toHaveURL("/profile"); + } + + // Test integrations route if user doesn't have permission + if (!permissions.manage_integrations) { + await page.goto("/integrations", { waitUntil: "networkidle" }); + + // Should be redirected to profile (as per middleware logic) + await expect(page).toHaveURL("/profile"); + } + }); + +}); diff --git a/ui/tests/auth-refresh-token.spec.ts b/ui/tests/auth-refresh-token.spec.ts new file mode 100644 index 0000000000..0b5a66ab17 --- /dev/null +++ b/ui/tests/auth-refresh-token.spec.ts @@ -0,0 +1,124 @@ +import { test, expect } from "@playwright/test"; +import { + goToLogin, + login, + verifySuccessfulLogin, + getSession, + verifySessionValid, + TEST_CREDENTIALS, + URLS, +} from "./helpers"; + +test.describe("Token Refresh Flow", () => { + test("should refresh access token when expired", async ({ page }) => { + // Login first + await goToLogin(page); + await login(page, TEST_CREDENTIALS.VALID); + await verifySuccessfulLogin(page); + + // Get initial session using helper + const initialSession = await verifySessionValid(page); + const initialAccessToken = initialSession.accessToken; + + // Wait for some time to allow token to potentially expire + // In a real scenario, you might want to manipulate the token expiry + await page.waitForTimeout(2000); + + // Make a request that requires authentication + // This should trigger token refresh if needed + await page.reload(); + await page.waitForLoadState("networkidle"); + + // Verify we're still authenticated + await expect(page).toHaveURL(URLS.DASHBOARD); + + // Get session after potential refresh using helper + const refreshedSession = await verifySessionValid(page); + + // User data should be maintained + expect(refreshedSession.user.email).toBe(initialSession.user.email); + expect(refreshedSession.userId).toBe(initialSession.userId); + expect(refreshedSession.tenantId).toBe(initialSession.tenantId); + }); + + test("should handle concurrent requests with token refresh", async ({ + page, + }) => { + // Login + await goToLogin(page); + await login(page, TEST_CREDENTIALS.VALID); + await verifySuccessfulLogin(page); + + // Make multiple concurrent requests to the API + const requests = Array(5) + .fill(null) + .map(() => page.request.get("/api/auth/session")); + + const responses = await Promise.all(requests); + + // All requests should succeed - verify using helper + for (const response of responses) { + expect(response.ok()).toBeTruthy(); + const session = await response.json(); + + // Validate session structure + expect(session).toBeTruthy(); + expect(session.user).toBeTruthy(); + expect(session.accessToken).toBeTruthy(); + expect(session.refreshToken).toBeTruthy(); + expect(session.error).toBeUndefined(); + } + }); + + test("should preserve user permissions after token refresh", async ({ + page, + }) => { + // Login + await goToLogin(page); + await login(page, TEST_CREDENTIALS.VALID); + await verifySuccessfulLogin(page); + + // Get initial session with permissions using helper + const initialSession = await verifySessionValid(page); + const initialPermissions = initialSession.user.permissions; + + // Reload page to potentially trigger token refresh + await page.reload(); + await page.waitForLoadState("networkidle"); + + // Get session after reload using helper + const refreshedSession = await verifySessionValid(page); + + // Permissions should be preserved + expect(refreshedSession.user.permissions).toEqual(initialPermissions); + + // All user data should be preserved + expect(refreshedSession.user.email).toBe(initialSession.user.email); + expect(refreshedSession.user.name).toBe(initialSession.user.name); + expect(refreshedSession.user.companyName).toBe( + initialSession.user.companyName, + ); + }); + + test("should clear session when cookies are removed", async ({ + page, + context, + }) => { + // Login + await goToLogin(page); + await login(page, TEST_CREDENTIALS.VALID); + await verifySuccessfulLogin(page); + + // Verify session is valid using helper + await verifySessionValid(page); + + // Clear all cookies to simulate complete session expiry + await context.clearCookies(); + + // Verify session is null after clearing cookies + const expiredSession = await getSession(page); + expect(expiredSession).toBeNull(); + + // Note: Middleware redirect behavior is tested in auth-middleware-error.spec.ts + }); +}); diff --git a/ui/tests/auth-session-error-message.spec.ts b/ui/tests/auth-session-error-message.spec.ts new file mode 100644 index 0000000000..07f96abc1d --- /dev/null +++ b/ui/tests/auth-session-error-message.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from "@playwright/test"; +import { + goToLogin, + login, + verifySuccessfulLogin, + TEST_CREDENTIALS, + URLS, +} from "./helpers"; + +test.describe("Session Error Messages", () => { + test("should show RefreshAccessTokenError message", async ({ page }) => { + // Navigate to sign-in with RefreshAccessTokenError query param + await page.goto("/sign-in?error=RefreshAccessTokenError"); + + // Wait for toast notification + await page.waitForTimeout(200); + + // Verify error toast appears + const toast = page.locator('[role="status"], [role="alert"]').first(); + + const isVisible = await toast.isVisible().catch(() => false); + + if (isVisible) { + const text = await toast.textContent(); + expect(text).toContain("Session Expired"); + expect(text).toContain("Please sign in again"); + } + + // Verify sign-in form is displayed + await expect(page.getByLabel("Email")).toBeVisible(); + await expect(page.getByLabel("Password")).toBeVisible(); + }); + + test("should show MissingRefreshToken error message", async ({ page }) => { + // Navigate to sign-in with MissingRefreshToken query param + await page.goto("/sign-in?error=MissingRefreshToken"); + + // Wait for toast notification + await page.waitForTimeout(200); + + // Verify error toast appears + const toast = page.locator('[role="status"], [role="alert"]').first(); + + const isVisible = await toast.isVisible().catch(() => false); + + if (isVisible) { + const text = await toast.textContent(); + expect(text).toContain("Session Error"); + } + + // Verify sign-in form is displayed + await expect(page.getByLabel("Email")).toBeVisible(); + }); + + test("should show generic error for unknown error types", async ({ page }) => { + // Navigate to sign-in with unknown error type + await page.goto("/sign-in?error=UnknownError"); + + // Wait for toast notification + await page.waitForTimeout(200); + + // Verify generic error toast appears + const toast = page.locator('[role="status"], [role="alert"]').first(); + + const isVisible = await toast.isVisible().catch(() => false); + + if (isVisible) { + const text = await toast.textContent(); + expect(text).toContain("Authentication Error"); + expect(text).toContain("Please sign in again"); + } + }); + + test("should include callbackUrl in redirect", async ({ + page, + context, + }) => { + // Login first + await goToLogin(page); + await login(page, TEST_CREDENTIALS.VALID); + await verifySuccessfulLogin(page); + + // Navigate to a specific page + await page.goto("/scans"); + await page.waitForLoadState("networkidle"); + + // Clear cookies to simulate session expiry + await context.clearCookies(); + + // Try to navigate to a different protected route + await page.goto("/providers"); + + // Should be redirected to login with callbackUrl + await expect(page).toHaveURL(/\/sign-in\?.*callbackUrl=/); + + // Verify callbackUrl contains the attempted route + const url = new URL(page.url()); + const callbackUrl = url.searchParams.get("callbackUrl"); + expect(callbackUrl).toBe("/providers"); + }); + +}); diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index 51a4297427..9f5efb0fdf 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -100,7 +100,7 @@ export async function logout(page: Page) { } export async function verifyLogoutSuccess(page: Page) { - await expect(page).toHaveURL("/sign-in"); + await expect(page).toHaveURL(/\/sign-in/); await expect(page.getByText("Sign in", { exact: true })).toBeVisible(); } @@ -137,3 +137,17 @@ export async function waitForPageLoad(page: Page) { export async function verifyDashboardRoute(page: Page) { await expect(page).toHaveURL("/"); } + +export async function getSession(page: Page) { + const response = await page.request.get("/api/auth/session"); + return response.json(); +} + +export async function verifySessionValid(page: Page) { + const session = await getSession(page); + expect(session).toBeTruthy(); + expect(session.user).toBeTruthy(); + expect(session.accessToken).toBeTruthy(); + expect(session.refreshToken).toBeTruthy(); + return session; +} From 335db928dcf87c86bd6539667b5e258f6400eb15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Fri, 10 Oct 2025 12:27:43 +0200 Subject: [PATCH 14/22] feat(database): add db read replica support (#8869) --- .env | 6 + api/CHANGELOG.md | 1 + api/src/backend/api/base_views.py | 101 ++- api/src/backend/api/compliance.py | 16 +- api/src/backend/api/db_router.py | 30 + api/src/backend/api/db_utils.py | 44 +- api/src/backend/api/renderers.py | 3 +- api/src/backend/api/v1/views.py | 28 +- api/src/backend/config/django/devel.py | 31 +- api/src/backend/config/django/production.py | 33 +- api/src/backend/tasks/jobs/integrations.py | 3 +- api/src/backend/tasks/jobs/scan.py | 256 +++++-- api/src/backend/tasks/tasks.py | 108 +-- api/src/backend/tasks/tests/test_scan.py | 777 +++++++++++++++++++- 14 files changed, 1267 insertions(+), 170 deletions(-) diff --git a/.env b/.env index 186ae3a6d0..7f739e01e5 100644 --- a/.env +++ b/.env @@ -29,6 +29,12 @@ POSTGRES_ADMIN_PASSWORD=postgres POSTGRES_USER=prowler POSTGRES_PASSWORD=postgres POSTGRES_DB=prowler_db +# Read replica settings (optional) +# POSTGRES_REPLICA_HOST=postgres-db +# POSTGRES_REPLICA_PORT=5432 +# POSTGRES_REPLICA_USER=prowler +# POSTGRES_REPLICA_PASSWORD=postgres +# POSTGRES_REPLICA_DB=prowler_db # Celery-Prowler task settings TASK_RETRY_DELAY_SECONDS=0.1 diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 3b78e84bb1..8fe04dfeba 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to the **Prowler API** are documented in this file. - API Key support [(#8805)](https://github.com/prowler-cloud/prowler/pull/8805) - SAML role mapping protection for single-admin tenants to prevent accidental lockout [(#8882)](https://github.com/prowler-cloud/prowler/pull/8882) - Support for `passed_findings` and `total_findings` fields in compliance requirement overview for accurate Prowler ThreatScore calculation [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582) +- Database read replica support [(#8869)](https://github.com/prowler-cloud/prowler/pull/8869) ### Changed - Now the MANAGE_ACCOUNT permission is required to modify or read user permissions instead of MANAGE_USERS [(#8281)](https://github.com/prowler-cloud/prowler/pull/8281) diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py index 36455c56b7..7cdf8fdd7f 100644 --- a/api/src/backend/api/base_views.py +++ b/api/src/backend/api/base_views.py @@ -1,13 +1,15 @@ +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from rest_framework import permissions from rest_framework.exceptions import NotAuthenticated from rest_framework.filters import SearchFilter +from rest_framework.permissions import SAFE_METHODS from rest_framework_json_api import filters from rest_framework_json_api.views import ModelViewSet from api.authentication import CombinedJWTOrAPIKeyAuthentication -from api.db_router import MainRouter +from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias from api.db_utils import POSTGRES_USER_VAR, rls_transaction from api.filters import CustomDjangoFilterBackend from api.models import Role, Tenant @@ -31,6 +33,20 @@ class BaseViewSet(ModelViewSet): ordering_fields = "__all__" ordering = ["id"] + def _get_request_db_alias(self, request): + if request is None: + return MainRouter.default_db + + read_alias = ( + MainRouter.replica_db + if request.method in SAFE_METHODS + and MainRouter.replica_db in settings.DATABASES + else None + ) + if read_alias: + return read_alias + return MainRouter.default_db + def initial(self, request, *args, **kwargs): """ Sets required_permissions before permissions are checked. @@ -48,8 +64,21 @@ class BaseViewSet(ModelViewSet): class BaseRLSViewSet(BaseViewSet): def dispatch(self, request, *args, **kwargs): - with transaction.atomic(): - return super().dispatch(request, *args, **kwargs) + self.db_alias = self._get_request_db_alias(request) + alias_token = None + try: + if self.db_alias != MainRouter.default_db: + alias_token = set_read_db_alias(self.db_alias) + + if request is not None: + request.db_alias = self.db_alias + + with transaction.atomic(using=self.db_alias): + return super().dispatch(request, *args, **kwargs) + finally: + if alias_token is not None: + reset_read_db_alias(alias_token) + self.db_alias = MainRouter.default_db def initial(self, request, *args, **kwargs): # Ideally, this logic would be in the `.setup()` method but DRF view sets don't call it @@ -61,7 +90,9 @@ class BaseRLSViewSet(BaseViewSet): if tenant_id is None: raise NotAuthenticated("Tenant ID is not present in token") - with rls_transaction(tenant_id): + with rls_transaction( + tenant_id, using=getattr(self, "db_alias", MainRouter.default_db) + ): self.request.tenant_id = tenant_id return super().initial(request, *args, **kwargs) @@ -73,18 +104,33 @@ class BaseRLSViewSet(BaseViewSet): class BaseTenantViewset(BaseViewSet): def dispatch(self, request, *args, **kwargs): - with transaction.atomic(): - tenant = super().dispatch(request, *args, **kwargs) - + self.db_alias = self._get_request_db_alias(request) + alias_token = None try: - # If the request is a POST, create the admin role - if request.method == "POST": - isinstance(tenant, dict) and self._create_admin_role(tenant.data["id"]) - except Exception as e: - self._handle_creation_error(e, tenant) - raise + if self.db_alias != MainRouter.default_db: + alias_token = set_read_db_alias(self.db_alias) - return tenant + if request is not None: + request.db_alias = self.db_alias + + with transaction.atomic(using=self.db_alias): + tenant = super().dispatch(request, *args, **kwargs) + + try: + # If the request is a POST, create the admin role + if request.method == "POST": + isinstance(tenant, dict) and self._create_admin_role( + tenant.data["id"] + ) + except Exception as e: + self._handle_creation_error(e, tenant) + raise + + return tenant + finally: + if alias_token is not None: + reset_read_db_alias(alias_token) + self.db_alias = MainRouter.default_db def _create_admin_role(self, tenant_id): Role.objects.using(MainRouter.admin_db).create( @@ -117,14 +163,31 @@ class BaseTenantViewset(BaseViewSet): raise NotAuthenticated("Tenant ID is not present in token") user_id = str(request.user.id) - with rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR): + with rls_transaction( + value=user_id, + parameter=POSTGRES_USER_VAR, + using=getattr(self, "db_alias", MainRouter.default_db), + ): return super().initial(request, *args, **kwargs) class BaseUserViewset(BaseViewSet): def dispatch(self, request, *args, **kwargs): - with transaction.atomic(): - return super().dispatch(request, *args, **kwargs) + self.db_alias = self._get_request_db_alias(request) + alias_token = None + try: + if self.db_alias != MainRouter.default_db: + alias_token = set_read_db_alias(self.db_alias) + + if request is not None: + request.db_alias = self.db_alias + + with transaction.atomic(using=self.db_alias): + return super().dispatch(request, *args, **kwargs) + finally: + if alias_token is not None: + reset_read_db_alias(alias_token) + self.db_alias = MainRouter.default_db def initial(self, request, *args, **kwargs): # TODO refactor after improving RLS on users @@ -137,6 +200,8 @@ class BaseUserViewset(BaseViewSet): if tenant_id is None: raise NotAuthenticated("Tenant ID is not present in token") - with rls_transaction(tenant_id): + with rls_transaction( + tenant_id, using=getattr(self, "db_alias", MainRouter.default_db) + ): self.request.tenant_id = tenant_id return super().initial(request, *args, **kwargs) diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 832b1745eb..291cbb757e 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -150,12 +150,16 @@ def generate_scan_compliance( requirement["checks"][check_id] = status requirement["checks_status"][status.lower()] += 1 - if requirement["status"] != "FAIL" and any( - value == "FAIL" for value in requirement["checks"].values() - ): - requirement["status"] = "FAIL" - compliance_overview[compliance_id]["requirements_status"]["passed"] -= 1 - compliance_overview[compliance_id]["requirements_status"]["failed"] += 1 + if requirement["status"] != "FAIL" and any( + value == "FAIL" for value in requirement["checks"].values() + ): + requirement["status"] = "FAIL" + compliance_overview[compliance_id]["requirements_status"][ + "passed" + ] -= 1 + compliance_overview[compliance_id]["requirements_status"][ + "failed" + ] += 1 def generate_compliance_overview_template(prowler_compliance: dict): diff --git a/api/src/backend/api/db_router.py b/api/src/backend/api/db_router.py index dc34c1191a..76b2ff7452 100644 --- a/api/src/backend/api/db_router.py +++ b/api/src/backend/api/db_router.py @@ -1,9 +1,31 @@ +from contextvars import ContextVar + +from django.conf import settings + ALLOWED_APPS = ("django", "socialaccount", "account", "authtoken", "silk") +_read_db_alias = ContextVar("read_db_alias", default=None) + + +def set_read_db_alias(alias: str | None): + if not alias: + return None + return _read_db_alias.set(alias) + + +def get_read_db_alias() -> str | None: + return _read_db_alias.get() + + +def reset_read_db_alias(token) -> None: + if token is not None: + _read_db_alias.reset(token) + class MainRouter: default_db = "default" admin_db = "admin" + replica_db = "replica" def db_for_read(self, model, **hints): # noqa: F841 model_table_name = model._meta.db_table @@ -11,6 +33,9 @@ class MainRouter: model_table_name.startswith(f"{app}_") for app in ALLOWED_APPS ): return self.admin_db + read_alias = get_read_db_alias() + if read_alias: + return read_alias return None def db_for_write(self, model, **hints): # noqa: F841 @@ -27,3 +52,8 @@ class MainRouter: if {obj1._state.db, obj2._state.db} <= {self.default_db, self.admin_db}: return True return None + + +READ_REPLICA_ALIAS = ( + MainRouter.replica_db if MainRouter.replica_db in settings.DATABASES else None +) diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py index 337f8444fe..0ef3891728 100644 --- a/api/src/backend/api/db_utils.py +++ b/api/src/backend/api/db_utils.py @@ -6,12 +6,14 @@ from datetime import datetime, timedelta, timezone from django.conf import settings from django.contrib.auth.models import BaseUserManager -from django.db import connection, models, transaction +from django.db import DEFAULT_DB_ALIAS, connection, connections, models, transaction from django_celery_beat.models import PeriodicTask from psycopg2 import connect as psycopg2_connect from psycopg2.extensions import AsIs, new_type, register_adapter, register_type from rest_framework_json_api.serializers import ValidationError +from api.db_router import get_read_db_alias, reset_read_db_alias, set_read_db_alias + DB_USER = settings.DATABASES["default"]["USER"] if not settings.TESTING else "test" DB_PASSWORD = ( settings.DATABASES["default"]["PASSWORD"] if not settings.TESTING else "test" @@ -49,7 +51,11 @@ def psycopg_connection(database_alias: str): @contextmanager -def rls_transaction(value: str, parameter: str = POSTGRES_TENANT_VAR): +def rls_transaction( + value: str, + parameter: str = POSTGRES_TENANT_VAR, + using: str | None = None, +): """ Creates a new database transaction setting the given configuration value for Postgres RLS. It validates the if the value is a valid UUID. @@ -57,16 +63,32 @@ def rls_transaction(value: str, parameter: str = POSTGRES_TENANT_VAR): Args: value (str): Database configuration parameter value. parameter (str): Database configuration parameter name, by default is 'api.tenant_id'. + using (str | None): Optional database alias to run the transaction against. Defaults to the + active read alias (if any) or Django's default connection. """ - with transaction.atomic(): - with connection.cursor() as cursor: - try: - # just in case the value is a UUID object - uuid.UUID(str(value)) - except ValueError: - raise ValidationError("Must be a valid UUID") - cursor.execute(SET_CONFIG_QUERY, [parameter, value]) - yield cursor + requested_alias = using or get_read_db_alias() + db_alias = requested_alias or DEFAULT_DB_ALIAS + if db_alias not in connections: + db_alias = DEFAULT_DB_ALIAS + + router_token = None + try: + if db_alias != DEFAULT_DB_ALIAS: + router_token = set_read_db_alias(db_alias) + + with transaction.atomic(using=db_alias): + conn = connections[db_alias] + with conn.cursor() as cursor: + try: + # just in case the value is a UUID object + uuid.UUID(str(value)) + except ValueError: + raise ValidationError("Must be a valid UUID") + cursor.execute(SET_CONFIG_QUERY, [parameter, value]) + yield cursor + finally: + if router_token is not None: + reset_read_db_alias(router_token) class CustomUserManager(BaseUserManager): diff --git a/api/src/backend/api/renderers.py b/api/src/backend/api/renderers.py index ee03f24758..44fd0edff1 100644 --- a/api/src/backend/api/renderers.py +++ b/api/src/backend/api/renderers.py @@ -11,11 +11,12 @@ class APIJSONRenderer(JSONRenderer): def render(self, data, accepted_media_type=None, renderer_context=None): request = renderer_context.get("request") tenant_id = getattr(request, "tenant_id", None) if request else None + db_alias = getattr(request, "db_alias", None) if request else None include_param_present = "include" in request.query_params if request else False # Use rls_transaction if needed for included resources, otherwise do nothing context_manager = ( - rls_transaction(tenant_id) + rls_transaction(tenant_id, using=db_alias) if tenant_id and include_param_present else nullcontext() ) diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 8be9f3aed3..6e561ff969 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -3449,20 +3449,16 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): ) filtered_queryset = self.filter_queryset(self.get_queryset()) - all_requirements = ( - filtered_queryset.values( - "requirement_id", - "framework", - "version", - "description", - "passed_findings", - "total_findings", - ) - .distinct() - .annotate( - total_instances=Count("id"), - manual_count=Count("id", filter=Q(requirement_status="MANUAL")), - ) + all_requirements = filtered_queryset.values( + "requirement_id", + "framework", + "version", + "description", + ).annotate( + total_instances=Count("id"), + manual_count=Count("id", filter=Q(requirement_status="MANUAL")), + passed_findings_sum=Sum("passed_findings"), + total_findings_sum=Sum("total_findings"), ) passed_instances = ( @@ -3481,8 +3477,8 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): total_instances = requirement["total_instances"] passed_count = passed_counts.get(requirement_id, 0) is_manual = requirement["manual_count"] == total_instances - passed_findings = requirement["passed_findings"] - total_findings = requirement["total_findings"] + passed_findings = requirement["passed_findings_sum"] or 0 + total_findings = requirement["total_findings_sum"] or 0 if is_manual: requirement_status = "MANUAL" elif passed_count == total_instances: diff --git a/api/src/backend/config/django/devel.py b/api/src/backend/config/django/devel.py index 825e1ce36a..12c3c384c7 100644 --- a/api/src/backend/config/django/devel.py +++ b/api/src/backend/config/django/devel.py @@ -5,24 +5,39 @@ DEBUG = env.bool("DJANGO_DEBUG", default=True) ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"]) # Database +default_db_name = env("POSTGRES_DB", default="prowler_db") +default_db_user = env("POSTGRES_USER", default="prowler_user") +default_db_password = env("POSTGRES_PASSWORD", default="prowler") +default_db_host = env("POSTGRES_HOST", default="postgres-db") +default_db_port = env("POSTGRES_PORT", default="5432") + DATABASES = { "prowler_user": { "ENGINE": "psqlextra.backend", - "NAME": env("POSTGRES_DB", default="prowler_db"), - "USER": env("POSTGRES_USER", default="prowler_user"), - "PASSWORD": env("POSTGRES_PASSWORD", default="prowler"), - "HOST": env("POSTGRES_HOST", default="postgres-db"), - "PORT": env("POSTGRES_PORT", default="5432"), + "NAME": default_db_name, + "USER": default_db_user, + "PASSWORD": default_db_password, + "HOST": default_db_host, + "PORT": default_db_port, }, "admin": { "ENGINE": "psqlextra.backend", - "NAME": env("POSTGRES_DB", default="prowler_db"), + "NAME": default_db_name, "USER": env("POSTGRES_ADMIN_USER", default="prowler"), "PASSWORD": env("POSTGRES_ADMIN_PASSWORD", default="S3cret"), - "HOST": env("POSTGRES_HOST", default="postgres-db"), - "PORT": env("POSTGRES_PORT", default="5432"), + "HOST": default_db_host, + "PORT": default_db_port, + }, + "replica": { + "ENGINE": "psqlextra.backend", + "NAME": env("POSTGRES_REPLICA_DB", default=default_db_name), + "USER": env("POSTGRES_REPLICA_USER", default=default_db_user), + "PASSWORD": env("POSTGRES_REPLICA_PASSWORD", default=default_db_password), + "HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host), + "PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port), }, } + DATABASES["default"] = DATABASES["prowler_user"] REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = tuple( # noqa: F405 diff --git a/api/src/backend/config/django/production.py b/api/src/backend/config/django/production.py index 0f9479625c..5a4cc73044 100644 --- a/api/src/backend/config/django/production.py +++ b/api/src/backend/config/django/production.py @@ -6,22 +6,37 @@ ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["localhost", "127.0.0. # Database # TODO Use Django database routers https://docs.djangoproject.com/en/5.0/topics/db/multi-db/#automatic-database-routing +default_db_name = env("POSTGRES_DB") +default_db_user = env("POSTGRES_USER") +default_db_password = env("POSTGRES_PASSWORD") +default_db_host = env("POSTGRES_HOST") +default_db_port = env("POSTGRES_PORT") + DATABASES = { "prowler_user": { - "ENGINE": "django.db.backends.postgresql", - "NAME": env("POSTGRES_DB"), - "USER": env("POSTGRES_USER"), - "PASSWORD": env("POSTGRES_PASSWORD"), - "HOST": env("POSTGRES_HOST"), - "PORT": env("POSTGRES_PORT"), + "ENGINE": "psqlextra.backend", + "NAME": default_db_name, + "USER": default_db_user, + "PASSWORD": default_db_password, + "HOST": default_db_host, + "PORT": default_db_port, }, "admin": { "ENGINE": "psqlextra.backend", - "NAME": env("POSTGRES_DB"), + "NAME": default_db_name, "USER": env("POSTGRES_ADMIN_USER"), "PASSWORD": env("POSTGRES_ADMIN_PASSWORD"), - "HOST": env("POSTGRES_HOST"), - "PORT": env("POSTGRES_PORT"), + "HOST": default_db_host, + "PORT": default_db_port, + }, + "replica": { + "ENGINE": "psqlextra.backend", + "NAME": env("POSTGRES_REPLICA_DB", default=default_db_name), + "USER": env("POSTGRES_REPLICA_USER", default=default_db_user), + "PASSWORD": env("POSTGRES_REPLICA_PASSWORD", default=default_db_password), + "HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host), + "PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port), }, } + DATABASES["default"] = DATABASES["prowler_user"] diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py index 06e7f4e7a5..4de4d89bb7 100644 --- a/api/src/backend/tasks/jobs/integrations.py +++ b/api/src/backend/tasks/jobs/integrations.py @@ -5,6 +5,7 @@ from celery.utils.log import get_task_logger from config.django.base import DJANGO_FINDINGS_BATCH_SIZE from tasks.utils import batched +from api.db_router import READ_REPLICA_ALIAS from api.db_utils import rls_transaction from api.models import Finding, Integration, Provider from api.utils import initialize_prowler_integration, initialize_prowler_provider @@ -289,7 +290,7 @@ def upload_security_hub_integration( has_findings = False batch_number = 0 - with rls_transaction(tenant_id): + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): qs = ( Finding.all_objects.filter(tenant_id=tenant_id, scan_id=scan_id) .order_by("uid") diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 63183d57db..1486cb13ae 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -1,8 +1,12 @@ +import csv +import io import json import time +import uuid from collections import defaultdict from copy import deepcopy from datetime import datetime, timezone +from typing import Any from celery.utils.log import get_task_logger from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS @@ -14,8 +18,11 @@ from api.compliance import ( PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, generate_scan_compliance, ) +from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import ( - create_objects_in_batches, + POSTGRES_TENANT_VAR, + SET_CONFIG_QUERY, + psycopg_connection, rls_transaction, update_objects_in_batches, ) @@ -40,6 +47,28 @@ from prowler.lib.scan.scan import Scan as ProwlerScan logger = get_task_logger(__name__) +# Column order must match `ComplianceRequirementOverview` schema in +# `api/models.py`. Keep this list minimal but sufficient to populate all +# non-nullable fields plus the counters we care about. +COMPLIANCE_REQUIREMENT_COPY_COLUMNS = ( + "id", + "tenant_id", + "inserted_at", + "compliance_id", + "framework", + "version", + "description", + "region", + "requirement_id", + "requirement_status", + "passed_checks", + "failed_checks", + "total_checks", + "passed_findings", + "total_findings", + "scan_id", +) + def _create_finding_delta( last_status: FindingStatus | None | str, new_status: FindingStatus | None @@ -107,6 +136,124 @@ def _store_resources( return resource_instance, (resource_instance.uid, resource_instance.region) +def _copy_compliance_requirement_rows( + tenant_id: str, rows: list[dict[str, Any]] +) -> None: + """Stream compliance requirement rows into Postgres using COPY. + + We leverage the admin connection (when available) to bypass the COPY + RLS + restriction, writing only the fields required by + ``ComplianceRequirementOverview``. + + Args: + tenant_id: Target tenant UUID. + rows: List of row dictionaries prepared by + :func:`create_compliance_requirements`. + """ + + csv_buffer = io.StringIO() + writer = csv.writer(csv_buffer) + + datetime_now = datetime.now(tz=timezone.utc) + for row in rows: + writer.writerow( + [ + str(row.get("id")), + str(row.get("tenant_id")), + (row.get("inserted_at") or datetime_now).isoformat(), + row.get("compliance_id") or "", + row.get("framework") or "", + row.get("version") or "", + row.get("description") or "", + row.get("region") or "", + row.get("requirement_id") or "", + row.get("requirement_status") or "", + row.get("passed_checks", 0), + row.get("failed_checks", 0), + row.get("total_checks", 0), + row.get("passed_findings", 0), + row.get("total_findings", 0), + str(row.get("scan_id")), + ] + ) + + csv_buffer.seek(0) + copy_sql = ( + "COPY compliance_requirements_overviews (" + + ", ".join(COMPLIANCE_REQUIREMENT_COPY_COLUMNS) + + ") FROM STDIN WITH (FORMAT CSV, DELIMITER ',', QUOTE '\"', ESCAPE '\"', NULL '\\N')" + ) + + try: + with psycopg_connection(MainRouter.admin_db) as connection: + connection.autocommit = False + try: + with connection.cursor() as cursor: + cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id]) + cursor.copy_expert(copy_sql, csv_buffer) + connection.commit() + except Exception: + connection.rollback() + raise + finally: + csv_buffer.close() + + +def _persist_compliance_requirement_rows( + tenant_id: str, rows: list[dict[str, Any]] +) -> None: + """Persist compliance requirement rows using COPY with ORM fallback. + + Args: + tenant_id: Target tenant UUID. + rows: Precomputed row dictionaries that reflect the compliance + overview state for a scan. + """ + if not rows: + return + + try: + _copy_compliance_requirement_rows(tenant_id, rows) + except Exception as error: + logger.exception( + "COPY bulk insert for compliance requirements failed; falling back to ORM bulk_create", + exc_info=error, + ) + fallback_objects = [ + ComplianceRequirementOverview( + id=row["id"], + tenant_id=row["tenant_id"], + inserted_at=row["inserted_at"], + compliance_id=row["compliance_id"], + framework=row["framework"], + version=row["version"], + description=row["description"], + region=row["region"], + requirement_id=row["requirement_id"], + requirement_status=row["requirement_status"], + passed_checks=row["passed_checks"], + failed_checks=row["failed_checks"], + total_checks=row["total_checks"], + passed_findings=row.get("passed_findings", 0), + total_findings=row.get("total_findings", 0), + scan_id=row["scan_id"], + ) + for row in rows + ] + with rls_transaction(tenant_id): + ComplianceRequirementOverview.objects.bulk_create( + fallback_objects, batch_size=500 + ) + + +def _normalized_compliance_key(framework: str | None, version: str | None) -> str: + """Return normalized identifier used to group compliance totals.""" + + normalized_framework = (framework or "").lower().replace("-", "").replace("_", "") + normalized_version = (version or "").lower().replace("-", "").replace("_", "") + return f"{normalized_framework}{normalized_version}" + + def perform_prowler_scan( tenant_id: str, scan_id: str, @@ -143,7 +290,7 @@ def perform_prowler_scan( scan_instance.save() # Find the mutelist processor if it exists - with rls_transaction(tenant_id): + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): try: mutelist_processor = Processor.objects.get( tenant_id=tenant_id, processor_type=Processor.ProcessorChoices.MUTELIST @@ -272,7 +419,7 @@ def perform_prowler_scan( unique_resources.add((resource_instance.uid, resource_instance.region)) # Process finding - with rls_transaction(tenant_id): + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): finding_uid = finding.uid last_first_seen_at = None if finding_uid not in last_status_cache: @@ -305,6 +452,12 @@ def perform_prowler_scan( # If the finding is muted at this time the reason must be the configured Mutelist muted_reason = "Muted by mutelist" if finding.muted else None + # Increment failed_findings_count cache if the finding status is FAIL and not muted + if status == FindingStatus.FAIL and not finding.muted: + resource_uid = finding.resource_uid + resource_failed_findings_cache[resource_uid] += 1 + + with rls_transaction(tenant_id): # Create the finding finding_instance = Finding.objects.create( tenant_id=tenant_id, @@ -325,11 +478,6 @@ def perform_prowler_scan( ) finding_instance.add_resources([resource_instance]) - # Increment failed_findings_count cache if the finding status is FAIL and not muted - if status == FindingStatus.FAIL and not finding.muted: - resource_uid = finding.resource_uid - resource_failed_findings_cache[resource_uid] += 1 - # Update scan resource summaries scan_resource_cache.add( ( @@ -439,7 +587,7 @@ def aggregate_findings(tenant_id: str, scan_id: str): - muted_new: Muted findings with a delta of 'new'. - muted_changed: Muted findings with a delta of 'changed'. """ - with rls_transaction(tenant_id): + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): findings = Finding.objects.filter(tenant_id=tenant_id, scan_id=scan_id) aggregation = findings.values( @@ -582,11 +730,28 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): ValidationError: If tenant_id is not a valid UUID. """ try: - with rls_transaction(tenant_id): + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): scan_instance = Scan.objects.get(pk=scan_id) provider_instance = scan_instance.provider prowler_provider = return_prowler_provider(provider_instance) + compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[ + provider_instance.provider + ] + modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + threatscore_requirements_by_check: dict[str, set[str]] = {} + threatscore_framework = compliance_template.get( + modeled_threatscore_compliance_id + ) + if threatscore_framework: + for requirement_id, requirement in threatscore_framework[ + "requirements" + ].items(): + for check_id in requirement["checks"]: + threatscore_requirements_by_check.setdefault(check_id, set()).add( + requirement_id + ) + # Get check status data by region from findings findings = ( Finding.all_objects.filter(scan_id=scan_id, muted=False) @@ -603,8 +768,7 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): findings_count_by_compliance = {} check_status_by_region = {} - modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" - with rls_transaction(tenant_id): + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): for finding in findings: for resource in finding.small_resources: region = resource.region @@ -640,11 +804,6 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): # If not available, use regions from findings regions = set(check_status_by_region.keys()) - # Get compliance template for the provider - compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[ - provider_instance.provider - ] - # Create compliance data by region compliance_overview_by_region = { region: deepcopy(compliance_template) for region in regions @@ -663,50 +822,53 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): status, ) - # Prepare compliance requirement objects - compliance_requirement_objects = [] + # Prepare compliance requirement rows + compliance_requirement_rows: list[dict[str, Any]] = [] + utc_datetime_now = datetime.now(tz=timezone.utc) for region, compliance_data in compliance_overview_by_region.items(): for compliance_id, compliance in compliance_data.items(): - modeled_framework = ( - compliance["framework"].lower().replace("-", "").replace("_", "") + modeled_compliance_id = _normalized_compliance_key( + compliance["framework"], compliance["version"] ) - modeled_version = ( - compliance["version"].lower().replace("-", "").replace("_", "") - ) - modeled_compliance_id = f"{modeled_framework}{modeled_version}" # Create an overview record for each requirement within each compliance framework for requirement_id, requirement in compliance["requirements"].items(): - compliance_requirement_objects.append( - ComplianceRequirementOverview( - tenant_id=tenant_id, - scan=scan_instance, - region=region, - compliance_id=compliance_id, - framework=compliance["framework"], - version=compliance["version"], - requirement_id=requirement_id, - description=requirement["description"], - passed_checks=requirement["checks_status"]["pass"], - failed_checks=requirement["checks_status"]["fail"], - total_checks=requirement["checks_status"]["total"], - requirement_status=requirement["status"], - passed_findings=findings_count_by_compliance.get(region, {}) + checks_status = requirement["checks_status"] + compliance_requirement_rows.append( + { + "id": uuid.uuid4(), + "tenant_id": tenant_id, + "inserted_at": utc_datetime_now, + "compliance_id": compliance_id, + "framework": compliance["framework"], + "version": compliance["version"] or "", + "description": requirement.get("description") or "", + "region": region, + "requirement_id": requirement_id, + "requirement_status": requirement["status"], + "passed_checks": checks_status["pass"], + "failed_checks": checks_status["fail"], + "total_checks": checks_status["total"], + "scan_id": scan_instance.id, + "passed_findings": findings_count_by_compliance.get( + region, {} + ) .get(modeled_compliance_id, {}) .get(requirement_id, {}) .get("pass", 0), - total_findings=findings_count_by_compliance.get(region, {}) + "total_findings": findings_count_by_compliance.get( + region, {} + ) .get(modeled_compliance_id, {}) .get(requirement_id, {}) .get("total", 0), - ) + } ) - # Bulk create requirement records - create_objects_in_batches( - tenant_id, ComplianceRequirementOverview, compliance_requirement_objects - ) + + # Bulk create requirement records using PostgreSQL COPY + _persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows) return { - "requirements_created": len(compliance_requirement_objects), + "requirements_created": len(compliance_requirement_rows), "regions_processed": list(regions), "compliance_frameworks": ( list(compliance_overview_by_region.get(list(regions)[0], {}).keys()) diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index dbb3c3a00e..fd4874eb6c 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -34,6 +34,7 @@ from tasks.jobs.scan import ( from tasks.utils import batched, get_next_execution_datetime from api.compliance import get_compliance_frameworks +from api.db_router import READ_REPLICA_ALIAS from api.db_utils import rls_transaction from api.decorators import set_tenant from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateChoices @@ -343,70 +344,73 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): .order_by("uid") .iterator() ) - for batch, is_last in batched(qs, DJANGO_FINDINGS_BATCH_SIZE): - fos = [FindingOutput.transform_api_finding(f, prowler_provider) for f in batch] + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + for batch, is_last in batched(qs, DJANGO_FINDINGS_BATCH_SIZE): + fos = [ + FindingOutput.transform_api_finding(f, prowler_provider) for f in batch + ] - # Outputs - for mode, cfg in OUTPUT_FORMATS_MAPPING.items(): - # Skip ASFF generation if not needed - if mode == "json-asff" and not generate_asff: - continue + # Outputs + for mode, cfg in OUTPUT_FORMATS_MAPPING.items(): + # Skip ASFF generation if not needed + if mode == "json-asff" and not generate_asff: + continue - cls = cfg["class"] - suffix = cfg["suffix"] - extra = cfg.get("kwargs", {}).copy() - if mode == "html": - extra.update(provider=prowler_provider, stats=scan_summary) + cls = cfg["class"] + suffix = cfg["suffix"] + extra = cfg.get("kwargs", {}).copy() + if mode == "html": + extra.update(provider=prowler_provider, stats=scan_summary) - writer, initialization = get_writer( - output_writers, - cls, - lambda cls=cls, fos=fos, suffix=suffix: cls( - findings=fos, - file_path=out_dir, - file_extension=suffix, - from_cli=False, - ), - is_last, - ) - if not initialization: - writer.transform(fos) - writer.batch_write_data_to_file(**extra) - writer._data.clear() + writer, initialization = get_writer( + output_writers, + cls, + lambda cls=cls, fos=fos, suffix=suffix: cls( + findings=fos, + file_path=out_dir, + file_extension=suffix, + from_cli=False, + ), + is_last, + ) + if not initialization: + writer.transform(fos) + writer.batch_write_data_to_file(**extra) + writer._data.clear() - # Compliance CSVs - for name in frameworks_avail: - compliance_obj = frameworks_bulk[name] + # Compliance CSVs + for name in frameworks_avail: + compliance_obj = frameworks_bulk[name] - klass = GenericCompliance - for condition, cls in COMPLIANCE_CLASS_MAP.get(provider_type, []): - if condition(name): - klass = cls - break + klass = GenericCompliance + for condition, cls in COMPLIANCE_CLASS_MAP.get(provider_type, []): + if condition(name): + klass = cls + break - filename = f"{comp_dir}_{name}.csv" + filename = f"{comp_dir}_{name}.csv" - writer, initialization = get_writer( - compliance_writers, - name, - lambda klass=klass, fos=fos: klass( - findings=fos, - compliance=compliance_obj, - file_path=filename, - from_cli=False, - ), - is_last, - ) - if not initialization: - writer.transform(fos, compliance_obj, name) - writer.batch_write_data_to_file() - writer._data.clear() + writer, initialization = get_writer( + compliance_writers, + name, + lambda klass=klass, fos=fos: klass( + findings=fos, + compliance=compliance_obj, + file_path=filename, + from_cli=False, + ), + is_last, + ) + if not initialization: + writer.transform(fos, compliance_obj, name) + writer.batch_write_data_to_file() + writer._data.clear() compressed = _compress_output_files(out_dir) upload_uri = _upload_to_s3(tenant_id, compressed, scan_id) # S3 integrations (need output_directory) - with rls_transaction(tenant_id): + with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): s3_integrations = Integration.objects.filter( integrationproviderrelationship__provider_id=provider_id, integration_type=Integration.IntegrationChoices.AMAZON_S3, diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index dcbec12956..62c9a2b7d1 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -1,17 +1,22 @@ +import csv import json import uuid -from datetime import datetime +from datetime import datetime, timezone +from io import StringIO from unittest.mock import MagicMock, patch import pytest from tasks.jobs.scan import ( + _copy_compliance_requirement_rows, _create_finding_delta, + _persist_compliance_requirement_rows, _store_resources, create_compliance_requirements, perform_prowler_scan, ) from tasks.utils import CustomEncoder +from api.db_router import MainRouter from api.exceptions import ProviderConnectionError from api.models import Finding, Provider, Resource, Scan, StateChoices, StatusChoices from prowler.lib.check.models import Severity @@ -1045,3 +1050,773 @@ class TestCreateComplianceRequirements: assert "requirements_created" in result assert result["requirements_created"] >= 0 + + +class TestComplianceRequirementCopy: + @patch("tasks.jobs.scan.psycopg_connection") + def test_copy_compliance_requirement_rows_streams_csv( + self, mock_psycopg_connection, settings + ): + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + captured = {} + + def copy_side_effect(sql, file_obj): + captured["sql"] = sql + captured["data"] = file_obj.read() + + cursor.copy_expert.side_effect = copy_side_effect + + row = { + "id": uuid.uuid4(), + "tenant_id": str(uuid.uuid4()), + "compliance_id": "cisa_aws", + "framework": "CISA", + "version": None, + "description": "desc", + "region": "us-east-1", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 1, + "failed_checks": 0, + "total_checks": 1, + "scan_id": uuid.uuid4(), + } + + with patch.object(MainRouter, "admin_db", "admin"): + _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + + mock_psycopg_connection.assert_called_once_with("admin") + connection.cursor.assert_called_once() + cursor.execute.assert_called_once() + cursor.copy_expert.assert_called_once() + + csv_rows = list(csv.reader(StringIO(captured["data"]))) + assert csv_rows[0][0] == str(row["id"]) + assert csv_rows[0][5] == "" + assert csv_rows[0][-1] == str(row["scan_id"]) + + @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + @patch( + "tasks.jobs.scan._copy_compliance_requirement_rows", + side_effect=Exception("copy failed"), + ) + def test_persist_compliance_requirement_rows_fallback( + self, mock_copy, mock_rls_transaction, mock_bulk_create + ): + inserted_at = datetime.now(timezone.utc) + row = { + "id": uuid.uuid4(), + "tenant_id": str(uuid.uuid4()), + "inserted_at": inserted_at, + "compliance_id": "cisa_aws", + "framework": "CISA", + "version": "1.0", + "description": "desc", + "region": "us-east-1", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 1, + "failed_checks": 0, + "total_checks": 1, + "scan_id": uuid.uuid4(), + } + + tenant_id = row["tenant_id"] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + + _persist_compliance_requirement_rows(tenant_id, [row]) + + mock_copy.assert_called_once_with(tenant_id, [row]) + mock_rls_transaction.assert_called_once_with(tenant_id) + mock_bulk_create.assert_called_once() + + args, kwargs = mock_bulk_create.call_args + objects = args[0] + assert len(objects) == 1 + fallback = objects[0] + assert fallback.version == row["version"] + assert fallback.compliance_id == row["compliance_id"] + + @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + @patch("tasks.jobs.scan._copy_compliance_requirement_rows") + def test_persist_compliance_requirement_rows_no_rows( + self, mock_copy, mock_rls_transaction, mock_bulk_create + ): + _persist_compliance_requirement_rows(str(uuid.uuid4()), []) + + mock_copy.assert_not_called() + mock_rls_transaction.assert_not_called() + mock_bulk_create.assert_not_called() + + @patch("tasks.jobs.scan.psycopg_connection") + def test_copy_compliance_requirement_rows_multiple_rows( + self, mock_psycopg_connection, settings + ): + """Test COPY with multiple rows to ensure batch processing works correctly.""" + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + captured = {} + + def copy_side_effect(sql, file_obj): + captured["sql"] = sql + captured["data"] = file_obj.read() + + cursor.copy_expert.side_effect = copy_side_effect + + tenant_id = str(uuid.uuid4()) + scan_id = uuid.uuid4() + inserted_at = datetime.now(timezone.utc) + + rows = [ + { + "id": uuid.uuid4(), + "tenant_id": tenant_id, + "inserted_at": inserted_at, + "compliance_id": "cisa_aws", + "framework": "CISA", + "version": "1.0", + "description": "First requirement", + "region": "us-east-1", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 5, + "failed_checks": 0, + "total_checks": 5, + "scan_id": scan_id, + }, + { + "id": uuid.uuid4(), + "tenant_id": tenant_id, + "inserted_at": inserted_at, + "compliance_id": "cisa_aws", + "framework": "CISA", + "version": "1.0", + "description": "Second requirement", + "region": "us-west-2", + "requirement_id": "req-2", + "requirement_status": "FAIL", + "passed_checks": 3, + "failed_checks": 2, + "total_checks": 5, + "scan_id": scan_id, + }, + { + "id": uuid.uuid4(), + "tenant_id": tenant_id, + "inserted_at": inserted_at, + "compliance_id": "aws_foundational_security_aws", + "framework": "AWS-Foundational-Security-Best-Practices", + "version": "2.0", + "description": "Third requirement", + "region": "eu-west-1", + "requirement_id": "req-3", + "requirement_status": "MANUAL", + "passed_checks": 0, + "failed_checks": 0, + "total_checks": 3, + "scan_id": scan_id, + }, + ] + + with patch.object(MainRouter, "admin_db", "admin"): + _copy_compliance_requirement_rows(tenant_id, rows) + + mock_psycopg_connection.assert_called_once_with("admin") + connection.cursor.assert_called_once() + cursor.execute.assert_called_once() + cursor.copy_expert.assert_called_once() + + csv_rows = list(csv.reader(StringIO(captured["data"]))) + assert len(csv_rows) == 3 + + # Validate first row + assert csv_rows[0][0] == str(rows[0]["id"]) + assert csv_rows[0][1] == tenant_id + assert csv_rows[0][3] == "cisa_aws" + assert csv_rows[0][4] == "CISA" + assert csv_rows[0][6] == "First requirement" + assert csv_rows[0][7] == "us-east-1" + assert csv_rows[0][10] == "5" + assert csv_rows[0][11] == "0" + assert csv_rows[0][12] == "5" + + # Validate second row + assert csv_rows[1][0] == str(rows[1]["id"]) + assert csv_rows[1][7] == "us-west-2" + assert csv_rows[1][9] == "FAIL" + assert csv_rows[1][10] == "3" + assert csv_rows[1][11] == "2" + + # Validate third row + assert csv_rows[2][0] == str(rows[2]["id"]) + assert csv_rows[2][3] == "aws_foundational_security_aws" + assert csv_rows[2][5] == "2.0" + assert csv_rows[2][9] == "MANUAL" + + @patch("tasks.jobs.scan.psycopg_connection") + def test_copy_compliance_requirement_rows_null_values( + self, mock_psycopg_connection, settings + ): + """Test COPY handles NULL/None values correctly in nullable fields.""" + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + captured = {} + + def copy_side_effect(sql, file_obj): + captured["sql"] = sql + captured["data"] = file_obj.read() + + cursor.copy_expert.side_effect = copy_side_effect + + # Row with all nullable fields set to None/empty + row = { + "id": uuid.uuid4(), + "tenant_id": str(uuid.uuid4()), + "compliance_id": "test_framework", + "framework": "Test", + "version": None, # nullable + "description": None, # nullable + "region": "", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 0, + "failed_checks": 0, + "total_checks": 0, + "scan_id": uuid.uuid4(), + } + + with patch.object(MainRouter, "admin_db", "admin"): + _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + + csv_rows = list(csv.reader(StringIO(captured["data"]))) + assert len(csv_rows) == 1 + + # Validate that None values are converted to empty strings in CSV + assert csv_rows[0][5] == "" # version + assert csv_rows[0][6] == "" # description + + @patch("tasks.jobs.scan.psycopg_connection") + def test_copy_compliance_requirement_rows_special_characters( + self, mock_psycopg_connection, settings + ): + """Test COPY correctly escapes special characters in CSV.""" + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + captured = {} + + def copy_side_effect(sql, file_obj): + captured["sql"] = sql + captured["data"] = file_obj.read() + + cursor.copy_expert.side_effect = copy_side_effect + + # Row with special characters that need escaping + row = { + "id": uuid.uuid4(), + "tenant_id": str(uuid.uuid4()), + "compliance_id": 'framework"with"quotes', + "framework": "Framework,with,commas", + "version": "1.0", + "description": 'Description with "quotes", commas, and\nnewlines', + "region": "us-east-1", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 1, + "failed_checks": 0, + "total_checks": 1, + "scan_id": uuid.uuid4(), + } + + with patch.object(MainRouter, "admin_db", "admin"): + _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + + # Verify CSV was generated (csv module handles escaping automatically) + csv_rows = list(csv.reader(StringIO(captured["data"]))) + assert len(csv_rows) == 1 + + # Verify special characters are preserved after CSV parsing + assert csv_rows[0][3] == 'framework"with"quotes' + assert csv_rows[0][4] == "Framework,with,commas" + assert "quotes" in csv_rows[0][6] + assert "commas" in csv_rows[0][6] + + @patch("tasks.jobs.scan.psycopg_connection") + def test_copy_compliance_requirement_rows_missing_inserted_at( + self, mock_psycopg_connection, settings + ): + """Test COPY uses current datetime when inserted_at is missing.""" + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + captured = {} + + def copy_side_effect(sql, file_obj): + captured["sql"] = sql + captured["data"] = file_obj.read() + + cursor.copy_expert.side_effect = copy_side_effect + + # Row without inserted_at field + row = { + "id": uuid.uuid4(), + "tenant_id": str(uuid.uuid4()), + "compliance_id": "test_framework", + "framework": "Test", + "version": "1.0", + "description": "desc", + "region": "us-east-1", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 1, + "failed_checks": 0, + "total_checks": 1, + "scan_id": uuid.uuid4(), + # Note: inserted_at is intentionally missing + } + + before_call = datetime.now(timezone.utc) + with patch.object(MainRouter, "admin_db", "admin"): + _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + after_call = datetime.now(timezone.utc) + + csv_rows = list(csv.reader(StringIO(captured["data"]))) + assert len(csv_rows) == 1 + + # Verify inserted_at was auto-generated and is a valid ISO datetime + inserted_at_str = csv_rows[0][2] + inserted_at = datetime.fromisoformat(inserted_at_str) + assert before_call <= inserted_at <= after_call + + @patch("tasks.jobs.scan.psycopg_connection") + def test_copy_compliance_requirement_rows_transaction_rollback_on_copy_error( + self, mock_psycopg_connection, settings + ): + """Test transaction is rolled back when copy_expert fails.""" + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + # Simulate copy_expert failure + cursor.copy_expert.side_effect = Exception("COPY command failed") + + row = { + "id": uuid.uuid4(), + "tenant_id": str(uuid.uuid4()), + "compliance_id": "test", + "framework": "Test", + "version": "1.0", + "description": "desc", + "region": "us-east-1", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 1, + "failed_checks": 0, + "total_checks": 1, + "scan_id": uuid.uuid4(), + } + + with patch.object(MainRouter, "admin_db", "admin"): + with pytest.raises(Exception, match="COPY command failed"): + _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + + # Verify rollback was called + connection.rollback.assert_called_once() + connection.commit.assert_not_called() + + @patch("tasks.jobs.scan.psycopg_connection") + def test_copy_compliance_requirement_rows_transaction_rollback_on_set_config_error( + self, mock_psycopg_connection, settings + ): + """Test transaction is rolled back when SET_CONFIG fails.""" + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + # Simulate cursor.execute failure + cursor.execute.side_effect = Exception("SET prowler.tenant_id failed") + + row = { + "id": uuid.uuid4(), + "tenant_id": str(uuid.uuid4()), + "compliance_id": "test", + "framework": "Test", + "version": "1.0", + "description": "desc", + "region": "us-east-1", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 1, + "failed_checks": 0, + "total_checks": 1, + "scan_id": uuid.uuid4(), + } + + with patch.object(MainRouter, "admin_db", "admin"): + with pytest.raises(Exception, match="SET prowler.tenant_id failed"): + _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + + # Verify rollback was called + connection.rollback.assert_called_once() + connection.commit.assert_not_called() + + @patch("tasks.jobs.scan.psycopg_connection") + def test_copy_compliance_requirement_rows_commit_on_success( + self, mock_psycopg_connection, settings + ): + """Test transaction is committed on successful COPY.""" + settings.DATABASES.setdefault("admin", settings.DATABASES["default"]) + + connection = MagicMock() + cursor = MagicMock() + cursor_context = MagicMock() + cursor_context.__enter__.return_value = cursor + cursor_context.__exit__.return_value = False + connection.cursor.return_value = cursor_context + connection.__enter__.return_value = connection + connection.__exit__.return_value = False + + context_manager = MagicMock() + context_manager.__enter__.return_value = connection + context_manager.__exit__.return_value = False + mock_psycopg_connection.return_value = context_manager + + cursor.copy_expert.return_value = None # Success + + row = { + "id": uuid.uuid4(), + "tenant_id": str(uuid.uuid4()), + "compliance_id": "test", + "framework": "Test", + "version": "1.0", + "description": "desc", + "region": "us-east-1", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 1, + "failed_checks": 0, + "total_checks": 1, + "scan_id": uuid.uuid4(), + } + + with patch.object(MainRouter, "admin_db", "admin"): + _copy_compliance_requirement_rows(str(row["tenant_id"]), [row]) + + # Verify commit was called and rollback was not + connection.commit.assert_called_once() + connection.rollback.assert_not_called() + # Verify autocommit was disabled + assert connection.autocommit is False + + @patch("tasks.jobs.scan._copy_compliance_requirement_rows") + def test_persist_compliance_requirement_rows_success(self, mock_copy): + """Test successful COPY path without fallback to ORM.""" + mock_copy.return_value = None # Success, no exception + + tenant_id = str(uuid.uuid4()) + rows = [ + { + "id": uuid.uuid4(), + "tenant_id": tenant_id, + "inserted_at": datetime.now(timezone.utc), + "compliance_id": "test", + "framework": "Test", + "version": "1.0", + "description": "desc", + "region": "us-east-1", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 1, + "failed_checks": 0, + "total_checks": 1, + "scan_id": uuid.uuid4(), + } + ] + + _persist_compliance_requirement_rows(tenant_id, rows) + + # Verify COPY was called + mock_copy.assert_called_once_with(tenant_id, rows) + + @patch("tasks.jobs.scan.logger") + @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + @patch( + "tasks.jobs.scan._copy_compliance_requirement_rows", + side_effect=Exception("COPY failed"), + ) + def test_persist_compliance_requirement_rows_fallback_logging( + self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_logger + ): + """Test logger.exception is called when COPY fails and fallback occurs.""" + tenant_id = str(uuid.uuid4()) + row = { + "id": uuid.uuid4(), + "tenant_id": tenant_id, + "inserted_at": datetime.now(timezone.utc), + "compliance_id": "test", + "framework": "Test", + "version": "1.0", + "description": "desc", + "region": "us-east-1", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 1, + "failed_checks": 0, + "total_checks": 1, + "scan_id": uuid.uuid4(), + } + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + + _persist_compliance_requirement_rows(tenant_id, [row]) + + # Verify logger.exception was called + mock_logger.exception.assert_called_once() + args, kwargs = mock_logger.exception.call_args + assert "COPY bulk insert" in args[0] + assert "falling back to ORM" in args[0] + assert kwargs.get("exc_info") is not None + + @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + @patch( + "tasks.jobs.scan._copy_compliance_requirement_rows", + side_effect=Exception("copy failed"), + ) + def test_persist_compliance_requirement_rows_fallback_multiple_rows( + self, mock_copy, mock_rls_transaction, mock_bulk_create + ): + """Test ORM fallback with multiple rows.""" + tenant_id = str(uuid.uuid4()) + scan_id = uuid.uuid4() + inserted_at = datetime.now(timezone.utc) + + rows = [ + { + "id": uuid.uuid4(), + "tenant_id": tenant_id, + "inserted_at": inserted_at, + "compliance_id": "cisa_aws", + "framework": "CISA", + "version": "1.0", + "description": "First requirement", + "region": "us-east-1", + "requirement_id": "req-1", + "requirement_status": "PASS", + "passed_checks": 5, + "failed_checks": 0, + "total_checks": 5, + "scan_id": scan_id, + }, + { + "id": uuid.uuid4(), + "tenant_id": tenant_id, + "inserted_at": inserted_at, + "compliance_id": "cisa_aws", + "framework": "CISA", + "version": "1.0", + "description": "Second requirement", + "region": "us-west-2", + "requirement_id": "req-2", + "requirement_status": "FAIL", + "passed_checks": 2, + "failed_checks": 3, + "total_checks": 5, + "scan_id": scan_id, + }, + ] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + + _persist_compliance_requirement_rows(tenant_id, rows) + + mock_copy.assert_called_once_with(tenant_id, rows) + mock_rls_transaction.assert_called_once_with(tenant_id) + mock_bulk_create.assert_called_once() + + args, kwargs = mock_bulk_create.call_args + objects = args[0] + assert len(objects) == 2 + assert kwargs["batch_size"] == 500 + + # Validate first object + assert objects[0].id == rows[0]["id"] + assert objects[0].tenant_id == rows[0]["tenant_id"] + assert objects[0].compliance_id == rows[0]["compliance_id"] + assert objects[0].framework == rows[0]["framework"] + assert objects[0].region == rows[0]["region"] + assert objects[0].passed_checks == 5 + assert objects[0].failed_checks == 0 + + # Validate second object + assert objects[1].id == rows[1]["id"] + assert objects[1].requirement_id == rows[1]["requirement_id"] + assert objects[1].requirement_status == rows[1]["requirement_status"] + assert objects[1].passed_checks == 2 + assert objects[1].failed_checks == 3 + + @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + @patch( + "tasks.jobs.scan._copy_compliance_requirement_rows", + side_effect=Exception("copy failed"), + ) + def test_persist_compliance_requirement_rows_fallback_all_fields( + self, mock_copy, mock_rls_transaction, mock_bulk_create + ): + """Test ORM fallback correctly maps all fields from row dict to model.""" + tenant_id = str(uuid.uuid4()) + row_id = uuid.uuid4() + scan_id = uuid.uuid4() + inserted_at = datetime.now(timezone.utc) + + row = { + "id": row_id, + "tenant_id": tenant_id, + "inserted_at": inserted_at, + "compliance_id": "aws_foundational_security_aws", + "framework": "AWS-Foundational-Security-Best-Practices", + "version": "2.0", + "description": "Ensure MFA is enabled", + "region": "eu-west-1", + "requirement_id": "iam.1", + "requirement_status": "FAIL", + "passed_checks": 10, + "failed_checks": 5, + "total_checks": 15, + "scan_id": scan_id, + } + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + + _persist_compliance_requirement_rows(tenant_id, [row]) + + args, kwargs = mock_bulk_create.call_args + objects = args[0] + assert len(objects) == 1 + + obj = objects[0] + # Validate ALL fields are correctly mapped + assert obj.id == row_id + assert obj.tenant_id == tenant_id + assert obj.inserted_at == inserted_at + assert obj.compliance_id == "aws_foundational_security_aws" + assert obj.framework == "AWS-Foundational-Security-Best-Practices" + assert obj.version == "2.0" + assert obj.description == "Ensure MFA is enabled" + assert obj.region == "eu-west-1" + assert obj.requirement_id == "iam.1" + assert obj.requirement_status == "FAIL" + assert obj.passed_checks == 10 + assert obj.failed_checks == 5 + assert obj.total_checks == 15 + assert obj.scan_id == scan_id From 87945153189764545afabaffd613055f37e82807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Fern=C3=A1ndez=20Poyatos?= Date: Fri, 10 Oct 2025 12:35:27 +0200 Subject: [PATCH 15/22] fix(api-keys): make name required and unique (#8891) --- .../backend/api/migrations/0048_api_key.py | 15 +++- api/src/backend/api/models.py | 5 ++ api/src/backend/api/specs/v1.yaml | 30 +++++--- api/src/backend/api/tests/test_views.py | 70 ++++++++++++++++++- api/src/backend/api/v1/serializers.py | 18 +++++ 5 files changed, 124 insertions(+), 14 deletions(-) diff --git a/api/src/backend/api/migrations/0048_api_key.py b/api/src/backend/api/migrations/0048_api_key.py index 9bc68af8e6..abcbe212ef 100644 --- a/api/src/backend/api/migrations/0048_api_key.py +++ b/api/src/backend/api/migrations/0048_api_key.py @@ -2,6 +2,7 @@ import uuid +import django.core.validators import django.db.models.deletion import drf_simple_apikey.models from django.conf import settings @@ -20,7 +21,13 @@ class Migration(migrations.Migration): migrations.CreateModel( name="TenantAPIKey", fields=[ - ("name", models.CharField(blank=True, max_length=255, null=True)), + ( + "name", + models.CharField( + max_length=255, + validators=[django.core.validators.MinLengthValidator(3)], + ), + ), ( "expiry_date", models.DateTimeField( @@ -110,7 +117,11 @@ class Migration(migrations.Migration): "constraints": [ models.UniqueConstraint( fields=("tenant_id", "prefix"), name="unique_api_key_prefixes" - ) + ), + models.UniqueConstraint( + fields=("tenant_id", "name"), + name="unique_api_key_name_per_tenant", + ), ], }, ), diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 048f1ca7db..c3cd30d932 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -220,6 +220,7 @@ class Membership(models.Model): class TenantAPIKey(AbstractAPIKey, RowLevelSecurityProtectedModel): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + name = models.CharField(max_length=100, validators=[MinLengthValidator(3)]) prefix = models.CharField( max_length=11, unique=True, @@ -255,6 +256,10 @@ class TenantAPIKey(AbstractAPIKey, RowLevelSecurityProtectedModel): fields=("tenant_id", "prefix"), name="unique_api_key_prefixes", ), + models.UniqueConstraint( + fields=("tenant_id", "name"), + name="unique_api_key_name_per_tenant", + ), ] indexes = [ diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index d282021d49..cd5e9d4af1 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -12460,8 +12460,8 @@ components: properties: name: type: string - nullable: true - maxLength: 255 + minLength: 3 + maxLength: 100 prefix: type: string readOnly: true @@ -12481,6 +12481,8 @@ components: readOnly: true nullable: true description: Last time this API key was used for authentication + required: + - name relationships: type: object properties: @@ -15767,8 +15769,8 @@ components: properties: name: type: string - nullable: true - maxLength: 255 + maxLength: 100 + minLength: 3 prefix: type: string readOnly: true @@ -15790,6 +15792,8 @@ components: format: date-time nullable: true description: Last time this API key was used for authentication + required: + - name relationships: type: object properties: @@ -15836,8 +15840,8 @@ components: properties: name: type: string - nullable: true - maxLength: 255 + maxLength: 100 + minLength: 3 prefix: type: string readOnly: true @@ -15863,6 +15867,8 @@ components: api_key: type: string readOnly: true + required: + - name relationships: type: object properties: @@ -15913,8 +15919,8 @@ components: properties: name: type: string - nullable: true - maxLength: 255 + minLength: 3 + maxLength: 100 prefix: type: string readOnly: true @@ -15941,6 +15947,8 @@ components: api_key: type: string readOnly: true + required: + - name relationships: type: object properties: @@ -16008,8 +16016,8 @@ components: properties: name: type: string - nullable: true - maxLength: 255 + maxLength: 100 + minLength: 3 prefix: type: string readOnly: true @@ -16028,6 +16036,8 @@ components: readOnly: true nullable: true description: Last time this API key was used for authentication + required: + - name relationships: type: object properties: diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 71f946116c..11dc1c8b33 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -7739,8 +7739,6 @@ class TestTenantApiKeyViewSet: ( [ {"name": "New API Key"}, - {"name": ""}, - {}, ] ), ) @@ -7781,6 +7779,18 @@ class TestTenantApiKeyViewSet: {"name": "Invalid Expiry", "expires_at": "not-a-date"}, "expires_at", ), + ( + {"name": ""}, + "name", + ), + ( + {}, + "name", + ), + ( + {"name": "AB"}, # Too short (min length is 3) + "name", + ), ] ), ) @@ -7809,6 +7819,58 @@ class TestTenantApiKeyViewSet: == f"/data/attributes/{error_pointer}" ) + def test_api_keys_create_duplicate_name( + self, authenticated_client, api_keys_fixture + ): + """Test creating an API key with a duplicate name fails.""" + # Use the name of an existing API key + existing_name = api_keys_fixture[0].name + data = { + "data": { + "type": "api-keys", + "attributes": { + "name": existing_name, + }, + } + } + response = authenticated_client.post( + reverse("api-key-list"), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "errors" in response.json() + error_detail = response.json()["errors"][0]["detail"] + assert "already exists" in error_detail.lower() + + def test_api_keys_update_duplicate_name( + self, authenticated_client, api_keys_fixture + ): + """Test updating an API key with a duplicate name fails.""" + # Get two different API keys + first_api_key = api_keys_fixture[0] + second_api_key = api_keys_fixture[1] + + # Try to update the second API key to have the same name as the first one + data = { + "data": { + "type": "api-keys", + "id": str(second_api_key.id), + "attributes": { + "name": first_api_key.name, + }, + } + } + response = authenticated_client.patch( + reverse("api-key-detail", kwargs={"pk": second_api_key.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "errors" in response.json() + error_detail = response.json()["errors"][0]["detail"] + assert "already exists" in error_detail.lower() + def test_api_keys_create_multiple_unique_prefixes( self, authenticated_client, api_keys_fixture ): @@ -8260,6 +8322,10 @@ class TestTenantApiKeyViewSet: assert included_user["type"] == "users" assert included_user["id"] == str(api_key.entity.id) + # Refresh entity from database to get current state + # (in case other tests modified the shared session-scoped user fixture) + api_key.entity.refresh_from_db() + # Verify UserIncludeSerializer fields are present user_attrs = included_user["attributes"] assert "name" in user_attrs diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 70e002cd10..8456c5a081 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -2872,6 +2872,13 @@ class TenantApiKeyCreateSerializer(RLSSerializer, BaseWriteSerializer): "api_key": {"read_only": True}, } + def validate_name(self, value): + """Validate that the name is unique within the tenant.""" + tenant_id = self.context.get("tenant_id") + if TenantAPIKey.objects.filter(tenant_id=tenant_id, name=value).exists(): + raise ValidationError("An API key with this name already exists.") + return value + def get_api_key(self, obj): """Return the raw API key if it was stored during creation.""" return getattr(obj, "_raw_api_key", None) @@ -2913,3 +2920,14 @@ class TenantApiKeyUpdateSerializer(RLSSerializer, BaseWriteSerializer): "inserted_at": {"read_only": True}, "last_used_at": {"read_only": True}, } + + def validate_name(self, value): + """Validate that the name is unique within the tenant, excluding current instance.""" + tenant_id = self.context.get("tenant_id") + if ( + TenantAPIKey.objects.filter(tenant_id=tenant_id, name=value) + .exclude(id=self.instance.id) + .exists() + ): + raise ValidationError("An API key with this name already exists.") + return value From fba2854f651623321b828810349236458655f013 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Fri, 10 Oct 2025 14:56:34 +0200 Subject: [PATCH 16/22] fix(ui): minor bugs (#8898) --- ui/CHANGELOG.md | 8 +- ui/app/(prowler)/users/page.tsx | 4 +- .../compliance-custom-details/cis-details.tsx | 2 +- ui/components/users/forms/edit-form.tsx | 7 +- ui/lib/compliance/mitre.tsx | 90 ++++++++++--------- ui/types/compliance.ts | 2 +- 6 files changed, 63 insertions(+), 50 deletions(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index e03ba49540..471df7c7d0 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -31,15 +31,11 @@ All notable changes to the **Prowler UI** are documented in this file. - SAML configuration errors are now properly caught and displayed [(#8880)](https://github.com/prowler-cloud/prowler/pull/8880) - ThreatScore for each pillar in Prowler ThreatScore specific view [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582) +- Remove maxTokens model param for GPT-5 models [(#8843)](https://github.com/prowler-cloud/prowler/pull/8843) +- MITRE ATTACK compliance view now shows all requirements in charts [(#8886)](https://github.com/prowler-cloud/prowler/pull/8886) --- -## [1.12.4] (Prowler v5.12.4) - -### 🐞 Fixed - -- Remove maxTokens model param for GPT-5 models [(#8843)](https://github.com/prowler-cloud/prowler/pull/8843) - ## [1.12.3] (Prowler v5.12.3) ### 🐞 Fixed diff --git a/ui/app/(prowler)/users/page.tsx b/ui/app/(prowler)/users/page.tsx index 8a872963cd..5bf40c7f08 100644 --- a/ui/app/(prowler)/users/page.tsx +++ b/ui/app/(prowler)/users/page.tsx @@ -1,6 +1,7 @@ import { Spacer } from "@heroui/spacer"; import { Suspense } from "react"; +import { getRoles } from "@/actions/roles/roles"; import { getUsers } from "@/actions/users/users"; import { FilterControls } from "@/components/filters"; import { filterUsers } from "@/components/filters/data-filters"; @@ -52,6 +53,7 @@ const SSRDataTable = async ({ const query = (filters["filter[search]"] as string) || ""; const usersData = await getUsers({ query, page, sort, filters, pageSize }); + const rolesData = await getRoles({}); // Create a dictionary for roles by user ID const roleDict = (usersData?.included || []).reduce( @@ -67,7 +69,7 @@ const SSRDataTable = async ({ // Generate the array of roles with all the roles available const roles = Array.from( new Map( - (usersData?.included || []).map((role: Role) => [ + (rolesData?.data || []).map((role: Role) => [ role.id, { id: role.id, name: role.attributes?.name || "Unnamed Role" }, ]), diff --git a/ui/components/compliance/compliance-custom-details/cis-details.tsx b/ui/components/compliance/compliance-custom-details/cis-details.tsx index 367c579133..be880fd522 100644 --- a/ui/components/compliance/compliance-custom-details/cis-details.tsx +++ b/ui/components/compliance/compliance-custom-details/cis-details.tsx @@ -17,7 +17,7 @@ interface CISDetailsProps { export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { const processReferences = ( - references: string | number | string[] | object[] | undefined, + references: string | number | boolean | string[] | object[] | undefined, ): string[] => { if (typeof references !== "string") return []; diff --git a/ui/components/users/forms/edit-form.tsx b/ui/components/users/forms/edit-form.tsx index ec607281c8..be571cf958 100644 --- a/ui/components/users/forms/edit-form.tsx +++ b/ui/components/users/forms/edit-form.tsx @@ -119,7 +119,12 @@ export const EditForm = ({
- Role: + + Role: + + {currentRole ? currentRole : "No role"} + + {currentRole} diff --git a/ui/lib/compliance/mitre.tsx b/ui/lib/compliance/mitre.tsx index 19678e389d..1079c334e2 100644 --- a/ui/lib/compliance/mitre.tsx +++ b/ui/lib/compliance/mitre.tsx @@ -25,16 +25,15 @@ export const mapComplianceData = ( ): Framework[] => { const attributes = attributesData?.data || []; const requirementsMap = createRequirementsMap(requirementsData); + const frameworks: Framework[] = []; - // Process attributes and merge with requirements data + // Process ALL attributes to ensure consistent counters for charts for (const attributeItem of attributes) { const id = attributeItem.id; const metadataArray = attributeItem.attributes?.attributes ?.metadata as unknown as MITREAttributesMetadata[]; - if (!metadataArray || metadataArray.length === 0) continue; - // Get corresponding requirement data const requirementData = requirementsMap.get(id); if (!requirementData) continue; @@ -56,6 +55,7 @@ export const mapComplianceData = ( const framework = findOrCreateFramework(frameworks, frameworkName); // Create requirement directly (flat structure - no categories) + // Include ALL requirements, even those without metadata (for accurate chart counts) const finalStatus: RequirementStatus = status as RequirementStatus; const requirement: Requirement = { name: requirementName, @@ -72,20 +72,23 @@ export const mapComplianceData = ( subtechniques: subtechniques, platforms: platforms, technique_url: techniqueUrl, - cloud_services: metadataArray.map((m) => { - // Dynamically find the service field (AWSService, GCPService, AzureService, etc.) - const serviceKey = Object.keys(m).find((key) => - key.toLowerCase().includes("service"), - ); - const serviceName = serviceKey ? m[serviceKey] : "Unknown Service"; + // Mark items without metadata so accordion can filter them out + hasMetadata: !!(metadataArray && metadataArray.length > 0), + cloud_services: + metadataArray?.map((m) => { + // Dynamically find the service field (AWSService, GCPService, AzureService, etc.) + const serviceKey = Object.keys(m).find((key) => + key.toLowerCase().includes("service"), + ); + const serviceName = serviceKey ? m[serviceKey] : "Unknown Service"; - return { - service: serviceName, - category: m.Category, - value: m.Value, - comment: m.Comment, - }; - }), + return { + service: serviceName, + category: m.Category, + value: m.Value, + comment: m.Comment, + }; + }) || [], }; // Add requirement directly to framework (store in a special property) @@ -106,31 +109,38 @@ export const toAccordionItems = ( return data.flatMap((framework) => { const requirements = (framework as any).requirements || []; - return requirements.map((requirement: Requirement, i: number) => { - const itemKey = `${framework.name}-req-${i}`; + // Filter out requirements without metadata (can't be displayed in accordion) + const displayableRequirements = requirements.filter( + (requirement: Requirement) => requirement.hasMetadata !== false, + ); - return { - key: itemKey, - title: ( - - ), - content: ( - - ), - items: [], - }; - }); + return displayableRequirements.map( + (requirement: Requirement, i: number) => { + const itemKey = `${framework.name}-req-${i}`; + + return { + key: itemKey, + title: ( + + ), + content: ( + + ), + items: [], + }; + }, + ); }); }; diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index 17e0189732..373ec4f79a 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -27,7 +27,7 @@ export interface Requirement { check_ids: string[]; // This is to allow any key to be added to the requirement object // because each compliance has different keys - [key: string]: string | string[] | number | object[] | undefined; + [key: string]: string | string[] | number | boolean | object[] | undefined; } export interface Control { From 5f9ab68bd97aa30d97302c042caf43ff95d86f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Mon, 13 Oct 2025 10:31:02 +0200 Subject: [PATCH 17/22] feat(mcp): add GitHub Action to publish MCP Server container to DockerHub (#8875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: César Arroba <19954079+cesararroba@users.noreply.github.com> --- .../mcp-server-build-push-containers.yml | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 .github/workflows/mcp-server-build-push-containers.yml diff --git a/.github/workflows/mcp-server-build-push-containers.yml b/.github/workflows/mcp-server-build-push-containers.yml new file mode 100644 index 0000000000..ec10caddab --- /dev/null +++ b/.github/workflows/mcp-server-build-push-containers.yml @@ -0,0 +1,90 @@ +name: MCP Server - Build and Push containers + +on: + push: + branches: + - "master" + paths: + - "mcp_server/**" + - ".github/workflows/mcp-server-build-push-containers.yml" + + # Uncomment the below code to test this action on PRs + # pull_request: + # branches: + # - "master" + # paths: + # - "mcp_server/**" + # - ".github/workflows/mcp-server-build-push-containers.yml" + + release: + types: [published] + +env: + # Tags + LATEST_TAG: latest + + WORKING_DIRECTORY: ./mcp_server + + # Container Registries + PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud + PROWLERCLOUD_DOCKERHUB_IMAGE: prowler-mcp + +jobs: + repository-check: + name: Repository check + runs-on: ubuntu-latest + outputs: + is_repo: ${{ steps.repository_check.outputs.is_repo }} + steps: + - name: Repository check + id: repository_check + working-directory: /tmp + run: | + if [[ ${{ github.repository }} == "prowler-cloud/prowler" ]] + then + echo "is_repo=true" >> "${GITHUB_OUTPUT}" + else + echo "This action only runs for prowler-cloud/prowler" + echo "is_repo=false" >> "${GITHUB_OUTPUT}" + fi + + container-build-push: + needs: repository-check + if: needs.repository-check.outputs.is_repo == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ env.WORKING_DIRECTORY }} + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Set short git commit SHA + id: vars + run: | + shortSha=$(git rev-parse --short ${{ github.sha }}) + echo "SHORT_SHA=${shortSha}" >> $GITHUB_ENV + + - name: Login to DockerHub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + + - name: Build and push container image (latest) + # Comment the following line for testing + if: github.event_name == 'push' + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: ${{ env.WORKING_DIRECTORY }} + # Set push: false for testing + push: true + tags: | + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.SHORT_SHA }} + cache-from: type=gha + cache-to: type=gha,mode=max From 741217ce809fef3eba371f0bce9beddf77204714 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Mon, 13 Oct 2025 13:48:00 +0200 Subject: [PATCH 18/22] feat(ui): API keys implementation (#8874) --- ui/CHANGELOG.md | 1 + ui/actions/api-keys/api-keys.adapter.ts | 44 +++++ ui/actions/api-keys/api-keys.ts | 184 ++++++++++++++++++ ui/app/(prowler)/profile/page.tsx | 32 +-- ui/components/ui/alert/Alert.tsx | 2 +- .../users/profile/api-key-success-modal.tsx | 67 +++++++ .../users/profile/api-keys-card-client.tsx | 131 +++++++++++++ ui/components/users/profile/api-keys-card.tsx | 23 +++ .../profile/api-keys/column-api-keys.tsx | 86 ++++++++ .../users/profile/api-keys/constants.ts | 10 + .../api-keys/data-table-row-actions.tsx | 86 ++++++++ .../users/profile/api-keys/modal-buttons.tsx | 42 ++++ .../users/profile/api-keys/table-cells.tsx | 38 ++++ ui/components/users/profile/api-keys/types.ts | 132 +++++++++++++ .../users/profile/api-keys/use-modal-form.ts | 71 +++++++ ui/components/users/profile/api-keys/utils.ts | 95 +++++++++ .../users/profile/create-api-key-modal.tsx | 141 ++++++++++++++ .../users/profile/edit-api-key-name-modal.tsx | 138 +++++++++++++ ui/components/users/profile/index.ts | 6 + .../users/profile/revoke-api-key-modal.tsx | 93 +++++++++ 20 files changed, 1410 insertions(+), 12 deletions(-) create mode 100644 ui/actions/api-keys/api-keys.adapter.ts create mode 100644 ui/actions/api-keys/api-keys.ts create mode 100644 ui/components/users/profile/api-key-success-modal.tsx create mode 100644 ui/components/users/profile/api-keys-card-client.tsx create mode 100644 ui/components/users/profile/api-keys-card.tsx create mode 100644 ui/components/users/profile/api-keys/column-api-keys.tsx create mode 100644 ui/components/users/profile/api-keys/constants.ts create mode 100644 ui/components/users/profile/api-keys/data-table-row-actions.tsx create mode 100644 ui/components/users/profile/api-keys/modal-buttons.tsx create mode 100644 ui/components/users/profile/api-keys/table-cells.tsx create mode 100644 ui/components/users/profile/api-keys/types.ts create mode 100644 ui/components/users/profile/api-keys/use-modal-form.ts create mode 100644 ui/components/users/profile/api-keys/utils.ts create mode 100644 ui/components/users/profile/create-api-key-modal.tsx create mode 100644 ui/components/users/profile/edit-api-key-name-modal.tsx create mode 100644 ui/components/users/profile/revoke-api-key-modal.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 471df7c7d0..022f719817 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file. - React Compiler support for automatic optimization [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - Turbopack support for faster development builds [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748) - Add compliance name in compliance detail view [(#8775)](https://github.com/prowler-cloud/prowler/pull/8775) +- API key management in user profile [(#8308)](https://github.com/prowler-cloud/prowler/pull/8308) - Refresh access token error handling [(#8864)](https://github.com/prowler-cloud/prowler/pull/8864) ### 🔄 Changed diff --git a/ui/actions/api-keys/api-keys.adapter.ts b/ui/actions/api-keys/api-keys.adapter.ts new file mode 100644 index 0000000000..313cf0dba7 --- /dev/null +++ b/ui/actions/api-keys/api-keys.adapter.ts @@ -0,0 +1,44 @@ +import { + ApiKeyResponse, + EnrichedApiKey, +} from "@/components/users/profile/api-keys/types"; +import { getApiKeyUserEmail } from "@/components/users/profile/api-keys/utils"; +import { MetaDataProps } from "@/types"; + +/** + * Adapts the raw API response to enriched API keys with metadata + * - Resolves user email from included resources + * - Co-locates data for better performance + * - Preserves pagination metadata + * + * @param response - Raw API response with data and included resources + * @returns Object with enriched API keys and metadata + */ +export function adaptApiKeysResponse(response: ApiKeyResponse | undefined): { + data: EnrichedApiKey[]; + metadata?: MetaDataProps; +} { + if (!response?.data) { + return { data: [] }; + } + + const enrichedData = response.data.map((key) => ({ + ...key, + userEmail: getApiKeyUserEmail(key, response.included), + })); + + // Transform meta to MetaDataProps format if pagination exists + const metadata: MetaDataProps | undefined = response.meta?.pagination + ? { + pagination: { + page: response.meta.pagination.page, + pages: response.meta.pagination.pages, + count: response.meta.pagination.count, + itemsPerPage: [10, 25, 50, 100], + }, + version: "1.0", + } + : undefined; + + return { data: enrichedData, metadata }; +} diff --git a/ui/actions/api-keys/api-keys.ts b/ui/actions/api-keys/api-keys.ts new file mode 100644 index 0000000000..966cc2114b --- /dev/null +++ b/ui/actions/api-keys/api-keys.ts @@ -0,0 +1,184 @@ +"use server"; + +import { revalidateTag } from "next/cache"; + +import { + ApiKeyResponse, + CreateApiKeyPayload, + CreateApiKeyResponse, + SingleApiKeyResponse, + UpdateApiKeyPayload, +} from "@/components/users/profile/api-keys/types"; +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; + +import { adaptApiKeysResponse } from "./api-keys.adapter"; + +interface GetApiKeysParams { + page?: number; + pageSize?: number; + sort?: string; +} + +/** + * Fetches API keys for the current tenant with pagination support + * Returns enriched API keys with user data already resolved and pagination metadata + */ +export const getApiKeys = async (params?: GetApiKeysParams) => { + const { page = 1, pageSize = 10, sort } = params || {}; + + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/api-keys`); + url.searchParams.set("include", "entity.roles"); + url.searchParams.set("page[number]", page.toString()); + url.searchParams.set("page[size]", pageSize.toString()); + + if (sort) { + url.searchParams.set("sort", sort); + } + + try { + const response = await fetch(url.toString(), { + headers, + next: { tags: ["api-keys"] }, + }); + + const apiResponse = (await handleApiResponse(response)) as ApiKeyResponse; + + return adaptApiKeysResponse(apiResponse); + } catch (error) { + console.error("Error fetching API keys:", error); + return { data: [], metadata: undefined }; + } +}; + +/** + * Creates a new API key + * IMPORTANT: The full API key is only returned in this response, it cannot be retrieved again + */ +export const createApiKey = async ( + payload: CreateApiKeyPayload, +): Promise< + | { data: CreateApiKeyResponse; error?: never } + | { data?: never; error: string } +> => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/api-keys`); + + const body = { + data: { + type: "api-keys", + attributes: { + name: payload.name, + ...(payload.expires_at && { expires_at: payload.expires_at }), + }, + }, + }; + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + return handleApiError(response); + } + + const data = (await handleApiResponse(response)) as CreateApiKeyResponse; + + // Revalidate the api-keys list + revalidateTag("api-keys"); + + return { data }; + } catch (error) { + console.error("Error creating API key:", error); + return { + error: + error instanceof Error ? error.message : "Failed to create API key", + }; + } +}; + +/** + * Updates an API key (only the name can be updated) + */ +export const updateApiKey = async ( + id: string, + payload: UpdateApiKeyPayload, +): Promise< + | { data: SingleApiKeyResponse; error?: never } + | { data?: never; error: string } +> => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/api-keys/${id}`); + + const body = { + data: { + type: "api-keys", + id, + attributes: { + name: payload.name, + }, + }, + }; + + try { + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + return handleApiError(response); + } + + const data = (await handleApiResponse(response)) as SingleApiKeyResponse; + + // Revalidate the api-keys list + revalidateTag("api-keys"); + + return { data }; + } catch (error) { + console.error("Error updating API key:", error); + return { + error: + error instanceof Error ? error.message : "Failed to update API key", + }; + } +}; + +/** + * Revokes an API key (cannot be undone) + */ +export const revokeApiKey = async ( + id: string, +): Promise<{ error?: string; success?: boolean }> => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/api-keys/${id}/revoke`); + + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + if (!response.ok) { + const errorData = handleApiError(response); + return { error: errorData.error }; + } + + // Revalidate the api-keys list + revalidateTag("api-keys"); + + return { success: true }; + } catch (error) { + console.error("Error revoking API key:", error); + return { + error: + error instanceof Error ? error.message : "Failed to revoke API key", + }; + } +}; diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx index c3898439bd..41ff81a5fb 100644 --- a/ui/app/(prowler)/profile/page.tsx +++ b/ui/app/(prowler)/profile/page.tsx @@ -4,10 +4,11 @@ import { getSamlConfig } from "@/actions/integrations/saml"; import { getUserInfo } from "@/actions/users/users"; import { SamlIntegrationCard } from "@/components/integrations/saml/saml-integration-card"; import { ContentLayout } from "@/components/ui"; -import { UserBasicInfoCard } from "@/components/users/profile"; +import { ApiKeysCard, UserBasicInfoCard } from "@/components/users/profile"; import { MembershipsCard } from "@/components/users/profile/memberships-card"; import { RolesCard } from "@/components/users/profile/roles-card"; import { SkeletonUserInfo } from "@/components/users/profile/skeleton-user-info"; +import { SearchParamsProps } from "@/types"; import { MembershipDetailData, RoleDetail, @@ -15,17 +16,27 @@ import { UserProfileResponse, } from "@/types/users"; -export default async function Profile() { +export default async function Profile({ + searchParams, +}: { + searchParams: Promise; +}) { + const resolvedSearchParams = await searchParams; + return ( }> - + ); } -const SSRDataUser = async () => { +const SSRDataUser = async ({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) => { const userProfile = (await getUserInfo()) as UserProfileResponse | undefined; if (!userProfile?.data) { return null; @@ -96,22 +107,21 @@ const SSRDataUser = async () => {
-
+
+ {hasManageIntegrations && ( + + )}
-
+
+ {hasManageAccount && }
- {hasManageIntegrations && ( -
- -
- )}
); }; diff --git a/ui/components/ui/alert/Alert.tsx b/ui/components/ui/alert/Alert.tsx index ed1deaae72..d83437a926 100644 --- a/ui/components/ui/alert/Alert.tsx +++ b/ui/components/ui/alert/Alert.tsx @@ -10,7 +10,7 @@ const alertVariants = cva( variant: { default: "bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50", destructive: - "border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900", + "bg-danger-50 border-red-500/50 text-red-200 dark:border-red-500 dark:border-red-900/50 dark:text-red-200 dark:dark:border-red-900", }, }, defaultVariants: { diff --git a/ui/components/users/profile/api-key-success-modal.tsx b/ui/components/users/profile/api-key-success-modal.tsx new file mode 100644 index 0000000000..fdfcec2e59 --- /dev/null +++ b/ui/components/users/profile/api-key-success-modal.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Snippet } from "@heroui/snippet"; + +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert/Alert"; +import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal"; +import { CustomButton } from "@/components/ui/custom/custom-button"; + +interface ApiKeySuccessModalProps { + isOpen: boolean; + onClose: () => void; + apiKey: string; +} + +export const ApiKeySuccessModal = ({ + isOpen, + onClose, + apiKey, +}: ApiKeySuccessModalProps) => { + return ( + !open && onClose()} + title="API Key Created Successfully" + size="2xl" + > +
+ + ⚠️ Important + + This is the only time you will see this API key. Please copy it now + and store it securely. Once you close this dialog, the key cannot be + retrieved again. + + + +
+

Your API Key

+ + {apiKey} + +
+
+ + + Acknowledged + +
+ ); +}; diff --git a/ui/components/users/profile/api-keys-card-client.tsx b/ui/components/users/profile/api-keys-card-client.tsx new file mode 100644 index 0000000000..f7d806fc69 --- /dev/null +++ b/ui/components/users/profile/api-keys-card-client.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { Card, CardBody, CardHeader } from "@heroui/card"; +import { useDisclosure } from "@heroui/use-disclosure"; +import { Plus } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { CustomButton } from "@/components/ui/custom/custom-button"; +import { DataTable } from "@/components/ui/table"; +import { MetaDataProps } from "@/types"; + +import { ApiKeySuccessModal } from "./api-key-success-modal"; +import { createApiKeyColumns } from "./api-keys/column-api-keys"; +import { ICON_SIZE } from "./api-keys/constants"; +import { EnrichedApiKey } from "./api-keys/types"; +import { CreateApiKeyModal } from "./create-api-key-modal"; +import { EditApiKeyNameModal } from "./edit-api-key-name-modal"; +import { RevokeApiKeyModal } from "./revoke-api-key-modal"; + +interface ApiKeysCardClientProps { + initialApiKeys: EnrichedApiKey[]; + metadata?: MetaDataProps; +} + +export const ApiKeysCardClient = ({ + initialApiKeys, + metadata, +}: ApiKeysCardClientProps) => { + const router = useRouter(); + const [selectedApiKey, setSelectedApiKey] = useState( + null, + ); + const [createdApiKey, setCreatedApiKey] = useState(null); + + const createModal = useDisclosure(); + const successModal = useDisclosure(); + const revokeModal = useDisclosure(); + const editModal = useDisclosure(); + + const handleCreateSuccess = (apiKey: string) => { + setCreatedApiKey(apiKey); + successModal.onOpen(); + router.refresh(); + }; + + const handleRevokeSuccess = () => { + router.refresh(); + }; + + const handleEditSuccess = () => { + router.refresh(); + }; + + const handleRevokeClick = (apiKey: EnrichedApiKey) => { + setSelectedApiKey(apiKey); + revokeModal.onOpen(); + }; + + const handleEditClick = (apiKey: EnrichedApiKey) => { + setSelectedApiKey(apiKey); + editModal.onOpen(); + }; + + const columns = createApiKeyColumns(handleEditClick, handleRevokeClick); + + return ( + <> + + +
+

API Keys

+

Manage API keys for programmatic access

+
+ } + onPress={createModal.onOpen} + > + Create API Key + +
+ + {initialApiKeys.length === 0 ? ( +
+

No API keys created yet.

+
+ ) : ( + + )} +
+
+ + {/* Modals */} + + + {createdApiKey && ( + + )} + + + + + + ); +}; diff --git a/ui/components/users/profile/api-keys-card.tsx b/ui/components/users/profile/api-keys-card.tsx new file mode 100644 index 0000000000..ce4018b47c --- /dev/null +++ b/ui/components/users/profile/api-keys-card.tsx @@ -0,0 +1,23 @@ +import { getApiKeys } from "@/actions/api-keys/api-keys"; +import { SearchParamsProps } from "@/types"; + +import { ApiKeysCardClient } from "./api-keys-card-client"; + +export const ApiKeysCard = async ({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) => { + const page = parseInt(searchParams.page?.toString() || "1", 10); + const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); + const sort = searchParams.sort?.toString(); + + const apiKeysResponse = await getApiKeys({ page, pageSize, sort }); + + return ( + + ); +}; diff --git a/ui/components/users/profile/api-keys/column-api-keys.tsx b/ui/components/users/profile/api-keys/column-api-keys.tsx new file mode 100644 index 0000000000..3c113e39ce --- /dev/null +++ b/ui/components/users/profile/api-keys/column-api-keys.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; + +import { DataTableColumnHeader } from "@/components/ui/table"; + +import { DataTableRowActions } from "./data-table-row-actions"; +import { + DateCell, + EmailCell, + LastUsedCell, + NameCell, + PrefixCell, + StatusCell, +} from "./table-cells"; +import { EnrichedApiKey } from "./types"; + +export const createApiKeyColumns = ( + onEdit: (apiKey: EnrichedApiKey) => void, + onRevoke: (apiKey: EnrichedApiKey) => void, +): ColumnDef[] => [ + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + accessorKey: "prefix", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + id: "email", + header: "EMAIL", + cell: ({ row }) => , + enableSorting: false, + }, + { + accessorKey: "inserted_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + accessorKey: "last_used_at", + header: "LAST USED", + cell: ({ row }) => , + enableSorting: false, + }, + { + accessorKey: "expires_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + accessorKey: "revoked", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + id: "actions", + header: "", + cell: ({ row }) => { + return ( + + ); + }, + }, +]; diff --git a/ui/components/users/profile/api-keys/constants.ts b/ui/components/users/profile/api-keys/constants.ts new file mode 100644 index 0000000000..7a54fad365 --- /dev/null +++ b/ui/components/users/profile/api-keys/constants.ts @@ -0,0 +1,10 @@ +export const DEFAULT_EXPIRY_DAYS = "365"; +export const ICON_SIZE = 16; + +// Fallback values for display +export const FALLBACK_VALUES = { + UNNAMED: "Unnamed", + UNNAMED_KEY: "Unnamed Key", + NEVER: "Never", + UNKNOWN: "Unknown", +} as const; diff --git a/ui/components/users/profile/api-keys/data-table-row-actions.tsx b/ui/components/users/profile/api-keys/data-table-row-actions.tsx new file mode 100644 index 0000000000..667b876f4d --- /dev/null +++ b/ui/components/users/profile/api-keys/data-table-row-actions.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Button } from "@heroui/button"; +import { + Dropdown, + DropdownItem, + DropdownMenu, + DropdownSection, + DropdownTrigger, +} from "@heroui/dropdown"; +import { + DeleteDocumentBulkIcon, + EditDocumentBulkIcon, +} from "@heroui/shared-icons"; +import { Row } from "@tanstack/react-table"; +import clsx from "clsx"; + +import { VerticalDotsIcon } from "@/components/icons"; + +import { EnrichedApiKey } from "./types"; + +interface DataTableRowActionsProps { + row: Row; + onEdit: (apiKey: EnrichedApiKey) => void; + onRevoke: (apiKey: EnrichedApiKey) => void; +} + +const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0"; + +export function DataTableRowActions({ + row, + onEdit, + onRevoke, +}: DataTableRowActionsProps) { + const apiKey = row.original; + + return ( +
+ + + + + + + } + onPress={() => onEdit(apiKey)} + > + Edit name + + + + + } + onPress={() => onRevoke(apiKey)} + > + Revoke + + + + +
+ ); +} diff --git a/ui/components/users/profile/api-keys/modal-buttons.tsx b/ui/components/users/profile/api-keys/modal-buttons.tsx new file mode 100644 index 0000000000..6fa1b68892 --- /dev/null +++ b/ui/components/users/profile/api-keys/modal-buttons.tsx @@ -0,0 +1,42 @@ +import { CustomButton } from "@/components/ui/custom/custom-button"; + +interface ModalButtonsProps { + onCancel: () => void; + onSubmit: () => void; + isLoading: boolean; + isDisabled?: boolean; + submitText?: string; + submitColor?: "action" | "danger"; +} + +export const ModalButtons = ({ + onCancel, + onSubmit, + isLoading, + isDisabled = false, + submitText = "Save", + submitColor = "action", +}: ModalButtonsProps) => { + return ( +
+ + Cancel + + + {submitText} + +
+ ); +}; diff --git a/ui/components/users/profile/api-keys/table-cells.tsx b/ui/components/users/profile/api-keys/table-cells.tsx new file mode 100644 index 0000000000..bcba736056 --- /dev/null +++ b/ui/components/users/profile/api-keys/table-cells.tsx @@ -0,0 +1,38 @@ +import { Chip } from "@heroui/chip"; + +import { FALLBACK_VALUES } from "./constants"; +import { EnrichedApiKey, getApiKeyStatus } from "./types"; +import { formatRelativeTime, getStatusColor, getStatusLabel } from "./utils"; + +export const NameCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => ( +

+ {apiKey.attributes.name || FALLBACK_VALUES.UNNAMED} +

+); + +export const PrefixCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => ( + + {apiKey.attributes.prefix} + +); + +export const DateCell = ({ date }: { date: string | null }) => ( +

{formatRelativeTime(date)}

+); + +export const LastUsedCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => ( + +); + +export const StatusCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => { + const status = getApiKeyStatus(apiKey); + return ( + + {getStatusLabel(status)} + + ); +}; + +export const EmailCell = ({ apiKey }: { apiKey: EnrichedApiKey }) => ( +

{apiKey.userEmail}

+); diff --git a/ui/components/users/profile/api-keys/types.ts b/ui/components/users/profile/api-keys/types.ts new file mode 100644 index 0000000000..73ec10a68a --- /dev/null +++ b/ui/components/users/profile/api-keys/types.ts @@ -0,0 +1,132 @@ +// API Key types following JSON:API specification + +export interface ApiKeyAttributes { + name: string | null; + prefix: string; + expires_at: string; + revoked: boolean; + inserted_at: string; + last_used_at: string | null; +} + +export interface ApiKeyRelationships { + entity: { + data: { + type: "users"; + id: string; + } | null; + }; +} + +export interface ApiKeyData { + type: "api-keys"; + id: string; + attributes: ApiKeyAttributes; + relationships?: ApiKeyRelationships; +} + +// Included resource types +export interface UserAttributes { + name: string; + email: string; + company_name: string; + date_joined: string; +} + +export interface RoleAttributes { + name: string; + manage_users: boolean; + manage_account: boolean; +} + +export interface UserData { + type: "users"; + id: string; + attributes: UserAttributes; + relationships?: { + roles: { + data: Array<{ + type: "roles"; + id: string; + }>; + meta?: { + count: number; + }; + }; + }; +} + +export interface RoleData { + type: "roles"; + id: string; + attributes: RoleAttributes; +} + +export type IncludedResource = UserData | RoleData; + +export interface ApiKeyResponse { + data: ApiKeyData[]; + included?: IncludedResource[]; + meta?: { + pagination?: { + page: number; + pages: number; + count: number; + }; + }; +} + +/** + * Enriched API Key with user data already resolved + * This type extends the base ApiKeyData with additional fields + * populated from the included resources in the API response + */ +export interface EnrichedApiKey extends ApiKeyData { + userEmail: string; +} + +export interface SingleApiKeyResponse { + data: ApiKeyData; +} + +export interface CreateApiKeyResponse { + data: ApiKeyData & { + attributes: ApiKeyAttributes & { + api_key: string; // Only present on creation + }; + }; +} + +export interface CreateApiKeyPayload { + name: string; + expires_at?: string; // ISO date string +} + +export interface UpdateApiKeyPayload { + name: string; +} + +// Status for UI display +export const API_KEY_STATUS = { + ACTIVE: "active", + REVOKED: "revoked", + EXPIRED: "expired", +} as const; + +export type ApiKeyStatus = (typeof API_KEY_STATUS)[keyof typeof API_KEY_STATUS]; + +// Helper to determine API key status +export const getApiKeyStatus = (apiKey: ApiKeyData): ApiKeyStatus => { + if (apiKey.attributes.revoked) { + return API_KEY_STATUS.REVOKED; + } + + const expiryDate = new Date(apiKey.attributes.expires_at); + const now = new Date(); + + if (expiryDate < now) { + return API_KEY_STATUS.EXPIRED; + } + + return API_KEY_STATUS.ACTIVE; +}; diff --git a/ui/components/users/profile/api-keys/use-modal-form.ts b/ui/components/users/profile/api-keys/use-modal-form.ts new file mode 100644 index 0000000000..a75564433d --- /dev/null +++ b/ui/components/users/profile/api-keys/use-modal-form.ts @@ -0,0 +1,71 @@ +import { useState } from "react"; + +interface UseModalFormOptions { + initialData: TFormData; + onSubmit: (data: TFormData) => Promise; + onSuccess?: () => void; + onClose: () => void; +} + +interface UseModalFormReturn { + formData: TFormData; + setFormData: React.Dispatch>; + isLoading: boolean; + error: string | null; + setError: (error: string | null) => void; + handleSubmit: () => Promise; + handleClose: () => void; + resetForm: () => void; +} + +/** + * Custom hook to manage modal form state and submission logic + * Reduces boilerplate in modal components + */ +export const useModalForm = ({ + initialData, + onSubmit, + onSuccess, + onClose, +}: UseModalFormOptions): UseModalFormReturn => { + const [formData, setFormData] = useState(initialData); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const resetForm = () => { + setFormData(initialData); + setError(null); + }; + + const handleSubmit = async () => { + setIsLoading(true); + setError(null); + + try { + await onSubmit(formData); + resetForm(); + onSuccess?.(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + return { + formData, + setFormData, + isLoading, + error, + setError, + handleSubmit, + handleClose, + resetForm, + }; +}; diff --git a/ui/components/users/profile/api-keys/utils.ts b/ui/components/users/profile/api-keys/utils.ts new file mode 100644 index 0000000000..c0e59bf18c --- /dev/null +++ b/ui/components/users/profile/api-keys/utils.ts @@ -0,0 +1,95 @@ +import { formatDistanceToNow } from "date-fns"; + +import { FALLBACK_VALUES } from "./constants"; +import { + API_KEY_STATUS, + ApiKeyData, + ApiKeyStatus, + IncludedResource, + UserData, +} from "./types"; + +export const getStatusColor = ( + status: ApiKeyStatus, +): "success" | "danger" | "warning" => { + const colorMap: Record = { + [API_KEY_STATUS.ACTIVE]: "success", + [API_KEY_STATUS.REVOKED]: "danger", + [API_KEY_STATUS.EXPIRED]: "warning", + }; + + return colorMap[status] || "success"; +}; + +export const getStatusLabel = (status: ApiKeyStatus): string => { + const labelMap: Record = { + [API_KEY_STATUS.ACTIVE]: "Active", + [API_KEY_STATUS.REVOKED]: "Revoked", + [API_KEY_STATUS.EXPIRED]: "Expired", + }; + + return labelMap[status] || FALLBACK_VALUES.UNKNOWN; +}; + +export const formatRelativeTime = (date: string | null): string => { + if (!date) return FALLBACK_VALUES.NEVER; + return formatDistanceToNow(new Date(date), { addSuffix: true }); +}; + +export const calculateExpiryDate = (days: number): string => { + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + days); + return expiresAt.toISOString(); +}; + +/** + * Generic utility to find a resource in the included array by type and ID + */ +export const findIncludedResource = ( + included: IncludedResource[] | undefined, + type: string, + id: string, +): T | undefined => { + if (!included) return undefined; + return included.find( + (resource): resource is T => resource.type === type && resource.id === id, + ); +}; + +/** + * Extracts the email from the included resources based on the API key's entity relationship + */ +export const getApiKeyUserEmail = ( + apiKey: ApiKeyData, + included?: IncludedResource[], +): string => { + if (!apiKey.relationships?.entity?.data) { + return FALLBACK_VALUES.UNKNOWN; + } + + const userId = apiKey.relationships.entity.data.id; + const user = findIncludedResource(included, "users", userId); + + return user?.attributes.email || FALLBACK_VALUES.UNKNOWN; +}; + +/** + * Checks if an API key name already exists in the list + * @param name - The name to check + * @param existingApiKeys - List of existing API keys + * @param excludeId - Optional ID to exclude from the check (for edit scenarios) + * @returns true if the name already exists, false otherwise + */ +export const isApiKeyNameDuplicate = ( + name: string, + existingApiKeys: ApiKeyData[], + excludeId?: string, +): boolean => { + const trimmedName = name.trim().toLowerCase(); + + return existingApiKeys.some( + (key) => + key.id !== excludeId && + key.attributes.name?.toLowerCase() === trimmedName, + ); +}; diff --git a/ui/components/users/profile/create-api-key-modal.tsx b/ui/components/users/profile/create-api-key-modal.tsx new file mode 100644 index 0000000000..0fcfc74c37 --- /dev/null +++ b/ui/components/users/profile/create-api-key-modal.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { createApiKey } from "@/actions/api-keys/api-keys"; +import { useToast } from "@/components/ui"; +import { CustomInput } from "@/components/ui/custom"; +import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal"; +import { Form } from "@/components/ui/form"; + +import { DEFAULT_EXPIRY_DAYS } from "./api-keys/constants"; +import { ModalButtons } from "./api-keys/modal-buttons"; +import { calculateExpiryDate } from "./api-keys/utils"; + +interface CreateApiKeyModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (apiKey: string) => void; +} + +const createApiKeySchema = z.object({ + name: z.string().min(1, "Name is required"), + expiresInDays: z.string().refine((val) => { + const num = parseInt(val); + return num >= 1 && num <= 3650; + }, "Must be between 1 and 3650 days"), +}); + +type FormValues = z.infer; + +export const CreateApiKeyModal = ({ + isOpen, + onClose, + onSuccess, +}: CreateApiKeyModalProps) => { + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(createApiKeySchema), + defaultValues: { + name: "", + expiresInDays: DEFAULT_EXPIRY_DAYS, + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormValues) => { + try { + const result = await createApiKey({ + name: values.name.trim(), + expires_at: calculateExpiryDate(parseInt(values.expiresInDays)), + }); + + if (result.error) { + throw new Error(result.error); + } + + if (!result.data) { + throw new Error("Failed to create API key"); + } + + const apiKey = result.data.data.attributes.api_key; + if (!apiKey) { + throw new Error("Failed to retrieve API key"); + } + + form.reset(); + onSuccess(apiKey); + onClose(); + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: + error instanceof Error + ? error.message + : "An unexpected error occurred", + }); + } + }; + + const handleClose = () => { + form.reset(); + onClose(); + }; + + return ( + !open && handleClose()} + title="Create API Key" + size="lg" + > +
+ +
+ +
+ +
+ +
+ + + + +
+ ); +}; diff --git a/ui/components/users/profile/edit-api-key-name-modal.tsx b/ui/components/users/profile/edit-api-key-name-modal.tsx new file mode 100644 index 0000000000..3fc02a5bf9 --- /dev/null +++ b/ui/components/users/profile/edit-api-key-name-modal.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { updateApiKey } from "@/actions/api-keys/api-keys"; +import { useToast } from "@/components/ui"; +import { CustomInput } from "@/components/ui/custom"; +import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal"; +import { Form } from "@/components/ui/form"; + +import { ModalButtons } from "./api-keys/modal-buttons"; +import { EnrichedApiKey } from "./api-keys/types"; +import { isApiKeyNameDuplicate } from "./api-keys/utils"; + +interface EditApiKeyNameModalProps { + isOpen: boolean; + onClose: () => void; + apiKey: EnrichedApiKey | null; + onSuccess: () => void; + existingApiKeys: EnrichedApiKey[]; +} + +const editApiKeyNameSchema = z.object({ + name: z.string().min(1, "Name is required"), +}); + +type FormValues = z.infer; + +export const EditApiKeyNameModal = ({ + isOpen, + onClose, + apiKey, + onSuccess, + existingApiKeys, +}: EditApiKeyNameModalProps) => { + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(editApiKeyNameSchema), + defaultValues: { + name: apiKey?.attributes.name || "", + }, + }); + + const isLoading = form.formState.isSubmitting; + + // Sync form data when apiKey changes or modal opens + useEffect(() => { + if (isOpen && apiKey) { + form.reset({ name: apiKey.attributes.name || "" }); + } + }, [isOpen, apiKey, form]); + + const onSubmitClient = async (values: FormValues) => { + try { + if (!apiKey) { + throw new Error("API key not found"); + } + + if (isApiKeyNameDuplicate(values.name, existingApiKeys, apiKey.id)) { + throw new Error( + "An API key with this name already exists. Please choose a different name.", + ); + } + + const result = await updateApiKey(apiKey.id, { + name: values.name.trim(), + }); + + if (result.error) { + throw new Error(result.error); + } + + form.reset(); + onSuccess(); + onClose(); + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: + error instanceof Error + ? error.message + : "An unexpected error occurred", + }); + } + }; + + const handleClose = () => { + form.reset(); + onClose(); + }; + + return ( + !open && handleClose()} + title="Edit API Key Name" + size="lg" + > +
+ +
+ Prefix: {apiKey?.attributes.prefix} +
+ +
+ +
+ + + + +
+ ); +}; diff --git a/ui/components/users/profile/index.ts b/ui/components/users/profile/index.ts index 02cdd56c9d..35afe2c8fa 100644 --- a/ui/components/users/profile/index.ts +++ b/ui/components/users/profile/index.ts @@ -1,5 +1,11 @@ +export * from "./api-key-success-modal"; +export * from "./api-keys-card"; +export * from "./api-keys-card-client"; +export * from "./create-api-key-modal"; +export * from "./edit-api-key-name-modal"; export * from "./membership-item"; export * from "./memberships-card"; +export * from "./revoke-api-key-modal"; export * from "./role-item"; export * from "./roles-card"; export * from "./user-basic-info-card"; diff --git a/ui/components/users/profile/revoke-api-key-modal.tsx b/ui/components/users/profile/revoke-api-key-modal.tsx new file mode 100644 index 0000000000..acf61091d8 --- /dev/null +++ b/ui/components/users/profile/revoke-api-key-modal.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { revokeApiKey } from "@/actions/api-keys/api-keys"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert/Alert"; +import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal"; + +import { FALLBACK_VALUES } from "./api-keys/constants"; +import { ModalButtons } from "./api-keys/modal-buttons"; +import { EnrichedApiKey } from "./api-keys/types"; +import { useModalForm } from "./api-keys/use-modal-form"; + +interface RevokeApiKeyModalProps { + isOpen: boolean; + onClose: () => void; + apiKey: EnrichedApiKey | null; + onSuccess: () => void; +} + +export const RevokeApiKeyModal = ({ + isOpen, + onClose, + apiKey, + onSuccess, +}: RevokeApiKeyModalProps) => { + const { isLoading, error, handleSubmit, handleClose } = useModalForm({ + initialData: {}, + onSubmit: async () => { + if (!apiKey) { + throw new Error("No API key selected"); + } + + const result = await revokeApiKey(apiKey.id); + + if (result.error) { + throw new Error(result.error); + } + + onSuccess(); + }, + onSuccess, + onClose, + }); + + return ( + !open && handleClose()} + title="Revoke API Key" + size="lg" + > +
+ + ⚠️ Warning + + This action cannot be undone. This API key will be revoked and will + no longer work. + + + +
+

Are you sure you want to revoke this API key?

+
+

+ {apiKey?.attributes.name || FALLBACK_VALUES.UNNAMED_KEY} +

+

+ Prefix: {apiKey?.attributes.prefix} +

+
+
+ + {error && ( + + {error} + + )} +
+ + +
+ ); +}; From 42e816081ed5c2204fadc8cd10b1f81c7bbacc5c Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Mon, 13 Oct 2025 13:53:28 +0200 Subject: [PATCH 19/22] feat: reusable graph components (#8873) Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> --- ui/components/graphs/BarChart.tsx | 162 ++++++++++++ ui/components/graphs/DonutChart.tsx | 171 ++++++++++++ ui/components/graphs/HorizontalBarChart.tsx | 121 +++++++++ ui/components/graphs/LineChart.tsx | 189 ++++++++++++++ ui/components/graphs/RadarChart.tsx | 146 +++++++++++ ui/components/graphs/RadialChart.tsx | 78 ++++++ ui/components/graphs/SankeyChart.tsx | 137 ++++++++++ ui/components/graphs/ScatterPlot.tsx | 181 +++++++++++++ ui/components/graphs/hooks/useSortableData.ts | 33 +++ ui/components/graphs/index.ts | 9 + ui/components/graphs/shared/AlertPill.tsx | 34 +++ ui/components/graphs/shared/ChartLegend.tsx | 24 ++ ui/components/graphs/shared/ChartTooltip.tsx | 123 +++++++++ ui/components/graphs/shared/constants.ts | 46 ++++ ui/components/graphs/shared/utils.ts | 13 + ui/components/graphs/types.ts | 55 ++++ ui/config/fonts.ts | 6 +- ui/styles/globals.css | 245 +++++++++++++++++- 18 files changed, 1764 insertions(+), 9 deletions(-) create mode 100644 ui/components/graphs/BarChart.tsx create mode 100644 ui/components/graphs/DonutChart.tsx create mode 100644 ui/components/graphs/HorizontalBarChart.tsx create mode 100644 ui/components/graphs/LineChart.tsx create mode 100644 ui/components/graphs/RadarChart.tsx create mode 100644 ui/components/graphs/RadialChart.tsx create mode 100644 ui/components/graphs/SankeyChart.tsx create mode 100644 ui/components/graphs/ScatterPlot.tsx create mode 100644 ui/components/graphs/hooks/useSortableData.ts create mode 100644 ui/components/graphs/index.ts create mode 100644 ui/components/graphs/shared/AlertPill.tsx create mode 100644 ui/components/graphs/shared/ChartLegend.tsx create mode 100644 ui/components/graphs/shared/ChartTooltip.tsx create mode 100644 ui/components/graphs/shared/constants.ts create mode 100644 ui/components/graphs/shared/utils.ts create mode 100644 ui/components/graphs/types.ts diff --git a/ui/components/graphs/BarChart.tsx b/ui/components/graphs/BarChart.tsx new file mode 100644 index 0000000000..8ebb7eca3b --- /dev/null +++ b/ui/components/graphs/BarChart.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { + Bar, + BarChart as RechartsBar, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { ChartTooltip } from "./shared/ChartTooltip"; +import { CHART_COLORS, LAYOUT_OPTIONS } from "./shared/constants"; +import { getSeverityColorByName } from "./shared/utils"; +import { BarDataPoint, LayoutOption } from "./types"; + +interface BarChartProps { + data: BarDataPoint[]; + layout?: LayoutOption; + xLabel?: string; + yLabel?: string; + height?: number; + showValues?: boolean; +} + +const CustomLabel = ({ x, y, width, height, value, data }: any) => { + const percentage = data.percentage; + return ( + + {percentage !== undefined + ? `${percentage}% • ${value.toLocaleString()}` + : value.toLocaleString()} + + ); +}; + +export function BarChart({ + data, + layout = LAYOUT_OPTIONS.horizontal, + xLabel, + yLabel, + height = 400, + showValues = true, +}: BarChartProps) { + const isHorizontal = layout === LAYOUT_OPTIONS.horizontal; + + return ( + + + + {isHorizontal ? ( + <> + + + + ) : ( + <> + + + + )} + } /> + ( + + ) + : false + } + > + {data.map((entry, index) => ( + + ))} + + + + ); +} diff --git a/ui/components/graphs/DonutChart.tsx b/ui/components/graphs/DonutChart.tsx new file mode 100644 index 0000000000..57713cc2f6 --- /dev/null +++ b/ui/components/graphs/DonutChart.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import { Cell, Label, Pie, PieChart, Tooltip } from "recharts"; + +import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart"; + +import { ChartLegend } from "./shared/ChartLegend"; +import { DonutDataPoint } from "./types"; + +interface DonutChartProps { + data: DonutDataPoint[]; + height?: number; + innerRadius?: number; + outerRadius?: number; + showLegend?: boolean; + centerLabel?: { + value: string | number; + label: string; + }; +} + +const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+
+
+ + {data.percentage}% {data.name} + +
+ {data.change !== undefined && ( +

+ + {data.change > 0 ? "+" : ""} + {data.change}% + {" "} + Since last scan +

+ )} +
+ ); + } + return null; +}; + +const CustomLegend = ({ payload }: any) => { + const items = payload.map((entry: any) => ({ + label: `${entry.value} (${entry.payload.percentage}%)`, + color: entry.color, + })); + + return ; +}; + +export function DonutChart({ + data, + innerRadius = 80, + outerRadius = 120, + showLegend = true, + centerLabel, +}: DonutChartProps) { + const [hoveredIndex, setHoveredIndex] = useState(null); + + const chartConfig = data.reduce( + (config, item) => ({ + ...config, + [item.name]: { + label: item.name, + color: item.color, + }, + }), + {} as ChartConfig, + ); + + const chartData = data.map((item) => ({ + name: item.name, + value: item.value, + fill: item.color, + color: item.color, + percentage: item.percentage, + change: item.change, + })); + + const legendPayload = chartData.map((entry) => ({ + value: entry.name, + color: entry.color, + payload: { + percentage: entry.percentage, + }, + })); + + return ( +
+ + + } /> + + {chartData.map((entry, index) => { + const opacity = + hoveredIndex === null ? 1 : hoveredIndex === index ? 1 : 0.5; + return ( + setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + /> + ); + })} + {centerLabel && ( + + + + {showLegend && } +
+ ); +} diff --git a/ui/components/graphs/HorizontalBarChart.tsx b/ui/components/graphs/HorizontalBarChart.tsx new file mode 100644 index 0000000000..b91e575656 --- /dev/null +++ b/ui/components/graphs/HorizontalBarChart.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Bell } from "lucide-react"; +import { useState } from "react"; + +import { CHART_COLORS, SEVERITY_ORDER } from "./shared/constants"; +import { getSeverityColorByName } from "./shared/utils"; +import { BarDataPoint } from "./types"; + +interface HorizontalBarChartProps { + data: BarDataPoint[]; + height?: number; + title?: string; +} + +export function HorizontalBarChart({ data, title }: HorizontalBarChartProps) { + const [hoveredIndex, setHoveredIndex] = useState(null); + + const sortedData = [...data].sort((a, b) => { + const orderA = SEVERITY_ORDER[a.name as keyof typeof SEVERITY_ORDER] ?? 999; + const orderB = SEVERITY_ORDER[b.name as keyof typeof SEVERITY_ORDER] ?? 999; + return orderA - orderB; + }); + + return ( +
+ {title && ( +
+

{title}

+
+ )} + +
+ {sortedData.map((item, index) => { + const isHovered = hoveredIndex === index; + const isFaded = hoveredIndex !== null && !isHovered; + const barColor = + item.color || + getSeverityColorByName(item.name) || + CHART_COLORS.defaultColor; + + return ( +
setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + > +
+ + {item.name} + +
+ +
+
+
d.value))) * 100}%`, + backgroundColor: barColor, + opacity: isFaded ? 0.5 : 1, + }} + /> + + {isHovered && ( +
+
+
+ + {item.value.toLocaleString()} {item.name} Risk + +
+ {item.newFindings !== undefined && ( +
+ + + {item.newFindings} New Findings + +
+ )} + {item.change !== undefined && ( +

+ + {item.change > 0 ? "+" : ""} + {item.change}% + {" "} + Since Last Scan +

+ )} +
+ )} +
+ +
+ {item.percentage}% + + {item.value.toLocaleString()} +
+
+ ); + })} +
+
+ ); +} diff --git a/ui/components/graphs/LineChart.tsx b/ui/components/graphs/LineChart.tsx new file mode 100644 index 0000000000..b19c9591f5 --- /dev/null +++ b/ui/components/graphs/LineChart.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { Bell } from "lucide-react"; +import { useState } from "react"; +import { + CartesianGrid, + Legend, + Line, + LineChart as RechartsLine, + ResponsiveContainer, + Tooltip, + TooltipProps, + XAxis, + YAxis, +} from "recharts"; + +import { AlertPill } from "./shared/AlertPill"; +import { ChartLegend } from "./shared/ChartLegend"; +import { CHART_COLORS } from "./shared/constants"; +import { LineConfig, LineDataPoint } from "./types"; + +interface LineChartProps { + data: LineDataPoint[]; + lines: LineConfig[]; + xLabel?: string; + yLabel?: string; + height?: number; +} + +interface TooltipPayloadItem { + dataKey: string; + value: number; + stroke: string; + name: string; + payload: LineDataPoint; +} + +const CustomLineTooltip = ({ + active, + payload, + label, +}: TooltipProps) => { + if (!active || !payload || payload.length === 0) { + return null; + } + + const typedPayload = payload as unknown as TooltipPayloadItem[]; + const totalValue = typedPayload.reduce((sum, item) => sum + item.value, 0); + + return ( +
+

{label}

+ +
+ +
+ +
+ {typedPayload.map((item) => { + const newFindings = item.payload[`${item.dataKey}_newFindings`]; + const change = item.payload[`${item.dataKey}_change`]; + + return ( +
+
+
+ {item.value} +
+ {newFindings !== undefined && ( +
+ + + {newFindings} New Findings + +
+ )} + {change !== undefined && typeof change === "number" && ( +

+ + {change > 0 ? "+" : ""} + {change}% + {" "} + Since Last Scan +

+ )} +
+ ); + })} +
+
+ ); +}; + +const CustomLegend = ({ payload }: any) => { + const severityOrder = [ + "Informational", + "Low", + "Medium", + "High", + "Critical", + "Muted", + ]; + + const sortedPayload = [...payload].sort((a, b) => { + const indexA = severityOrder.indexOf(a.value); + const indexB = severityOrder.indexOf(b.value); + return indexA - indexB; + }); + + const items = sortedPayload.map((entry: any) => ({ + label: entry.value, + color: entry.color, + })); + + return ; +}; + +export function LineChart({ + data, + lines, + xLabel, + yLabel, + height = 400, +}: LineChartProps) { + const [hoveredLine, setHoveredLine] = useState(null); + + return ( + + + + + + } /> + } /> + {lines.map((line) => { + const isHovered = hoveredLine === line.dataKey; + const isFaded = hoveredLine !== null && !isHovered; + return ( + setHoveredLine(line.dataKey)} + onMouseLeave={() => setHoveredLine(null)} + style={{ transition: "stroke-opacity 0.2s" }} + /> + ); + })} + + + ); +} diff --git a/ui/components/graphs/RadarChart.tsx b/ui/components/graphs/RadarChart.tsx new file mode 100644 index 0000000000..462c29dd68 --- /dev/null +++ b/ui/components/graphs/RadarChart.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { + PolarAngleAxis, + PolarGrid, + Radar, + RadarChart as RechartsRadar, +} from "recharts"; + +import { + ChartConfig, + ChartContainer, + ChartTooltip, +} from "@/components/ui/chart/Chart"; + +import { AlertPill } from "./shared/AlertPill"; +import { CHART_COLORS } from "./shared/constants"; +import { RadarDataPoint } from "./types"; + +interface RadarChartProps { + data: RadarDataPoint[]; + height?: number; + dataKey?: string; + onSelectPoint?: (point: RadarDataPoint | null) => void; + selectedPoint?: RadarDataPoint | null; +} + +const chartConfig = { + value: { + label: "Findings", + color: "var(--color-magenta)", + }, +} satisfies ChartConfig; + +const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0]; + return ( +
+

+ {data.payload.category} +

+
+ +
+ {data.payload.change !== undefined && ( +

+ + {data.payload.change > 0 ? "+" : ""} + {data.payload.change}% + {" "} + Since Last Scan +

+ )} +
+ ); + } + return null; +}; + +const CustomDot = (props: any) => { + const { cx, cy, payload, selectedPoint, onSelectPoint } = props; + const currentCategory = payload.category || payload.name; + const isSelected = selectedPoint?.category === currentCategory; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onSelectPoint) { + if (isSelected) { + onSelectPoint(null); + } else { + const point = { + category: currentCategory, + value: payload.value, + change: payload.change, + }; + onSelectPoint(point); + } + } + }; + + return ( + + ); +}; + +export function RadarChart({ + data, + height = 400, + dataKey = "value", + onSelectPoint, + selectedPoint, +}: RadarChartProps) { + return ( + + + } /> + + + { + const { key, ...rest } = dotProps; + return ( + + ); + } + : { + r: 6, + fill: "var(--color-purple-dark)", + fillOpacity: 1, + } + } + /> + + + ); +} diff --git a/ui/components/graphs/RadialChart.tsx b/ui/components/graphs/RadialChart.tsx new file mode 100644 index 0000000000..10f4007a4d --- /dev/null +++ b/ui/components/graphs/RadialChart.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { + PolarAngleAxis, + RadialBar, + RadialBarChart, + ResponsiveContainer, +} from "recharts"; + +import { CHART_COLORS } from "./shared/constants"; +interface RadialChartProps { + percentage: number; + label?: string; + color?: string; + backgroundColor?: string; + height?: number; + innerRadius?: number; + outerRadius?: number; + startAngle?: number; + endAngle?: number; +} + +export function RadialChart({ + percentage, + label = "Score", + color = "var(--color-success)", + backgroundColor = CHART_COLORS.tooltipBackground, + height = 250, + innerRadius = 60, + outerRadius = 100, + startAngle = 90, + endAngle = -270, +}: RadialChartProps) { + const data = [ + { + name: label, + value: percentage, + fill: color, + }, + ]; + + return ( + + + + + + {percentage}% + + + + ); +} diff --git a/ui/components/graphs/SankeyChart.tsx b/ui/components/graphs/SankeyChart.tsx new file mode 100644 index 0000000000..d9c35cd1df --- /dev/null +++ b/ui/components/graphs/SankeyChart.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { Rectangle, ResponsiveContainer, Sankey, Tooltip } from "recharts"; + +import { CHART_COLORS, SEVERITY_COLORS } from "./shared/constants"; + +interface SankeyNode { + name: string; +} + +interface SankeyLink { + source: number; + target: number; + value: number; +} + +interface SankeyChartProps { + data: { + nodes: SankeyNode[]; + links: SankeyLink[]; + }; + height?: number; +} + +const COLORS: Record = { + Success: "var(--color-success)", + Fail: "var(--color-destructive)", + AWS: "var(--color-orange)", + Azure: "var(--color-cyan)", + Google: "var(--color-red)", + ...SEVERITY_COLORS, +}; + +const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.name}

+ {data.value && ( +

Value: {data.value}

+ )} +
+ ); + } + return null; +}; + +const CustomNode = ({ x, y, width, height, payload, containerWidth }: any) => { + const isOut = x + width + 6 > containerWidth; + const nodeName = payload.name; + const color = COLORS[nodeName] || CHART_COLORS.defaultColor; + + return ( + + + + {nodeName} + + + {payload.value} + + + ); +}; + +const CustomLink = (props: any) => { + const { + sourceX, + targetX, + sourceY, + targetY, + sourceControlX, + targetControlX, + linkWidth, + } = props; + + const sourceName = props.payload.source?.name || ""; + const color = COLORS[sourceName] || CHART_COLORS.defaultColor; + + return ( + + + + ); +}; + +export function SankeyChart({ data, height = 400 }: SankeyChartProps) { + return ( + + } + link={} + nodePadding={50} + margin={{ top: 20, right: 160, bottom: 20, left: 160 }} + > + } /> + + + ); +} diff --git a/ui/components/graphs/ScatterPlot.tsx b/ui/components/graphs/ScatterPlot.tsx new file mode 100644 index 0000000000..920f6c0c10 --- /dev/null +++ b/ui/components/graphs/ScatterPlot.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { + CartesianGrid, + Legend, + ResponsiveContainer, + Scatter, + ScatterChart, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { AlertPill } from "./shared/AlertPill"; +import { ChartLegend } from "./shared/ChartLegend"; +import { CHART_COLORS } from "./shared/constants"; +import { getSeverityColorByRiskScore } from "./shared/utils"; + +interface ScatterDataPoint { + x: number; + y: number; + provider: string; + name: string; + size?: number; +} + +interface ScatterPlotProps { + data: ScatterDataPoint[]; + xLabel?: string; + yLabel?: string; + height?: number; + onSelectPoint?: (point: ScatterDataPoint | null) => void; + selectedPoint?: ScatterDataPoint | null; +} + +const PROVIDER_COLORS = { + AWS: "var(--color-orange)", + Azure: "var(--color-cyan)", + Google: "var(--color-red)", +}; + +const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + const severityColor = getSeverityColorByRiskScore(data.x); + + return ( +
+

{data.name}

+

+ {data.x} Risk Score +

+
+ +
+
+ ); + } + return null; +}; + +const CustomScatterDot = ({ + cx, + cy, + payload, + selectedPoint, + onSelectPoint, +}: any) => { + const isSelected = selectedPoint?.name === payload.name; + const size = isSelected ? 18 : 8; + const fill = isSelected + ? "var(--color-success)" + : PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] || + CHART_COLORS.defaultColor; + + return ( + onSelectPoint?.(payload)} + /> + ); +}; + +const CustomLegend = ({ payload }: any) => { + const items = payload.map((entry: any) => ({ + label: entry.value, + color: entry.color, + })); + + return ; +}; + +export function ScatterPlot({ + data, + xLabel = "Risk Score", + yLabel = "Failed Findings", + height = 400, + onSelectPoint, + selectedPoint, +}: ScatterPlotProps) { + const handlePointClick = (point: ScatterDataPoint) => { + if (onSelectPoint) { + if (selectedPoint?.name === point.name) { + onSelectPoint(null); + } else { + onSelectPoint(point); + } + } + }; + + const dataByProvider = data.reduce( + (acc, point) => { + const provider = point.provider; + if (!acc[provider]) { + acc[provider] = []; + } + acc[provider].push(point); + return acc; + }, + {} as Record, + ); + + return ( + + + + + + } /> + } /> + {Object.entries(dataByProvider).map(([provider, points]) => ( + ( + + )} + /> + ))} + + + ); +} diff --git a/ui/components/graphs/hooks/useSortableData.ts b/ui/components/graphs/hooks/useSortableData.ts new file mode 100644 index 0000000000..2a2841dfbe --- /dev/null +++ b/ui/components/graphs/hooks/useSortableData.ts @@ -0,0 +1,33 @@ +import { useState } from "react"; + +import { DEFAULT_SORT_OPTION, SORT_OPTIONS } from "../shared/constants"; + +type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS]; + +interface SortableItem { + name: string; + value: number; +} + +export function useSortableData(data: T[]) { + const [sortBy, setSortBy] = useState(DEFAULT_SORT_OPTION); + + const sortedData = [...data].sort((a, b) => { + switch (sortBy) { + case SORT_OPTIONS.highLow: + return b.value - a.value; + case SORT_OPTIONS.lowHigh: + return a.value - b.value; + case SORT_OPTIONS.alphabetical: + return a.name.localeCompare(b.name); + default: + return 0; + } + }); + + return { + sortBy, + setSortBy, + sortedData, + }; +} diff --git a/ui/components/graphs/index.ts b/ui/components/graphs/index.ts new file mode 100644 index 0000000000..f0f0e53697 --- /dev/null +++ b/ui/components/graphs/index.ts @@ -0,0 +1,9 @@ +export { BarChart } from "./BarChart"; +export { DonutChart } from "./DonutChart"; +export { HorizontalBarChart } from "./HorizontalBarChart"; +export { LineChart } from "./LineChart"; +export { RadarChart } from "./RadarChart"; +export { RadialChart } from "./RadialChart"; +export { SankeyChart } from "./SankeyChart"; +export { ScatterPlot } from "./ScatterPlot"; +export { ChartLegend, type ChartLegendItem } from "./shared/ChartLegend"; diff --git a/ui/components/graphs/shared/AlertPill.tsx b/ui/components/graphs/shared/AlertPill.tsx new file mode 100644 index 0000000000..f611b27766 --- /dev/null +++ b/ui/components/graphs/shared/AlertPill.tsx @@ -0,0 +1,34 @@ +import { AlertTriangle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +interface AlertPillProps { + value: number; + label?: string; + iconSize?: number; + textSize?: "xs" | "sm" | "base"; +} + +export function AlertPill({ + value, + label = "Fail Findings", + iconSize = 12, + textSize = "xs", +}: AlertPillProps) { + return ( +
+
+ + + {value} + +
+ {label} +
+ ); +} diff --git a/ui/components/graphs/shared/ChartLegend.tsx b/ui/components/graphs/shared/ChartLegend.tsx new file mode 100644 index 0000000000..11066aa54a --- /dev/null +++ b/ui/components/graphs/shared/ChartLegend.tsx @@ -0,0 +1,24 @@ +export interface ChartLegendItem { + label: string; + color: string; +} + +interface ChartLegendProps { + items: ChartLegendItem[]; +} + +export function ChartLegend({ items }: ChartLegendProps) { + return ( +
+ {items.map((item, index) => ( +
+
+ {item.label} +
+ ))} +
+ ); +} diff --git a/ui/components/graphs/shared/ChartTooltip.tsx b/ui/components/graphs/shared/ChartTooltip.tsx new file mode 100644 index 0000000000..6b4bde27bc --- /dev/null +++ b/ui/components/graphs/shared/ChartTooltip.tsx @@ -0,0 +1,123 @@ +import { Bell, VolumeX } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +import { TooltipData } from "../types"; + +interface ChartTooltipProps { + active?: boolean; + payload?: any[]; + label?: string; + showColorIndicator?: boolean; + colorIndicatorShape?: "circle" | "square"; +} + +export function ChartTooltip({ + active, + payload, + label, + showColorIndicator = true, + colorIndicatorShape = "square", +}: ChartTooltipProps) { + if (!active || !payload || payload.length === 0) { + return null; + } + + const data: TooltipData = payload[0].payload || payload[0]; + const color = payload[0].color || data.color; + + return ( +
+
+ {showColorIndicator && color && ( +
+ )} +

{label || data.name}

+
+ +

+ {typeof data.value === "number" + ? data.value.toLocaleString() + : data.value} + {data.percentage !== undefined && ` (${data.percentage}%)`} +

+ + {data.newFindings !== undefined && data.newFindings > 0 && ( +
+ + + {data.newFindings} New Findings + +
+ )} + + {data.new !== undefined && data.new > 0 && ( +
+ + {data.new} New +
+ )} + + {data.muted !== undefined && data.muted > 0 && ( +
+ + {data.muted} Muted +
+ )} + + {data.change !== undefined && ( +

+ + {data.change > 0 ? "+" : ""} + {data.change}% + {" "} + Since Last Scan +

+ )} +
+ ); +} + +/** + * Tooltip for charts with multiple data series (like LineChart) + */ +export function MultiSeriesChartTooltip({ + active, + payload, + label, +}: ChartTooltipProps) { + if (!active || !payload || payload.length === 0) { + return null; + } + + return ( +
+

{label}

+ + {payload.map((entry: any, index: number) => ( +
+
+ {entry.name}: + + {entry.value} + + {entry.payload[`${entry.dataKey}_change`] && ( + + ({entry.payload[`${entry.dataKey}_change`] > 0 ? "+" : ""} + {entry.payload[`${entry.dataKey}_change`]}%) + + )} +
+ ))} +
+ ); +} diff --git a/ui/components/graphs/shared/constants.ts b/ui/components/graphs/shared/constants.ts new file mode 100644 index 0000000000..9ab80b8fa8 --- /dev/null +++ b/ui/components/graphs/shared/constants.ts @@ -0,0 +1,46 @@ +export const SEVERITY_COLORS = { + Informational: "var(--color-info)", + Low: "var(--color-warning)", + Medium: "var(--color-warning-emphasis)", + High: "var(--color-danger)", + Critical: "var(--color-danger-emphasis)", +} as const; + +export const CHART_COLORS = { + tooltipBorder: "var(--color-slate-700)", + tooltipBackground: "var(--color-slate-800)", + textPrimary: "var(--color-white)", + textSecondary: "var(--color-slate-400)", + gridLine: "var(--color-slate-700)", + backgroundTrack: "rgba(51, 65, 85, 0.5)", // slate-700 with 50% opacity + alertPillBg: "var(--color-alert-pill-bg)", + alertPillText: "var(--color-alert-pill-text)", + defaultColor: "var(--color-slate-500)", // Default fallback color for charts +} as const; + +export const CHART_DIMENSIONS = { + defaultHeight: 400, + tooltipMinWidth: "200px", + borderRadius: "8px", +} as const; + +export const SORT_OPTIONS = { + highLow: "high-low", + lowHigh: "low-high", + alphabetical: "alphabetical", +} as const; + +export const DEFAULT_SORT_OPTION = SORT_OPTIONS.highLow; + +export const SEVERITY_ORDER = { + Critical: 0, + High: 1, + Medium: 2, + Low: 3, + Informational: 4, +} as const; + +export const LAYOUT_OPTIONS = { + horizontal: "horizontal", + vertical: "vertical", +} as const; diff --git a/ui/components/graphs/shared/utils.ts b/ui/components/graphs/shared/utils.ts new file mode 100644 index 0000000000..1b3484a0e9 --- /dev/null +++ b/ui/components/graphs/shared/utils.ts @@ -0,0 +1,13 @@ +import { SEVERITY_COLORS } from "./constants"; + +export function getSeverityColorByRiskScore(riskScore: number): string { + if (riskScore >= 7) return SEVERITY_COLORS.Critical; + if (riskScore >= 5) return SEVERITY_COLORS.High; + if (riskScore >= 3) return SEVERITY_COLORS.Medium; + if (riskScore >= 1) return SEVERITY_COLORS.Low; + return SEVERITY_COLORS.Informational; +} + +export function getSeverityColorByName(name: string): string | undefined { + return SEVERITY_COLORS[name as keyof typeof SEVERITY_COLORS]; +} diff --git a/ui/components/graphs/types.ts b/ui/components/graphs/types.ts new file mode 100644 index 0000000000..0961a32105 --- /dev/null +++ b/ui/components/graphs/types.ts @@ -0,0 +1,55 @@ +import { LAYOUT_OPTIONS, SORT_OPTIONS } from "./shared/constants"; + +export type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS]; + +export type LayoutOption = (typeof LAYOUT_OPTIONS)[keyof typeof LAYOUT_OPTIONS]; + +export interface BaseDataPoint { + name: string; + value: number; + percentage?: number; + color?: string; + change?: number; + newFindings?: number; +} + +export interface BarDataPoint extends BaseDataPoint {} + +export interface DonutDataPoint { + name: string; + value: number; + color: string; + percentage?: number; + new?: number; + muted?: number; + change?: number; +} + +export interface LineDataPoint { + date: string; + [key: string]: string | number; +} + +export interface RadarDataPoint { + category: string; + value: number; + change?: number; +} + +export interface LineConfig { + dataKey: string; + color: string; + label: string; +} + +export interface TooltipData { + name: string; + value: number | string; + color?: string; + percentage?: number; + newFindings?: number; + new?: number; + muted?: number; + change?: number; + [key: string]: any; +} diff --git a/ui/config/fonts.ts b/ui/config/fonts.ts index 9724605392..9d4782b33d 100644 --- a/ui/config/fonts.ts +++ b/ui/config/fonts.ts @@ -1,12 +1,10 @@ -import { - Fira_Code as FontMono, - Plus_Jakarta_Sans as FontSans, -} from "next/font/google"; +import { Fira_Code as FontMono, Work_Sans as FontSans } from "next/font/google"; export const fontSans = FontSans({ subsets: ["latin"], variable: "--font-sans", preload: false, + display: "swap", }); export const fontMono = FontMono({ diff --git a/ui/styles/globals.css b/ui/styles/globals.css index 9ba7fc7a21..3f7bce99ed 100644 --- a/ui/styles/globals.css +++ b/ui/styles/globals.css @@ -1,5 +1,240 @@ - @import "tailwindcss"; - @config "../tailwind.config.js"; +@import "tailwindcss"; +@config "../tailwind.config.js"; + +@theme { + /* Font Families are injected by Next.js from config/fonts.ts */ + /* --font-sans: Work Sans (from Next.js) */ + /* --font-mono: Fira Code (from Next.js) */ + + /* Text Sizes with Line Heights - Exact from Figma Design System */ + --text-xs: 0.625rem; /* 10px */ + --text-xs--line-height: 1.6; + --text-sm: 0.75rem; /* 12px */ + --text-sm--line-height: 1.667; + --text-base: 0.875rem; /* 14px */ + --text-base--line-height: 1.714; + --text-lg: 1rem; /* 16px */ + --text-lg--line-height: 1.75; + --text-xl: 1.125rem; /* 18px */ + --text-xl--line-height: 1.556; + --text-2xl: 1.25rem; /* 20px */ + --text-2xl--line-height: 1.6; + --text-3xl: 1.5rem; /* 24px */ + --text-3xl--line-height: 1.5; + --text-4xl: 1.875rem; /* 30px */ + --text-4xl--line-height: 1.333; + --text-5xl: 2.25rem; /* 36px */ + --text-5xl--line-height: 1.333; + --text-6xl: 3rem; /* 48px */ + --text-6xl--line-height: 1.25; + --text-7xl: 3.75rem; /* 60px */ + --text-7xl--line-height: 1.2; + --text-8xl: 4.5rem; /* 72px */ + --text-8xl--line-height: 1.333; + --text-9xl: 6rem; /* 96px */ + --text-9xl--line-height: 1.333; + + /* Font Weights - Complete Inter Scale */ + --font-weight-thin: 100; + --font-weight-extralight: 200; + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --font-weight-extrabold: 800; + --font-weight-black: 900; + + /* Info & Primary Colors */ + --color-info: #2e51b2; + --color-primary: #2e51b2; + + /* Warning Colors */ + --color-warning: #fdd34f; + --color-warning-emphasis: #ff7d19; + + /* Danger & Destructive Colors */ + --color-danger: #ff3077; + --color-danger-emphasis: #971348; + --color-destructive: #db2b49; + + /* Success Colors */ + --color-success: #86da26; + --color-success-emphasis: #20b853; + + /* Provider-specific Colors */ + --color-orange: #ff9800; + --color-cyan: #06b6d4; + --color-red: #ef4444; + + /* Accent Colors */ + --color-magenta: #b51c80; + --color-purple-dark: #5f1551; + + /* Chart Card Colors - From Figma */ + --color-card-bg: #080f23; + --color-card-border: #171d30; + --color-text-accent: #fface2; + --color-page-bg: #1a2034; + + /* Alert Pill Colors */ + --color-alert-pill-bg: #432232; + --color-alert-pill-text: #f54280; + + /* =========================== + Border Radius + =========================== */ + + /* Widget Border Radius - From Figma */ + --radius-widget: 12px; + + /* White & Black */ + --color-white: #ffffff; + --color-black: #000000; + + /* Slate Scale */ + --color-slate-50: #f8fafc; + --color-slate-100: #f1f5f9; + --color-slate-200: #e2e8f0; + --color-slate-300: #cbd5e1; + --color-slate-400: #94a3b8; + --color-slate-500: #64748b; + --color-slate-600: #475569; + --color-slate-700: #334155; + --color-slate-800: #1e293b; + --color-slate-900: #0f172a; + --color-slate-950: #020617; + + /* Gray Scale */ + --color-gray-50: #f9fafb; + --color-gray-100: #f3f4f6; + --color-gray-200: #e5e7eb; + --color-gray-300: #d1d5db; + --color-gray-400: #9ca3af; + --color-gray-500: #6b7280; + --color-gray-600: #4b5563; + --color-gray-700: #374151; + --color-gray-800: #1f2937; + --color-gray-900: #111827; + --color-gray-950: #030712; + + /* Zinc Scale */ + --color-zinc-50: #fafafa; + --color-zinc-100: #f4f4f5; + --color-zinc-200: #e4e4e7; + --color-zinc-300: #d4d4d8; + --color-zinc-400: #a1a1aa; + --color-zinc-500: #71717a; + --color-zinc-600: #52525b; + --color-zinc-700: #3f3f46; + --color-zinc-800: #27272a; + --color-zinc-900: #18181b; + --color-zinc-950: #09090b; + + /* Stone Scale */ + --color-stone-50: #fafaf9; + --color-stone-100: #f5f5f4; + --color-stone-200: #e7e5e4; + --color-stone-300: #d6d3d1; + --color-stone-400: #a8a29e; + --color-stone-500: #78716c; + --color-stone-600: #57534e; + --color-stone-700: #44403c; + --color-stone-800: #292524; + --color-stone-900: #1c1917; + --color-stone-950: #0c0a09; + + /* Red Scale */ + --color-red-50: #fef2f2; + --color-red-100: #fee2e2; + --color-red-200: #fecaca; + --color-red-300: #fca5a5; + --color-red-400: #f87171; + --color-red-500: #ef4444; + --color-red-600: #dc2626; + --color-red-700: #b91c1c; + --color-red-800: #991b1b; + --color-red-900: #7f1d1d; + --color-red-950: #450a0a; + + /* Rose Scale */ + --color-rose-50: #fff1f2; + --color-rose-100: #ffe4e6; + --color-rose-200: #fecdd3; + --color-rose-300: #fda4af; + --color-rose-400: #fb7185; + --color-rose-500: #f43f5e; + --color-rose-600: #e11d48; + --color-rose-700: #be123c; + --color-rose-800: #9f1239; + --color-rose-900: #881337; + --color-rose-950: #4c0519; + + /* Pink Scale */ + --color-pink-50: #fdf2f8; + --color-pink-100: #fce7f3; + --color-pink-200: #fbcfe8; + --color-pink-300: #f9a8d4; + --color-pink-400: #f472b6; + --color-pink-500: #ec4899; + --color-pink-600: #db2777; + --color-pink-700: #be185d; + --color-pink-800: #9d174d; + --color-pink-900: #831843; + --color-pink-950: #500724; + + /* Fuchsia Scale */ + --color-fuchsia-50: #fdf4ff; + --color-fuchsia-100: #fae8ff; + --color-fuchsia-200: #f5d0fe; + --color-fuchsia-300: #f0abfc; + --color-fuchsia-400: #e879f9; + --color-fuchsia-500: #d946ef; + --color-fuchsia-600: #c026d3; + --color-fuchsia-700: #a21caf; + --color-fuchsia-800: #86198f; + --color-fuchsia-900: #701a75; + --color-fuchsia-950: #4a044e; + + /* Purple Scale */ + --color-purple-50: #faf5ff; + --color-purple-100: #f3e8ff; + --color-purple-200: #e9d5ff; + --color-purple-300: #d8b4fe; + --color-purple-400: #c084fc; + --color-purple-500: #a855f7; + --color-purple-600: #9333ea; + --color-purple-700: #7e22ce; + --color-purple-800: #6b21a8; + --color-purple-900: #581c87; + --color-purple-950: #3b0764; + + /* Violet Scale */ + --color-violet-50: #f5f3ff; + --color-violet-100: #ede9fe; + --color-violet-200: #ddd6fe; + --color-violet-300: #c4b5fd; + --color-violet-400: #a78bfa; + --color-violet-500: #8b5cf6; + --color-violet-600: #7c3aed; + --color-violet-700: #6d28d9; + --color-violet-800: #5b21b6; + --color-violet-900: #4c1d95; + --color-violet-950: #2e1065; + + /* Indigo Scale */ + --color-indigo-50: #eef2ff; + --color-indigo-100: #e0e7ff; + --color-indigo-200: #c7d2fe; + --color-indigo-300: #a5b4fc; + --color-indigo-400: #818cf8; + --color-indigo-500: #6366f1; + --color-indigo-600: #4f46e5; + --color-indigo-700: #4338ca; + --color-indigo-800: #3730a3; + --color-indigo-900: #312e81; + --color-indigo-950: #1e1b4b; +} @layer base { :root { @@ -44,8 +279,8 @@ } .checkbox-update { - margin-right: 0.5rem; - background-color: var(--background); + margin-right: 0.5rem; + background-color: var(--background); } } @@ -64,4 +299,4 @@ [role="button"]:not(:disabled) { cursor: pointer; } -} \ No newline at end of file +} From ebd58141125b63f395d047f4953c375213f4afe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Mon, 13 Oct 2025 14:22:49 +0200 Subject: [PATCH 20/22] chore(aws): enhance metadata for `backup` service (#8826) Co-authored-by: HugoPBrito --- prowler/CHANGELOG.md | 3 +- .../backup_plans_exist.metadata.json | 37 +++++++++++-------- ...kup_recovery_point_encrypted.metadata.json | 33 +++++++++++------ .../backup_reportplans_exist.metadata.json | 34 +++++++++-------- .../backup_vaults_encrypted.metadata.json | 37 ++++++++++++------- .../backup_vaults_exist.metadata.json | 34 +++++++++-------- 6 files changed, 107 insertions(+), 71 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 2eaec5e7bd..8272ce15a2 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update AWS AppStream service metadata to new format [(#8789)](https://github.com/prowler-cloud/prowler/pull/8789) - Update AWS API Gateway service metadata to new format [(#8788)](https://github.com/prowler-cloud/prowler/pull/8788) - Update AWS Athena service metadata to new format [(#8790)](https://github.com/prowler-cloud/prowler/pull/8790) +- Update AWS Backup service metadata to new format [(#8826)](https://github.com/prowler-cloud/prowler/pull/8826) - Update AWS CloudFormation service metadata to new format [(#8828)](https://github.com/prowler-cloud/prowler/pull/8828) - Update AWS Lambda service metadata to new format [(#8825)](https://github.com/prowler-cloud/prowler/pull/8825) - Deprecate user authentication for M365 provider [(#8865)](https://github.com/prowler-cloud/prowler/pull/8865) @@ -441,4 +442,4 @@ All notable changes to the **Prowler SDK** are documented in this file. - Handle projects without ID in GCP [(#7496)](https://github.com/prowler-cloud/prowler/pull/7496) - Restore packages location in PyProject [(#7510)](https://github.com/prowler-cloud/prowler/pull/7510) ---- \ No newline at end of file +--- diff --git a/prowler/providers/aws/services/backup/backup_plans_exist/backup_plans_exist.metadata.json b/prowler/providers/aws/services/backup/backup_plans_exist/backup_plans_exist.metadata.json index 9a630c335c..320748069d 100644 --- a/prowler/providers/aws/services/backup/backup_plans_exist/backup_plans_exist.metadata.json +++ b/prowler/providers/aws/services/backup/backup_plans_exist/backup_plans_exist.metadata.json @@ -1,33 +1,40 @@ { "Provider": "aws", "CheckID": "backup_plans_exist", - "CheckTitle": "Ensure that there is at least one AWS Backup plan", + "CheckTitle": "At least one AWS Backup plan exists", "CheckType": [ - "Recover", - "Resilience", - "Backup" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" ], "ServiceName": "backup", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:backup-plan:backup-plan-id", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsBackupBackupPlan", - "Description": "This check ensures that there is at least one backup plan in place.", - "Risk": "Without a backup plan, an organization may be at risk of losing important data due to accidental deletion, system failures, or natural disasters. This can result in significant financial and reputational damage for the organization.", - "RelatedUrl": "https://docs.aws.amazon.com/aws-backup/latest/devguide/about-backup-plans.html", + "Description": "**AWS Backup** is assessed for the existence of at least one **backup plan** that schedules and retains recovery points for selected resources.\n\nThe evaluation determines whether any plan is configured; when none is found-even if backup vaults exist-the absence of a plan is noted.", + "Risk": "Without a backup plan, resources lack scheduled recovery points, undermining RPO/RTO.\n- Irrecoverable data after deletion or corruption (integrity)\n- Prolonged outages due to unavailable restores (availability)\n- Inconsistent backups that hinder investigations and controlled recovery", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://awscli.amazonaws.com/v2/documentation/api/2.0.33/reference/backup/create-backup-plan.html", + "https://docs.aws.amazon.com/aws-backup/latest/devguide/about-backup-plans.html", + "https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/backup_plan", + "https://medium.com/@christopheradamson253/backup-strategies-using-aws-backup-1b17b94a7957" + ], "Remediation": { "Code": { - "CLI": "aws backup create-backup-plan --backup-plan --backup-plan-rule ", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws backup create-backup-plan --backup-plan \"{\\\"BackupPlanName\\\":\\\"\\\",\\\"Rules\\\":[{\\\"RuleName\\\":\\\"\\\",\\\"TargetBackupVaultName\\\":\\\"Default\\\"}]}\"", + "NativeIaC": "```yaml\n# CloudFormation: create a minimal AWS Backup Plan to pass the check\nResources:\n :\n Type: AWS::Backup::BackupPlan\n Properties:\n BackupPlan:\n BackupPlanName: # Critical: ensures at least one Backup Plan exists\n Rules:\n - RuleName: # Critical: minimal required rule\n TargetBackupVault: Default # Critical: required vault for the rule\n```", + "Other": "1. In the AWS Console, go to AWS Backup\n2. Click Backup plans > Create backup plan\n3. Choose Build a new plan\n4. Enter Plan name: \n5. Under Backup rule, set Rule name: and Target backup vault: Default\n6. Click Create plan", + "Terraform": "```hcl\n# Terraform: minimal AWS Backup Plan to satisfy the check\nresource \"aws_backup_plan\" \"\" {\n name = \"\" # Critical: creates the Backup Plan so the check passes\n\n rule {\n rule_name = \"\" # Critical: minimal rule\n target_vault_name = \"Default\" # Critical: required vault\n }\n}\n```" }, "Recommendation": { - "Text": "Use AWS Backup to create backup plans for your critical data and services.", - "Url": "https://docs.aws.amazon.com/aws-backup/latest/devguide/about-backup-plans.html" + "Text": "Establish and enforce **backup plans** for critical workloads:\n- Define schedules, retention, and lifecycle to meet RPO/RTO\n- Use tagging to include all required resources by policy\n- Enable cross-Region/account copies and immutability where feasible\n- Apply least privilege to backup roles\n- Regularly test restores and review reports", + "Url": "https://hub.prowler.com/check/backup_plans_exist" } }, - "Categories": [], + "Categories": [ + "resilience" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/backup/backup_recovery_point_encrypted/backup_recovery_point_encrypted.metadata.json b/prowler/providers/aws/services/backup/backup_recovery_point_encrypted/backup_recovery_point_encrypted.metadata.json index e6eab7dddb..a68e92fe7b 100644 --- a/prowler/providers/aws/services/backup/backup_recovery_point_encrypted/backup_recovery_point_encrypted.metadata.json +++ b/prowler/providers/aws/services/backup/backup_recovery_point_encrypted/backup_recovery_point_encrypted.metadata.json @@ -1,28 +1,37 @@ { "Provider": "aws", "CheckID": "backup_recovery_point_encrypted", - "CheckTitle": "Check if AWS Backup recovery points are encrypted at rest.", + "CheckTitle": "AWS Backup recovery point is encrypted at rest", "CheckType": [ - "Software and Configuration Checks/AWS Security Best Practices" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Effects/Data Exposure" ], "ServiceName": "backup", "SubServiceName": "", - "ResourceIdTemplate": "arn:aws:backup:region:account-id:recovery-point/recovery-point-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsBackupRecoveryPoint", - "Description": "This control checks if an AWS Backup recovery point is encrypted at rest. The control fails if the recovery point isn't encrypted at rest.", - "Risk": "Without encryption at rest, AWS Backup recovery points are vulnerable to unauthorized access, which could compromise the confidentiality and integrity of the backed-up data.", - "RelatedUrl": "https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html", + "Description": "**AWS Backup recovery points** are evaluated for **encryption at rest** using the backup vault's KMS configuration. Items lacking vault-level encryption are highlighted, regardless of the source resource's encryption.", + "Risk": "Unencrypted recovery points can be read or copied if vault access is obtained, enabling offline analysis and data theft (**confidentiality**). Snapshots or restores may be altered (**integrity**), and unsafe restores can disrupt recovery operations (**availability**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/securityhub/latest/userguide/backup-controls.html#backup-1", + "https://readmedium.com/how-would-you-desgin-a-solution-for-autmated-backup-and-recovery-of-data-and-services-in-aws-311662f5a43e", + "https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html", + "https://medium.com/cloud-devops-security-ai-career-talk/how-would-you-desgin-a-solution-for-autmated-backup-and-recovery-of-data-and-services-in-aws-311662f5a43e", + "https://github.com/turbot/steampipe-mod-aws-compliance/issues/598" + ], "Remediation": { "Code": { - "CLI": "aws backup update-backup-vault --backup-vault-name --encryption-key-arn ", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/backup-controls.html#backup-1", - "Terraform": "" + "CLI": "", + "NativeIaC": "```yaml\n# CloudFormation: Encrypted AWS Backup Vault\nResources:\n :\n Type: AWS::Backup::BackupVault\n Properties:\n BackupVaultName: \n EncryptionKeyArn: # Critical: vault uses this KMS key so recovery points stored here are encrypted at rest\n```", + "Other": "1. In AWS Backup, go to Backup vaults > Create backup vault\n2. Enter a name and select a KMS key (aws/backup or a customer-managed key)\n3. Save the vault\n4. Go to Backup plans > select your plan > Edit and set the Target backup vault to the encrypted vault > Save\n5. To remediate existing unencrypted recovery points: Recovery points > select the item > Copy > choose the encrypted vault > Start copy, then delete the original unencrypted recovery point", + "Terraform": "```hcl\n# Encrypted AWS Backup Vault\nresource \"aws_backup_vault\" \"\" {\n name = \"\"\n kms_key_arn = \"\" # Critical: ensures recovery points in this vault are encrypted at rest\n}\n```" }, "Recommendation": { - "Text": "Ensure that AWS Backup recovery points are encrypted at rest by using an AWS KMS key when creating backups.", - "Url": "https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html" + "Text": "Encrypt all recovery points with **KMS**, preferring **customer-managed keys** for rotation and control. Apply **least privilege** to keys and vaults, require encrypted copies across accounts/Regions, and continuously monitor for unencrypted artifacts. Use `aws/backup` or `CMEK` consistently.", + "Url": "https://hub.prowler.com/check/backup_recovery_point_encrypted" } }, "Categories": [ diff --git a/prowler/providers/aws/services/backup/backup_reportplans_exist/backup_reportplans_exist.metadata.json b/prowler/providers/aws/services/backup/backup_reportplans_exist/backup_reportplans_exist.metadata.json index c600682e1d..c6dd3244f1 100644 --- a/prowler/providers/aws/services/backup/backup_reportplans_exist/backup_reportplans_exist.metadata.json +++ b/prowler/providers/aws/services/backup/backup_reportplans_exist/backup_reportplans_exist.metadata.json @@ -1,33 +1,37 @@ { "Provider": "aws", "CheckID": "backup_reportplans_exist", - "CheckTitle": "Ensure that there is at least one AWS Backup report plan", + "CheckTitle": "At least one AWS Backup report plan exists", "CheckType": [ - "Recover", - "Resilience", - "Backup" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" ], "ServiceName": "backup", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:backup-report-plan:backup-report-plan-id", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsBackupBackupPlan", - "Description": "This check ensures that there is at least one backup report plan in place.", - "Risk": "Without a backup report plan, an organization may lack visibility into the success or failure of backup operations.", - "RelatedUrl": "https://docs.aws.amazon.com/aws-backup/latest/devguide/create-report-plan-console.html", + "Description": "**AWS Backup** environments with existing backup plans are assessed for the presence of at least one **report plan** that generates `jobs` or `compliance` reports.", + "Risk": "Without a report plan, backup failures and missed restores may go unnoticed, harming **availability** and recovery objectives. Gaps in retention, scheduling, or encryption controls can persist unreported, weakening **integrity** and auditability across accounts and Regions, increasing the chance of SLA breaches.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/aws-backup/latest/devguide/create-report-plan-console.html" + ], "Remediation": { "Code": { - "CLI": "aws backup create-report-plan --report-plan-name --report-delivery-channel --report-setting ", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws backup create-report-plan --report-plan-name --report-delivery-channel s3BucketName=,formats=CSV --report-setting reportTemplate=BACKUP_JOB_REPORT", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::Backup::ReportPlan\n Properties:\n ReportPlanName: # Critical: creates the report plan required to pass the check\n ReportDeliveryChannel:\n S3BucketName: # Critical: destination bucket for reports\n Formats:\n - CSV # Critical: valid report file format\n ReportSetting:\n ReportTemplate: BACKUP_JOB_REPORT # Critical: minimal template to enable job reports\n```", + "Other": "1. Open the AWS Backup console and go to Reports\n2. Click Create report plan\n3. Select the Backup jobs (job report) template\n4. Enter a Report plan name and choose an S3 bucket\n5. Select CSV as the file format\n6. Click Create report plan", + "Terraform": "```hcl\nresource \"aws_backup_report_plan\" \"\" {\n name = \"\" # Critical: creates at least one report plan\n\n report_delivery_channel {\n s3_bucket_name = \"\" # Critical: destination bucket for reports\n formats = [\"CSV\"] # Critical: valid report file format\n }\n\n report_setting {\n report_template = \"BACKUP_JOB_REPORT\" # Critical: minimal job report template\n }\n}\n```" }, "Recommendation": { - "Text": "Use AWS Backup to create backup report plans that provide visibility into the success or failure of backup operations.", - "Url": "https://docs.aws.amazon.com/aws-backup/latest/devguide/create-report-plan-console.html" + "Text": "Establish and maintain **report plans** to continuously monitor backup activity and policy adherence.\n- Apply least privilege to report storage\n- Include relevant accounts and Regions for coverage\n- Review reports routinely and alert on anomalies\n- Enforce separation of duties between backup admins and auditors", + "Url": "https://hub.prowler.com/check/backup_reportplans_exist" } }, - "Categories": [], + "Categories": [ + "logging" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/backup/backup_vaults_encrypted/backup_vaults_encrypted.metadata.json b/prowler/providers/aws/services/backup/backup_vaults_encrypted/backup_vaults_encrypted.metadata.json index 7a77b8e543..4b1f412b8f 100644 --- a/prowler/providers/aws/services/backup/backup_vaults_encrypted/backup_vaults_encrypted.metadata.json +++ b/prowler/providers/aws/services/backup/backup_vaults_encrypted/backup_vaults_encrypted.metadata.json @@ -1,31 +1,42 @@ { "Provider": "aws", "CheckID": "backup_vaults_encrypted", - "CheckTitle": "Ensure that AWS Backup vaults are encrypted with AWS KMS", + "CheckTitle": "AWS Backup vault is encrypted at rest", "CheckType": [ - "Software and Configuration Checks/AWS Security Best Practices" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls (USA)", + "Software and Configuration Checks/Industry and Regulatory Standards/NIST CSF Controls (USA)", + "Software and Configuration Checks/Industry and Regulatory Standards/PCI-DSS", + "Software and Configuration Checks/Industry and Regulatory Standards/ISO 27001 Controls", + "Software and Configuration Checks/Industry and Regulatory Standards/HIPAA Controls (USA)" ], "ServiceName": "backup", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:backup-vault:backup-vault-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsBackupBackupVault", - "Description": "This check ensures that AWS Backup vaults are encrypted with AWS KMS.", - "Risk": "Without encryption using AWS KMS, an organization's backup data may be at risk of unauthorized access, which can lead to data breaches and other security incidents.", - "RelatedUrl": "https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html", + "Description": "**AWS Backup vaults** are evaluated for **encryption at rest** with **AWS KMS**. The finding highlights vaults without a configured KMS key protecting stored recovery points.", + "Risk": "Unencrypted vaults allow recovery points to be read if storage or credentials are compromised, undermining **confidentiality** and enabling data exfiltration. Missing KMS controls also weaken **integrity** guarantees and impede forensic **auditability** during investigations.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Athena/encrypted-with-cmk.html" + ], "Remediation": { "Code": { - "CLI": "aws backup update-backup-vault --backup-vault-name --encryption-key-arn ", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/Athena/encrypted-with-cmk.html", - "Terraform": "" + "CLI": "", + "NativeIaC": "```yaml\n# CloudFormation: Encrypted AWS Backup Vault\nResources:\n :\n Type: AWS::Backup::BackupVault\n Properties:\n BackupVaultName: \n EncryptionKeyArn: # CRITICAL: sets KMS key to encrypt the vault at rest\n```", + "Other": "1. In the AWS Console, go to AWS Backup > Backup vaults\n2. Click Create backup vault\n3. Set Name to \n4. Under Encryption key, select a customer managed KMS key ()\n5. Click Create backup vault\n6. Update any Backup plans to use the new vault (Plans > select plan > Edit > change Target vault name)\n7. Delete the old unencrypted vault after it is empty (select vault > Delete backup vault)", + "Terraform": "```hcl\n# Encrypted AWS Backup Vault\nresource \"aws_backup_vault\" \"\" {\n name = \"\"\n kms_key_arn = \"\" # CRITICAL: enables encryption at rest for the vault\n}\n```" }, "Recommendation": { - "Text": "Use AWS KMS to encrypt your AWS Backup vaults and backup data.", - "Url": "https://docs.aws.amazon.com/aws-backup/latest/devguide/encryption.html" + "Text": "Encrypt every backup vault with **customer-managed KMS keys** (`CMK`). Enforce **least privilege** in key policies, enable rotation, and separate key admins from backup operators. Add **defense-in-depth** with vault lock and logging. *For copies*, ensure destination vaults use appropriate KMS keys.", + "Url": "https://hub.prowler.com/check/backup_vaults_encrypted" } }, - "Categories": [], + "Categories": [ + "encryption" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/backup/backup_vaults_exist/backup_vaults_exist.metadata.json b/prowler/providers/aws/services/backup/backup_vaults_exist/backup_vaults_exist.metadata.json index 2828d212c8..96a28997d0 100644 --- a/prowler/providers/aws/services/backup/backup_vaults_exist/backup_vaults_exist.metadata.json +++ b/prowler/providers/aws/services/backup/backup_vaults_exist/backup_vaults_exist.metadata.json @@ -1,33 +1,37 @@ { "Provider": "aws", "CheckID": "backup_vaults_exist", - "CheckTitle": "Ensure AWS Backup vaults exist", + "CheckTitle": "At least one AWS Backup vault exists", "CheckType": [ - "Recover", - "Resilience", - "Backup" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" ], "ServiceName": "backup", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:backup-vault:backup-vault-id", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsBackupBackupVault", - "Description": "This check ensures that AWS Backup vaults exist to provide a secure and durable storage location for backup data.", - "Risk": "Without an AWS Backup vault, an organization's critical data may be at risk of being lost in the event of an accidental deletion, system failures, or natural disasters.", - "RelatedUrl": "https://docs.aws.amazon.com/aws-backup/latest/devguide/vaults.html", + "Description": "**AWS Backup** in the account/region includes at least one **backup vault** that stores and organizes recovery points for use by backup plans and copies.", + "Risk": "Without a vault, recovery points cannot be created or retained in AWS Backup, degrading **availability** and **integrity**. Data may be irrecoverable after deletion, ransomware, or misconfiguration, and RPO/RTO targets may be missed during incidents.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/aws-backup/latest/devguide/vaults.html" + ], "Remediation": { "Code": { - "CLI": "aws backup create-backup-vault --backup-vault-name ", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "CLI": "aws backup create-backup-vault --backup-vault-name ", + "NativeIaC": "```yaml\n# CloudFormation: create a Backup Vault\nResources:\n BackupVault:\n Type: AWS::Backup::BackupVault\n Properties:\n VaultName: # Critical: creates a backup vault to satisfy the check\n```", + "Other": "1. Sign in to the AWS Management Console and open the AWS Backup console\n2. In the left navigation pane, select Backup vaults\n3. Click Create backup vault\n4. Enter a name (e.g., )\n5. Click Create backup vault", + "Terraform": "```hcl\n# Create a Backup Vault\nresource \"aws_backup_vault\" \"\" {\n name = \"\" # Critical: ensures at least one backup vault exists\n}\n```" }, "Recommendation": { - "Text": "Use AWS Backup to create backup vaults for your critical data and services.", - "Url": "https://docs.aws.amazon.com/aws-backup/latest/devguide/vaults.html" + "Text": "Create and maintain a **backup vault** in each required region. Enforce **least privilege** access, encrypt with **KMS CMKs**, and enable **Vault Lock** to prevent tampering. Use lifecycle rules and cross-region/cross-account copies, and regularly test restores for **defense in depth**.", + "Url": "https://hub.prowler.com/check/backup_vaults_exist" } }, - "Categories": [], + "Categories": [ + "resilience" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" From 406aace585a8b3e3ebc2e55cee50988701a47769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Mon, 13 Oct 2025 16:52:29 +0200 Subject: [PATCH 21/22] chore(aws): enhance metadata for `autoscaling` service (#8824) Co-authored-by: HugoPBrito --- prowler/CHANGELOG.md | 1 + ...ets_ec2_launch_configuration.metadata.json | 25 +++++++----- ...p_capacity_rebalance_enabled.metadata.json | 33 +++++++++------ ...oup_elb_health_check_enabled.metadata.json | 32 +++++++++------ ...h_configuration_no_public_ip.metadata.json | 33 +++++++++------ ...onfiguration_requires_imdsv2.metadata.json | 40 ++++++++++++------- ...utoscaling_group_multiple_az.metadata.json | 35 ++++++++++------ ...roup_multiple_instance_types.metadata.json | 34 ++++++++++------ ...up_using_ec2_launch_template.metadata.json | 31 ++++++++------ 9 files changed, 164 insertions(+), 100 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 8272ce15a2..bb027a4940 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update AWS AppStream service metadata to new format [(#8789)](https://github.com/prowler-cloud/prowler/pull/8789) - Update AWS API Gateway service metadata to new format [(#8788)](https://github.com/prowler-cloud/prowler/pull/8788) - Update AWS Athena service metadata to new format [(#8790)](https://github.com/prowler-cloud/prowler/pull/8790) +- Update AWS Auto Scaling service metadata to new format [(#8824)](https://github.com/prowler-cloud/prowler/pull/8824) - Update AWS Backup service metadata to new format [(#8826)](https://github.com/prowler-cloud/prowler/pull/8826) - Update AWS CloudFormation service metadata to new format [(#8828)](https://github.com/prowler-cloud/prowler/pull/8828) - Update AWS Lambda service metadata to new format [(#8825)](https://github.com/prowler-cloud/prowler/pull/8825) diff --git a/prowler/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration.metadata.json b/prowler/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration.metadata.json index b98d761e49..80b50f4c56 100644 --- a/prowler/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration.metadata.json +++ b/prowler/providers/aws/services/autoscaling/autoscaling_find_secrets_ec2_launch_configuration/autoscaling_find_secrets_ec2_launch_configuration.metadata.json @@ -1,28 +1,33 @@ { "Provider": "aws", "CheckID": "autoscaling_find_secrets_ec2_launch_configuration", - "CheckTitle": "[DEPRECATED] Find secrets in EC2 Auto Scaling Launch Configuration", + "CheckTitle": "[DEPRECATED] EC2 Auto Scaling launch configuration user data contains no secrets", "CheckType": [ - "IAM" + "Software and Configuration Checks/AWS Security Best Practices", + "Sensitive Data Identifications/Passwords", + "Effects/Data Exposure" ], "ServiceName": "autoscaling", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:autoscaling:region:account-id:autoScalingGroupName/resource-name", + "ResourceIdTemplate": "", "Severity": "critical", "ResourceType": "AwsAutoScalingLaunchConfiguration", - "Description": "[DEPRECATED] Find secrets in EC2 Auto Scaling Launch Configuration", - "Risk": "The use of a hard-coded password increases the possibility of password guessing. If hard-coded passwords are used, it is possible that malicious users gain access through the account in question.", + "Description": "[DEPRECATED] EC2 Auto Scaling launch configurations are analyzed for **secrets** embedded in `User Data`, such as passwords, tokens, or API keys in bootstrapping scripts.", + "Risk": "Secrets in `User Data` erode **confidentiality** and **integrity**:\n- Instance users or processes can read or log them\n- Exposed keys enable unauthorized API calls, data exfiltration, and lateral movement\n- Credential reuse increases blast radius across accounts and services", "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "", - "Terraform": "" + "NativeIaC": "```yaml\n# CloudFormation Launch Configuration without secrets in UserData\nResources:\n :\n Type: AWS::AutoScaling::LaunchConfiguration\n Properties:\n ImageId: \n InstanceType: \n UserData: '' # Critical: empty user data ensures no secrets are present\n```", + "Other": "1. In the AWS Console, go to EC2 > Launch configurations and click Create launch configuration\n2. Reuse the same AMI and instance type; leave User data empty\n3. Go to EC2 > Auto Scaling groups, select the group using the failing launch configuration, click Edit\n4. Under Launch options, select the new launch configuration and Save\n5. After the ASG is updated, delete the old launch configuration", + "Terraform": "```hcl\n# Launch configuration with no secrets in user data\nresource \"aws_launch_configuration\" \"\" {\n image_id = \"\"\n instance_type = \"\"\n user_data = \"\" # Critical: empty user data ensures no secrets are present\n}\n```" }, "Recommendation": { - "Text": "Do not include sensitive information in user data within the launch configuration, try to use Secrets Manager instead.", - "Url": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html" + "Text": "Never place secrets in `User Data`.\n- Use a managed secret store with an instance role to fetch at runtime\n- Enforce **least privilege**, rotate secrets, and avoid writing secrets to logs\n- Prefer short-lived, scoped credentials and layer controls for **defense in depth**", + "Url": "https://hub.prowler.com/check/autoscaling_find_secrets_ec2_launch_configuration" } }, "Categories": [ diff --git a/prowler/providers/aws/services/autoscaling/autoscaling_group_capacity_rebalance_enabled/autoscaling_group_capacity_rebalance_enabled.metadata.json b/prowler/providers/aws/services/autoscaling/autoscaling_group_capacity_rebalance_enabled/autoscaling_group_capacity_rebalance_enabled.metadata.json index a326427ab0..f376f879d0 100644 --- a/prowler/providers/aws/services/autoscaling/autoscaling_group_capacity_rebalance_enabled/autoscaling_group_capacity_rebalance_enabled.metadata.json +++ b/prowler/providers/aws/services/autoscaling/autoscaling_group_capacity_rebalance_enabled/autoscaling_group_capacity_rebalance_enabled.metadata.json @@ -1,32 +1,39 @@ { "Provider": "aws", "CheckID": "autoscaling_group_capacity_rebalance_enabled", - "CheckTitle": "Check if Amazon EC2 Auto Scaling groups have capacity rebalance enabled.", + "CheckTitle": "Amazon EC2 Auto Scaling group has Capacity Rebalancing enabled", "CheckType": [ - "Resilience" + "Software and Configuration Checks/AWS Security Best Practices", + "Effects/Denial of Service" ], "ServiceName": "autoscaling", "SubServiceName": "", - "ResourceIdTemplate": "arn:aws:autoscaling:region:account-id:autoScalingGroup/autoScalingGroupName", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsAutoScalingAutoScalingGroup", - "Description": "This control checks whether an Amazon EC2 Auto Scaling group has capacity rebalance enabled.", - "Risk": "When you don't use Capacity Rebalancing, Amazon EC2 Auto Scaling doesn't replace Spot Instances until after the Amazon EC2 Spot service interrupts the instances and their health check fails. Before interrupting an instance, Amazon EC2 always gives both an EC2 instance rebalance recommendation and a Spot two-minute instance interruption notice.", - "RelatedUrl": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/ec2-auto-scaling-capacity-rebalancing.html", + "Description": "**EC2 Auto Scaling groups** use **Capacity Rebalancing** to act on EC2 `rebalance` recommendations by launching replacement Spot instances and terminating at-risk ones after they are healthy.\n\n*Assesses whether this proactive replacement behavior is enabled.*", + "Risk": "Without **Capacity Rebalancing**, Spot interruptions can drop targets and reduce capacity, causing timeouts, 5xx spikes, and backlog growth. The two-minute notice is often insufficient, reducing service **availability** and increasing the chance of cascading failures and slow recovery.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/awssupport/latest/user/fault-tolerance-checks.html#amazon-ec2-auto-scaling-group-capacity-rebalance-enabled", + "https://docs.aws.amazon.com/autoscaling/ec2/userguide/ec2-auto-scaling-capacity-rebalancing.html", + "https://trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/enable-capacity-rebalancing.html", + "https://docs.aws.amazon.com/autoscaling/ec2/userguide/enable-capacity-rebalancing-console-cli.html" + ], "Remediation": { "Code": { - "CLI": "aws autoscaling create-auto-scaling-group --capacity-rebalance", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/enable-capacity-rebalancing-console-cli.html", - "Terraform": "" + "CLI": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name --capacity-rebalance", + "NativeIaC": "```yaml\n# CloudFormation: Enable Capacity Rebalancing on an Auto Scaling group\nResources:\n :\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n MinSize: \"1\"\n MaxSize: \"1\"\n AvailabilityZones: [\"\"]\n LaunchTemplate:\n LaunchTemplateName: \n Version: \"$Default\"\n CapacityRebalance: true # CRITICAL: Enables proactive replacement of at-risk Spot instances\n```", + "Other": "1. In the AWS Console, go to EC2 > Auto Scaling Groups\n2. Select and open the Details tab\n3. Click Allocation strategies > Edit, check Capacity rebalancing\n4. Click Update/Save", + "Terraform": "```hcl\n# Terraform: Enable Capacity Rebalancing on an Auto Scaling group\nresource \"aws_autoscaling_group\" \"\" {\n name = \"\"\n min_size = 1\n max_size = 1\n desired_capacity = 1\n availability_zones = [\"\"]\n\n launch_template {\n id = \"\"\n version = \"$Latest\"\n }\n\n capacity_rebalance = true # CRITICAL: Turns on Capacity Rebalancing\n}\n```" }, "Recommendation": { - "Text": "When you enable Capacity Rebalancing for your Auto Scaling group, Amazon EC2 Auto Scaling attempts to proactively replace the Spot Instances in your group that have received a rebalance recommendation. This provides an opportunity to rebalance your workload to new Spot Instances that aren't at an elevated risk of interruption.", - "Url": "https://docs.aws.amazon.com/awssupport/latest/user/fault-tolerance-checks.html#amazon-ec2-auto-scaling-group-capacity-rebalance-enabled" + "Text": "Enable **Capacity Rebalancing** for ASGs that use Spot.\n\nApply resilience practices:\n- Prefer `price-capacity-optimized` allocation\n- Keep headroom below `MaxSize`\n- Use lifecycle hooks to drain/deregister\n- Design stateless, interruption-tolerant workloads (least privilege and defense-in-depth for dependencies)", + "Url": "https://hub.prowler.com/check/autoscaling_group_capacity_rebalance_enabled" } }, "Categories": [ - "redundancy" + "resilience" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/autoscaling/autoscaling_group_elb_health_check_enabled/autoscaling_group_elb_health_check_enabled.metadata.json b/prowler/providers/aws/services/autoscaling/autoscaling_group_elb_health_check_enabled/autoscaling_group_elb_health_check_enabled.metadata.json index 3538daa3fc..54a06668c5 100644 --- a/prowler/providers/aws/services/autoscaling/autoscaling_group_elb_health_check_enabled/autoscaling_group_elb_health_check_enabled.metadata.json +++ b/prowler/providers/aws/services/autoscaling/autoscaling_group_elb_health_check_enabled/autoscaling_group_elb_health_check_enabled.metadata.json @@ -1,31 +1,39 @@ { "Provider": "aws", "CheckID": "autoscaling_group_elb_health_check_enabled", - "CheckTitle": "Check if Auto Scaling groups associated with a load balancer use ELB health checks.", + "CheckTitle": "Auto Scaling group associated with a load balancer has ELB health checks enabled", "CheckType": [ - "Software and Configuration Checks/AWS Security Best Practices" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" ], "ServiceName": "autoscaling", "SubServiceName": "", - "ResourceIdTemplate": "arn:aws:autoscaling:region:account-id:autoScalingGroup/autoScalingGroupName", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsAutoScalingAutoScalingGroup", - "Description": "This control checks whether an Amazon EC2 Auto Scaling group that is associated with a load balancer uses Elastic Load Balancing (ELB) health checks. The control fails if the Auto Scaling group doesn't use ELB health checks.", - "Risk": "If ELB health checks are not enabled, the Auto Scaling group might not be able to accurately determine the health of instances, which could impact the availability and reliability of the applications running on these instances.", - "RelatedUrl": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-add-elb-healthcheck.html#as-add-elb-healthcheck-console", + "Description": "EC2 Auto Scaling groups attached to a load balancer are evaluated for **ELB-based health checks** that use the load balancer's target health instead of instance-only checks.", + "Risk": "Without **ELB health checks**, the group may keep instances that fail load balancer probes, causing:\n- Reduced **availability** from routing to bad targets\n- Higher error rates impacting transaction **integrity**\n- Inefficient scaling and increased **costs**", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-1", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/auto-scaling-group-health-check.html", + "https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-add-elb-healthcheck.html#as-add-elb-healthcheck-console" + ], "Remediation": { "Code": { "CLI": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name --health-check-type ELB", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-1", - "Terraform": "" + "NativeIaC": "```yaml\n# CloudFormation: Enable ELB health checks for the Auto Scaling group\nResources:\n :\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n HealthCheckType: ELB # Remediation: use ELB health checks so the ASG evaluates instance health via the load balancer\n```", + "Other": "1. In AWS Console, go to EC2 > Auto Scaling Groups\n2. Select the Auto Scaling group\n3. On the Details tab, click Edit under Health checks\n4. Under Additional health check types, select Elastic Load Balancing (ELB)\n5. Click Update/Save", + "Terraform": "```hcl\n# Enable ELB health checks on the Auto Scaling group\nresource \"aws_autoscaling_group\" \"\" {\n health_check_type = \"ELB\" # Remediation: ensures ASG uses load balancer health status\n}\n```" }, "Recommendation": { - "Text": "Configure your Auto Scaling groups to use ELB health checks to improve the monitoring and availability of your applications.", - "Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/auto-scaling-group-health-check.html" + "Text": "Enable **ELB health checks** for Auto Scaling groups behind load balancers to reflect real client reachability. Apply **high availability** and **defense in depth** by:\n- Using application-appropriate LB probes\n- Tuning grace and threshold settings to avoid flapping\n- Monitoring health metrics and alerts", + "Url": "https://hub.prowler.com/check/autoscaling_group_elb_health_check_enabled" } }, - "Categories": [], + "Categories": [ + "resilience" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/autoscaling/autoscaling_group_launch_configuration_no_public_ip/autoscaling_group_launch_configuration_no_public_ip.metadata.json b/prowler/providers/aws/services/autoscaling/autoscaling_group_launch_configuration_no_public_ip/autoscaling_group_launch_configuration_no_public_ip.metadata.json index 619f509544..4ece074fb3 100644 --- a/prowler/providers/aws/services/autoscaling/autoscaling_group_launch_configuration_no_public_ip/autoscaling_group_launch_configuration_no_public_ip.metadata.json +++ b/prowler/providers/aws/services/autoscaling/autoscaling_group_launch_configuration_no_public_ip/autoscaling_group_launch_configuration_no_public_ip.metadata.json @@ -1,28 +1,35 @@ { "Provider": "aws", "CheckID": "autoscaling_group_launch_configuration_no_public_ip", - "CheckTitle": "Check if Amazon EC2 instances launched using Auto Scaling group launch configurations have Public IP addresses.", + "CheckTitle": "Auto Scaling group associated launch configuration does not assign a public IP address", "CheckType": [ - "Software and Configuration Checks/AWS Security Best Practices" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark" ], "ServiceName": "autoscaling", "SubServiceName": "", - "ResourceIdTemplate": "arn:aws:autoscaling:region:account-id:launchConfiguration/launchConfigurationName", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AwsAutoScalingLaunchConfiguration", - "Description": "This control checks whether an Auto Scaling group's associated launch configuration assigns a public IP address to the group's instances. The control fails if the associated launch configuration assigns a public IP address.", - "Risk": "Assigning a public IP address to EC2 instances can expose them directly to the internet, increasing the risk of unauthorized access and potential security breaches.", - "RelatedUrl": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/create-auto-scaling-groups-launch-configuration.html", + "ResourceType": "AwsAutoScalingAutoScalingGroup", + "Description": "**Amazon EC2 Auto Scaling groups** are evaluated to determine whether their associated **launch configuration** assigns **public IP addresses** to instances (e.g., `AssociatePublicIpAddress=true`).", + "Risk": "**Publicly addressable instances** are reachable from the Internet, enabling reconnaissance, brute-force, and exploitation of exposed services.\n\nCompromise can lead to remote access, **data exfiltration**, and **lateral movement**, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-5", + "https://docs.aws.amazon.com/autoscaling/ec2/userguide/create-auto-scaling-groups-launch-configuration.html", + "https://docs.aws.amazon.com/autoscaling/ec2/userguide/change-launch-config.html" + ], "Remediation": { "Code": { - "CLI": "aws autoscaling create-launch-configuration --launch-configuration-name --associate-public-ip-address false", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-5", - "Terraform": "" + "CLI": "", + "NativeIaC": "```yaml\n# CloudFormation Launch Configuration without public IPs\nResources:\n :\n Type: AWS::AutoScaling::LaunchConfiguration\n Properties:\n ImageId: \n InstanceType: \n AssociatePublicIpAddress: false # Critical: disables assigning public IPs to instances\n```", + "Other": "1. In the AWS console, go to EC2 > Auto Scaling > Launch configurations and click Create launch configuration\n2. Use the same AMI and instance type as the current group; under Advanced details set IP address type to Do not assign a public IP address\n3. Create the launch configuration\n4. Go to EC2 > Auto Scaling Groups, select your group, click Edit next to Launch configuration, choose the new configuration, and click Update", + "Terraform": "```hcl\n# Launch Configuration without public IPs\nresource \"aws_launch_configuration\" \"\" {\n image_id = \"\"\n instance_type = \"\"\n associate_public_ip_address = false # Critical: disables assigning public IPs\n}\n```" }, "Recommendation": { - "Text": "Create a new launch configuration without a public IP address and update your Auto Scaling groups to use the new configuration.", - "Url": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/change-launch-config.html" + "Text": "Place instances in private subnets and disable public addressing (`AssociatePublicIpAddress=false`). Publish services via **load balancers** or **private endpoints**, enforce **least privilege** security groups, and use **SSM**, VPN, or a hardened bastion for admin access. Prefer **launch templates** to standardize network controls.", + "Url": "https://hub.prowler.com/check/autoscaling_group_launch_configuration_no_public_ip" } }, "Categories": [ diff --git a/prowler/providers/aws/services/autoscaling/autoscaling_group_launch_configuration_requires_imdsv2/autoscaling_group_launch_configuration_requires_imdsv2.metadata.json b/prowler/providers/aws/services/autoscaling/autoscaling_group_launch_configuration_requires_imdsv2/autoscaling_group_launch_configuration_requires_imdsv2.metadata.json index ea913b6505..e1e4dd33f8 100644 --- a/prowler/providers/aws/services/autoscaling/autoscaling_group_launch_configuration_requires_imdsv2/autoscaling_group_launch_configuration_requires_imdsv2.metadata.json +++ b/prowler/providers/aws/services/autoscaling/autoscaling_group_launch_configuration_requires_imdsv2/autoscaling_group_launch_configuration_requires_imdsv2.metadata.json @@ -1,31 +1,43 @@ { "Provider": "aws", "CheckID": "autoscaling_group_launch_configuration_requires_imdsv2", - "CheckTitle": "Check if Auto Scaling group launch configurations require Instance Metadata Service Version 2 (IMDSv2).", + "CheckTitle": "Auto Scaling group enforces IMDSv2 or disables the instance metadata service", "CheckType": [ - "Software and Configuration Checks/AWS Security Best Practices" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "TTPs/Credential Access", + "Effects/Data Exposure" ], "ServiceName": "autoscaling", "SubServiceName": "", - "ResourceIdTemplate": "arn:aws:autoscaling:region:account-id:launchConfiguration/launchConfigurationName", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AwsAutoScalingLaunchConfiguration", - "Description": "This control checks whether IMDSv2 is enabled on all instances launched by Amazon EC2 Auto Scaling groups. The control fails if the Instance Metadata Service (IMDS) version isn't included in the launch configuration or is configured as token optional, which allows either IMDSv1 or IMDSv2.", - "Risk": "If IMDSv2 is not enforced, instances may be vulnerable to certain types of attacks that target the metadata service, potentially exposing sensitive instance information.", - "RelatedUrl": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html", + "ResourceType": "AwsAutoScalingAutoScalingGroup", + "Description": "Amazon EC2 Auto Scaling launch configurations are evaluated for **Instance Metadata Service** settings. Instances should have the metadata endpoint `enabled` with `http_tokens=required` (enforcing **IMDSv2**), or have the metadata service `disabled`.\n\nAllowing `http_tokens=optional` or omitting the version leaves legacy access enabled.", + "Risk": "Without enforced **IMDSv2**, **SSRF** and local escape paths can access **IAM role credentials**, enabling unauthorized API calls.\n\nAttackers could:\n- Exfiltrate data with stolen tokens\n- Move laterally and modify resources, degrading confidentiality and integrity", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://trendmicro.com/cloudoneconformity/knowledge-base/aws/EC2/require-imds-v2.html", + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-3", + "https://aws.plainenglish.io/dont-let-metadata-leak-why-imdsv2-is-a-must-and-how-to-migrate-a88e1e285394" + ], "Remediation": { "Code": { - "CLI": "aws autoscaling create-launch-configuration --launch-configuration-name --metadata-options 'HttpTokens=required'", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-3", - "Terraform": "" + "CLI": "aws autoscaling create-launch-configuration --launch-configuration-name --image-id --instance-type --metadata-options 'HttpTokens=required,HttpEndpoint=enabled'", + "NativeIaC": "```yaml\n# CloudFormation: ASG launch configuration enforces IMDSv2\nResources:\n LaunchConfig:\n Type: AWS::AutoScaling::LaunchConfiguration\n Properties:\n ImageId: \n InstanceType: \n MetadataOptions:\n HttpTokens: required # critical: require IMDSv2 tokens (disables IMDSv1)\n HttpEndpoint: enabled # critical: keep IMDS enabled while enforcing v2\n\n AutoScalingGroup:\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n LaunchConfigurationName: !Ref LaunchConfig\n MinSize: 1\n MaxSize: 1\n VPCZoneIdentifier:\n - \n```", + "Other": "1. In the AWS Console, go to EC2 > Auto Scaling > Launch configurations\n2. Click Create launch configuration and choose the same AMI and instance type used by the group\n3. Expand Advanced details and set Metadata options to: Metadata accessible = Enabled, Metadata version = V2 only (token required)\n4. Create the launch configuration\n5. Go to EC2 > Auto Scaling > Auto Scaling groups, select the group, click Edit\n6. Under Launch configuration, select the new launch configuration and Save\n7. (Alternative) To disable IMDS entirely: when creating the launch configuration, set Metadata accessible = Disabled", + "Terraform": "```hcl\n# ASG launch configuration enforces IMDSv2\nresource \"aws_launch_configuration\" \"example\" {\n image_id = \"\"\n instance_type = \"\"\n\n metadata_options {\n http_tokens = \"required\" # critical: require IMDSv2 tokens (blocks IMDSv1)\n http_endpoint = \"enabled\" # critical: IMDS enabled while enforcing v2\n }\n}\n\nresource \"aws_autoscaling_group\" \"example\" {\n launch_configuration = aws_launch_configuration.example.name\n min_size = 1\n max_size = 1\n vpc_zone_identifier = [\"\"]\n}\n```" }, "Recommendation": { - "Text": "Create a new launch configuration that requires IMDSv2 and update your Auto Scaling groups to use the new configuration.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-new-instances.html" + "Text": "Require **IMDSv2** for Auto Scaling-launched instances by setting `http_tokens=required` when metadata is `enabled`. *If metadata is not needed*, disable it.\n\nApply **least privilege** to instance roles, set IMDSv2 as an account default, and use **defense in depth** (egress filtering, SSRF protections) to limit exposure.", + "Url": "https://hub.prowler.com/check/autoscaling_group_launch_configuration_requires_imdsv2" } }, - "Categories": [], + "Categories": [ + "secrets" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/autoscaling/autoscaling_group_multiple_az/autoscaling_group_multiple_az.metadata.json b/prowler/providers/aws/services/autoscaling/autoscaling_group_multiple_az/autoscaling_group_multiple_az.metadata.json index 73cf4a1236..53d02fa0a8 100644 --- a/prowler/providers/aws/services/autoscaling/autoscaling_group_multiple_az/autoscaling_group_multiple_az.metadata.json +++ b/prowler/providers/aws/services/autoscaling/autoscaling_group_multiple_az/autoscaling_group_multiple_az.metadata.json @@ -1,30 +1,39 @@ { "Provider": "aws", "CheckID": "autoscaling_group_multiple_az", - "CheckTitle": "EC2 Auto Scaling Group should use multiple Availability Zones", - "CheckType": [], + "CheckTitle": "Auto Scaling group uses multiple Availability Zones", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Effects/Denial of Service" + ], "ServiceName": "autoscaling", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:autoscaling:region:account-id:autoScalingGroupName/resource-name", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsAutoScalingAutoScalingGroup", - "Description": "EC2 Auto Scaling Group should use multiple Availability Zones", - "Risk": "In case of a failure in a single Availability Zone, the Auto Scaling Group will not be able to launch new instances to replace the failed ones.", - "RelatedUrl": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-add-availability-zone.html", + "Description": "**EC2 Auto Scaling groups** use **multiple Availability Zones** within a Region, with instances distributed across more than one zone rather than confined to a single zone.", + "Risk": "Relying on a single zone concentrates failure risk and harms **availability**. An AZ outage or capacity shortfall can block replacements and scaling, causing downtime, dropped traffic, and a wider blast radius. Recovery can lag because workloads can't shift to healthy zones.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-add-az-console.html", + "https://docs.aws.amazon.com/autoscaling/ec2/userguide/ec2-auto-scaling-availability-zone-balanced.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/multiple-availability-zones.html", + "https://docs.aws.amazon.com/autoscaling/ec2/userguide/disaster-recovery-resiliency.html" + ], "Remediation": { "Code": { - "CLI": "aws autoscaling update-auto-scaling-group", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/multiple-availability-zones.html", - "Terraform": "" + "CLI": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name --vpc-zone-identifier \",\"", + "NativeIaC": "```yaml\n# CloudFormation: ensure ASG spans multiple AZs\nResources:\n :\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n MinSize: '1'\n MaxSize: '1'\n LaunchTemplate:\n LaunchTemplateId: \n Version: '$Latest'\n VPCZoneIdentifier:\n - \n - # CRITICAL: Add a second subnet in a different AZ to ensure multiple AZs\n```", + "Other": "1. In the AWS Console, go to EC2 > Auto Scaling Groups\n2. Select the group and open the Details tab\n3. Click Network > Edit\n4. In Subnets, add one more subnet from a different Availability Zone\n5. Click Update to save", + "Terraform": "```hcl\n# Terraform: ensure ASG spans multiple AZs\nresource \"aws_autoscaling_group\" \"\" {\n min_size = 1\n max_size = 1\n\n launch_template {\n id = \"\"\n version = \"$Latest\"\n }\n\n vpc_zone_identifier = [\n \"\",\n \"\" # CRITICAL: two subnets in different AZs to pass the check\n ]\n}\n```" }, "Recommendation": { - "Text": "Configure multiple Availability Zones for EC2 Auto Scaling Group", - "Url": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/as-add-availability-zone.html" + "Text": "Distribute each group across at least two **Availability Zones** to design for failure. Use a load balancer to spread traffic and health-based replacement to sustain capacity. Apply **resilience** and **fault isolation** principles so service continues during zonal degradation.", + "Url": "https://hub.prowler.com/check/autoscaling_group_multiple_az" } }, "Categories": [ - "redundancy" + "resilience" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/autoscaling/autoscaling_group_multiple_instance_types/autoscaling_group_multiple_instance_types.metadata.json b/prowler/providers/aws/services/autoscaling/autoscaling_group_multiple_instance_types/autoscaling_group_multiple_instance_types.metadata.json index 7604051326..7bddb0da8e 100644 --- a/prowler/providers/aws/services/autoscaling/autoscaling_group_multiple_instance_types/autoscaling_group_multiple_instance_types.metadata.json +++ b/prowler/providers/aws/services/autoscaling/autoscaling_group_multiple_instance_types/autoscaling_group_multiple_instance_types.metadata.json @@ -1,31 +1,39 @@ { "Provider": "aws", "CheckID": "autoscaling_group_multiple_instance_types", - "CheckTitle": "EC2 Auto Scaling Group should use multiple instance types in multiple Availability Zones.", + "CheckTitle": "Auto Scaling group spans multiple Availability Zones and has multiple instance types per Availability Zone", "CheckType": [ - "Software and Configuration Checks/AWS Security Best Practices" + "Software and Configuration Checks/AWS Security Best Practices", + "Effects/Denial of Service" ], "ServiceName": "autoscaling", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:autoscaling:region:account-id:autoScalingGroupName/resource-name", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsAutoScalingAutoScalingGroup", - "Description": "This control checks whether an Amazon EC2 Auto Scaling group uses multiple instance types in all the Availability Zones, meaning that there should be multiple Availability Zones with multiple instances on each one. The control fails if the Auto Scaling group has only one instance type defined.", - "Risk": "Using only one instance type in an Auto Scaling group reduces the flexibility to launch new instances when there is insufficient capacity for that specific type, potentially affecting the availability of the application.", - "RelatedUrl": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/ec2-auto-scaling-mixed-instances-groups.html", + "Description": "**EC2 Auto Scaling groups** are evaluated for using **multiple instance types** in each **Availability Zone** and spanning more than one AZ.\n\nGroups are identified when every AZ defines at least two instance types; groups with any AZ using a single or no type, or confined to one AZ, are noted.", + "Risk": "Limited to one instance type per AZ or a single AZ, scaling can stall during **capacity shortages**, hindering **failover** and degrading **availability** (timeouts, backlog growth). Costs may spike if only expensive capacity is available. Reduced diversity increases the likelihood of prolonged outages during zonal or market disruptions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/asg-multiple-instance-type-az.html", + "https://docs.aws.amazon.com/autoscaling/ec2/userguide/ec2-auto-scaling-mixed-instances-groups.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-6" + ], "Remediation": { "Code": { - "CLI": "aws autoscaling create-auto-scaling-group --mixed-instances-policy ...", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-6", - "Terraform": "" + "CLI": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name --mixed-instances-policy '{\"LaunchTemplate\":{\"LaunchTemplateSpecification\":{\"LaunchTemplateName\":\"\",\"Version\":\"$Latest\"},\"Overrides\":[{\"InstanceType\":\"\"},{\"InstanceType\":\"\"}]}}' --vpc-zone-identifier \",\"", + "NativeIaC": "```yaml\n# CloudFormation: Ensure ASG uses multiple instance types across multiple AZs\nResources:\n :\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n MinSize: \"1\"\n MaxSize: \"1\"\n VPCZoneIdentifier:\n - # CRITICAL: Use subnets in different AZs to span multiple AZs\n - # CRITICAL: Ensures at least two Availability Zones\n MixedInstancesPolicy:\n LaunchTemplate:\n LaunchTemplateSpecification:\n LaunchTemplateName: \n Version: $Latest\n Overrides:\n - InstanceType: # CRITICAL: Multiple instance types per AZ\n - InstanceType: # CRITICAL: Multiple instance types per AZ\n```", + "Other": "1. In the AWS Console, go to EC2 > Auto Scaling Groups and select \n2. Click Edit\n3. Under Network, add at least two subnets in different Availability Zones\n4. Under Launch options, choose Mixed instance types\n5. Select your Launch template and set Version to $Latest\n6. Add at least two Instance types in Overrides\n7. Click Update to save", + "Terraform": "```hcl\n# Terraform: Ensure ASG uses multiple instance types across multiple AZs\nresource \"aws_autoscaling_group\" \"\" {\n name = \"\"\n min_size = 1\n max_size = 1\n vpc_zone_identifier = [\"\", \"\"] # CRITICAL: Subnets in different AZs\n\n mixed_instances_policy {\n launch_template {\n launch_template_specification {\n launch_template_name = \"\"\n version = \"$Latest\"\n }\n override { instance_type = \"\" } # CRITICAL: Multiple instance types per AZ\n override { instance_type = \"\" } # CRITICAL: Multiple instance types per AZ\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Configure your EC2 Auto Scaling group to use multiple instance types across multiple Availability Zones.", - "Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/asg-multiple-instance-type-az.html" + "Text": "Adopt a **mixed instances** strategy for resilience:\n- Use diverse instance families and sizes per AZ\n- Distribute capacity across multiple AZs\n- Favor allocation approaches that tolerate spot/on-demand scarcity\nApply **redundancy** and **fault tolerance** principles and validate scaling policies to avoid single points of capacity failure.", + "Url": "https://hub.prowler.com/check/autoscaling_group_multiple_instance_types" } }, - "Categories": [], + "Categories": [ + "resilience" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/autoscaling/autoscaling_group_using_ec2_launch_template/autoscaling_group_using_ec2_launch_template.metadata.json b/prowler/providers/aws/services/autoscaling/autoscaling_group_using_ec2_launch_template/autoscaling_group_using_ec2_launch_template.metadata.json index 2479164979..b1f204cb1c 100644 --- a/prowler/providers/aws/services/autoscaling/autoscaling_group_using_ec2_launch_template/autoscaling_group_using_ec2_launch_template.metadata.json +++ b/prowler/providers/aws/services/autoscaling/autoscaling_group_using_ec2_launch_template/autoscaling_group_using_ec2_launch_template.metadata.json @@ -1,31 +1,38 @@ { "Provider": "aws", "CheckID": "autoscaling_group_using_ec2_launch_template", - "CheckTitle": "Check if Amazon EC2 Auto Scaling groups use EC2 launch templates.", + "CheckTitle": "Amazon EC2 Auto Scaling group uses an EC2 launch template", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices" ], "ServiceName": "autoscaling", "SubServiceName": "", - "ResourceIdTemplate": "arn:aws:autoscaling:region:account-id:autoScalingGroup/autoScalingGroupName", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsAutoScalingAutoScalingGroup", - "Description": "This control checks whether an Amazon EC2 Auto Scaling group is created using an EC2 launch template. The control fails if the Auto Scaling group is not created with a launch template or if a launch template is not specified in a mixed instances policy.", - "Risk": "Using launch configurations instead of launch templates may limit your access to the latest EC2 features and improvements, reducing the flexibility and efficiency of your Auto Scaling groups.", - "RelatedUrl": "https://docs.aws.amazon.com/autoscaling/ec2/userguide/create-asg-launch-template.html", + "Description": "**EC2 Auto Scaling groups** use an **EC2 launch template** directly or via a `mixed instances policy` to define instance configuration and versioned settings.", + "Risk": "Without a launch template, there is no **versioned, auditable baseline** for instance settings, increasing configuration drift. Inconsistent metadata and network options can enable unauthorized access or unstable deployments, degrading confidentiality and availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/asg-launch-template.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-9", + "https://docs.aws.amazon.com/autoscaling/ec2/userguide/create-asg-launch-template.html" + ], "Remediation": { "Code": { - "CLI": "aws autoscaling create-auto-scaling-group --launch-template LaunchTemplateId=", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/autoscaling-controls.html#autoscaling-9", - "Terraform": "" + "CLI": "aws autoscaling update-auto-scaling-group --auto-scaling-group-name --launch-template LaunchTemplateId=", + "NativeIaC": "```yaml\n# CloudFormation: attach a Launch Template to the ASG\nResources:\n ASG:\n Type: AWS::AutoScaling::AutoScalingGroup\n Properties:\n MinSize: '0'\n MaxSize: '1'\n VPCZoneIdentifier:\n - \n LaunchTemplate: # critical: ensures the ASG uses an EC2 launch template (fixes the check)\n LaunchTemplateId: # references the EC2 Launch Template\n Version: $Default\n```", + "Other": "1. In the AWS console, go to EC2 > Auto Scaling Groups\n2. Select and click Edit\n3. Under \"Launch template or configuration\", choose Launch template and select your template and version (Default or Latest)\n4. Click Update to save", + "Terraform": "```hcl\n# Terraform: attach a Launch Template to the ASG\nresource \"aws_autoscaling_group\" \"example\" {\n min_size = 0\n max_size = 1\n vpc_zone_identifier = [\"\"]\n\n launch_template {\n id = \"\" # critical: ensures the ASG uses an EC2 launch template (fixes the check)\n version = \"$Default\"\n }\n}\n```" }, "Recommendation": { - "Text": "Use EC2 launch templates when creating Auto Scaling groups to ensure access to the latest features and improvements.", - "Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/AutoScaling/asg-launch-template.html" + "Text": "Adopt **launch templates** for all Auto Scaling groups and include them in any `mixed instances policy`. Use versioning with approvals, enforce hardened defaults (least privilege roles, secure metadata like `IMDSv2`, encrypted storage), and apply change control to ensure consistency and defense in depth.", + "Url": "https://hub.prowler.com/check/autoscaling_group_using_ec2_launch_template" } }, - "Categories": [], + "Categories": [ + "resilience" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" From 9761651f8d806553227fb7beca2e7ff67b715956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Tue, 14 Oct 2025 09:26:33 +0200 Subject: [PATCH 22/22] chore(aws): enhance metadata for `cloudfront` service (#8829) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + ...tions_custom_ssl_certificate.metadata.json | 33 +++++++++------ ...ibutions_default_root_object.metadata.json | 31 ++++++++------ ...eld_level_encryption_enabled.metadata.json | 31 ++++++++------ ...ons_geo_restrictions_enabled.metadata.json | 35 ++++++++++------ ..._distributions_https_enabled.metadata.json | 33 +++++++++------ ...tributions_https_sni_enabled.metadata.json | 32 +++++++++------ ...istributions_logging_enabled.metadata.json | 35 ++++++++++------ ...e_origin_failover_configured.metadata.json | 37 +++++++++-------- ...ons_origin_traffic_encrypted.metadata.json | 41 ++++++++++++------- ...ons_s3_origin_access_control.metadata.json | 38 ++++++++++------- ...3_origin_non_existent_bucket.metadata.json | 29 ++++++++----- ...ing_deprecated_ssl_protocols.metadata.json | 32 +++++++++------ ...ront_distributions_using_waf.metadata.json | 33 +++++++++------ 14 files changed, 276 insertions(+), 165 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index bb027a4940..289795abd5 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update AWS Backup service metadata to new format [(#8826)](https://github.com/prowler-cloud/prowler/pull/8826) - Update AWS CloudFormation service metadata to new format [(#8828)](https://github.com/prowler-cloud/prowler/pull/8828) - Update AWS Lambda service metadata to new format [(#8825)](https://github.com/prowler-cloud/prowler/pull/8825) +- Update AWS CloudFront service metadata to new format [(#8829)](https://github.com/prowler-cloud/prowler/pull/8829) - Deprecate user authentication for M365 provider [(#8865)](https://github.com/prowler-cloud/prowler/pull/8865) ### Fixed diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_custom_ssl_certificate/cloudfront_distributions_custom_ssl_certificate.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_custom_ssl_certificate/cloudfront_distributions_custom_ssl_certificate.metadata.json index 9ef0cfca0a..90abc8341d 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_custom_ssl_certificate/cloudfront_distributions_custom_ssl_certificate.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_custom_ssl_certificate/cloudfront_distributions_custom_ssl_certificate.metadata.json @@ -1,26 +1,35 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_custom_ssl_certificate", - "CheckTitle": "CloudFront distributions should use custom SSL/TLS certificates.", - "CheckType": [], + "CheckTitle": "CloudFront distribution uses a custom SSL/TLS certificate", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsCloudFrontDistribution", - "Description": "Ensure that your Amazon CloudFront distributions are configured to use a custom SSL/TLS certificate instead of the default one.", - "Risk": "Using the default SSL/TLS certificate provided by CloudFront can limit your ability to use custom domain names and may not align with your organization's security policies or branding requirements.", - "RelatedUrl": "https://aws.amazon.com/what-is/ssl-certificate/", + "Description": "CloudFront distributions are configured with a **custom SSL/TLS certificate** rather than the default `*.cloudfront.net` certificate for viewer connections.", + "Risk": "Using the default certificate prevents HTTPS on your own hostnames, breaking hostname validation. Clients may face errors or avoid TLS, impacting **authentication** and **availability**. Control over TLS posture and domain-bound security headers is reduced, weakening **confidentiality** and user trust.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-distro-custom-tls.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-7", + "https://support.icompaas.com/support/solutions/articles/62000233491-ensure-cloudfront-distributions-use-custom-ssl-tls-certificates", + "https://reintech.io/blog/configure-https-ssl-certificates-cloudfront-distributions" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/aws/networking-policies/ensure-aws-cloudfront-distribution-uses-custom-ssl-certificate/", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-7", - "Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-distro-custom-tls.html" + "CLI": "aws cloudfront get-distribution-config --id --output json > current-config.json && echo 'Manually edit current-config.json to add Aliases and ViewerCertificate fields, then run:' && echo 'aws cloudfront update-distribution --id --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id --query \"ETag\" --output text)'", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: origin1\n DomainName: \n S3OriginConfig: {}\n DefaultCacheBehavior:\n TargetOriginId: origin1\n ViewerProtocolPolicy: redirect-to-https\n ForwardedValues:\n QueryString: false\n Aliases:\n - # CRITICAL: add an alternate domain name (CNAME) covered by the certificate\n ViewerCertificate:\n AcmCertificateArn: # CRITICAL: attach custom ACM cert (must be in us-east-1)\n SslSupportMethod: sni-only # CRITICAL: required when using ACM cert\n```", + "Other": "1. Open the CloudFront console and select your distribution\n2. Go to the Settings/General tab and click Edit\n3. In Alternate domain name (CNAME), add \n4. In SSL certificate, choose Custom SSL certificate and select your ACM certificate (issued in us-east-1 and covering )\n5. Click Save/Yes, Edit and wait for the distribution to deploy", + "Terraform": "```hcl\nresource \"aws_cloudfront_distribution\" \"\" {\n enabled = true\n\n origin {\n domain_name = \"\"\n origin_id = \"origin1\"\n s3_origin_config {}\n }\n\n default_cache_behavior {\n target_origin_id = \"origin1\"\n viewer_protocol_policy = \"redirect-to-https\"\n forwarded_values { query_string = false }\n }\n\n aliases = [\"\"] # CRITICAL: add CNAME covered by the cert\n\n viewer_certificate {\n acm_certificate_arn = \"\" # CRITICAL: custom ACM cert (in us-east-1)\n ssl_support_method = \"sni-only\" # CRITICAL: required with ACM cert\n }\n}\n```" }, "Recommendation": { - "Text": "Configure your CloudFront distributions to use a custom SSL/TLS certificate to enable secure access via your own domain names and meet specific security and branding needs. This allows for more control over encryption and authentication settings.", - "Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html#CreatingCNAME" + "Text": "- Use a **custom SSL/TLS certificate** covering your domains and configure aliases.\n- Enforce modern TLS policy, **SNI**, and **HSTS**; disable legacy protocols.\n- Apply **least privilege** to certificate lifecycle and rotate/monitor keys.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_custom_ssl_certificate" } }, "Categories": [ diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_default_root_object/cloudfront_distributions_default_root_object.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_default_root_object/cloudfront_distributions_default_root_object.metadata.json index d8247d0ff6..399736aa28 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_default_root_object/cloudfront_distributions_default_root_object.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_default_root_object/cloudfront_distributions_default_root_object.metadata.json @@ -1,26 +1,33 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_default_root_object", - "CheckTitle": "Check if CloudFront distributions have a default root object.", - "CheckType": [], + "CheckTitle": "CloudFront distribution has a default root object configured", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsCloudFrontDistribution", - "Description": "Check if CloudFront distributions have a default root object.", - "Risk": "Without a default root object, requests to the root URL may result in an error or expose unintended content, leading to potential security risks and a poor user experience.", - "RelatedUrl": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DefaultRootObject.html#DefaultRootObjectHow", + "Description": "CloudFront distributions are evaluated for a configured **default root object** that maps `/` requests to a specific file such as `index.html`, rather than forwarding root requests directly to the origin.", + "Risk": "Without a **default root object**, root requests can reveal **origin listings** or unintended files, exposing data (**confidentiality**) and aiding reconnaissance. They may also return errors, lowering uptime (**availability**), or route unpredictably, risking wrong content delivery (**integrity**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-1", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-default-object.html", + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DefaultRootObject.html" + ], "Remediation": { "Code": { - "CLI": "aws cloudfront update-distribution --id --default-root-object ", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-1", - "Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-default-object.html" + "CLI": "aws cloudfront get-distribution-config --id --output json > current-config.json && echo 'Manually edit current-config.json to add DefaultRootObject: \"index.html\", then run:' && echo 'aws cloudfront update-distribution --id --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id --query \"ETag\" --output text)'", + "NativeIaC": "```yaml\n# CloudFormation: Set a default root object on a CloudFront distribution\nResources:\n CloudFrontDistribution:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n DefaultRootObject: index.html # CRITICAL: ensures a default root object is configured\n Origins:\n - Id: \n DomainName: \n S3OriginConfig: {}\n DefaultCacheBehavior:\n TargetOriginId: \n ViewerProtocolPolicy: allow-all\n ForwardedValues:\n QueryString: false\n```", + "Other": "1. Open the AWS Console and go to CloudFront\n2. Select the target distribution and choose Settings > General > Edit\n3. In Default root object, enter index.html (do not start with a /)\n4. Save changes and wait for deployment to complete", + "Terraform": "```hcl\n# Terraform: Set a default root object on a CloudFront distribution\nresource \"aws_cloudfront_distribution\" \"\" {\n enabled = true\n default_root_object = \"index.html\" # CRITICAL: ensures a default root object is configured\n\n origin {\n domain_name = \"\"\n origin_id = \"\"\n\n s3_origin_config {}\n }\n\n default_cache_behavior {\n target_origin_id = \"\"\n viewer_protocol_policy = \"allow-all\"\n forwarded_values {\n query_string = false\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Configure a default root object for your CloudFront distribution to ensure that a specific file (such as index.html) is returned when users access the root URL. This improves user experience and ensures that sensitive content isn't accidentally exposed.", - "Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DefaultRootObject.html#DefaultRootObjectHowToDefine" + "Text": "Set a **default root object** that returns a safe landing page (e.g., `index.html`). Apply **defense in depth**: restrict direct origin access, define explicit error pages, and standardize redirects. Test root and subdirectory requests for predictable responses. Align origin permissions with **least privilege**.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_default_root_object" } }, "Categories": [], diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_field_level_encryption_enabled/cloudfront_distributions_field_level_encryption_enabled.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_field_level_encryption_enabled/cloudfront_distributions_field_level_encryption_enabled.metadata.json index 3b12a0ec81..68be24f4b4 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_field_level_encryption_enabled/cloudfront_distributions_field_level_encryption_enabled.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_field_level_encryption_enabled/cloudfront_distributions_field_level_encryption_enabled.metadata.json @@ -1,26 +1,33 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_field_level_encryption_enabled", - "CheckTitle": "Check if CloudFront distributions have Field Level Encryption enabled.", - "CheckType": [], + "CheckTitle": "CloudFront distribution has Field Level Encryption enabled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Effects/Data Exposure" + ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsCloudFrontDistribution", - "Description": "Check if CloudFront distributions have Field Level Encryption enabled.", - "Risk": "Allows you protect specific data throughout system processing so that only certain applications can see it.", - "RelatedUrl": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/field-level-encryption.html", + "Description": "CloudFront distributions have the default cache behavior associated with **Field-Level Encryption** via `field_level_encryption_id`, targeting specified request fields for edge encryption.", + "Risk": "Absent **field-level encryption**, sensitive inputs (PII, payment data, credentials) may surface in origin paths, logs, or middleware in plaintext. This undermines **confidentiality**, enables data exfiltration and insider misuse, and can lead to session or account compromise if tokens are captured.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/field-level-encryption.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/field-level-encryption-enabled.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/field-level-encryption-enabled.html", - "Terraform": "" + "CLI": "aws cloudfront create-field-level-encryption-config --field-level-encryption-config file://fle-config.json && aws cloudfront get-distribution-config --id --output json > current-config.json && echo 'Manually edit current-config.json to add FieldLevelEncryptionId to DefaultCacheBehavior, then run:' && echo 'aws cloudfront update-distribution --id --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id --query \"ETag\" --output text)'", + "NativeIaC": "```yaml\n# CloudFormation: Enable Field Level Encryption on Default Cache Behavior\nResources:\n FLEConfig:\n Type: AWS::CloudFront::FieldLevelEncryptionConfig\n Properties:\n FieldLevelEncryptionConfig:\n CallerReference: !Ref AWS::StackName\n ContentTypeProfileConfig:\n ForwardWhenContentTypeIsUnknown: true\n ContentTypeProfiles:\n Quantity: 0\n\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - DomainName: \".s3.amazonaws.com\"\n Id: \"\"\n S3OriginConfig: {}\n DefaultCacheBehavior:\n TargetOriginId: \"\"\n ViewerProtocolPolicy: redirect-to-https\n CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6\n FieldLevelEncryptionId: !Ref FLEConfig # Critical: enables FLE on the default cache behavior\n```", + "Other": "1. In the AWS Console, go to CloudFront\n2. If you don't have a Field-level encryption configuration:\n - In the left menu, click Public keys > Add public key (paste your RSA public key)\n - Click Field-level encryption > Create profile (choose the public key and add fields to encrypt)\n - Click Field-level encryption > Create configuration (set the profile as Default profile)\n3. Attach it to your distribution:\n - Go to Distributions > select \n - Choose Behaviors > select Default (*) > Edit\n - Set Field-level encryption configuration to your created configuration\n - Click Save changes and wait for deployment", + "Terraform": "```hcl\n# Enable Field Level Encryption on Default Cache Behavior\nresource \"aws_cloudfront_field_level_encryption_config\" \"fle\" {\n content_type_profile_config {\n forward_when_content_type_is_unknown = true\n }\n}\n\nresource \"aws_cloudfront_distribution\" \"\" {\n enabled = true\n\n origin {\n domain_name = \".s3.amazonaws.com\"\n origin_id = \"\"\n }\n\n default_cache_behavior {\n target_origin_id = \"\"\n viewer_protocol_policy = \"redirect-to-https\"\n cache_policy_id = \"658327ea-f89d-4fab-a63d-7e88639e58f6\"\n field_level_encryption_id = aws_cloudfront_field_level_encryption_config.fle.id # Critical: enables FLE\n }\n}\n```" }, "Recommendation": { - "Text": "Check if applicable to any sensitive data. This encryption ensures that only applications that need the data—and have the credentials to decrypt it - are able to do so.", - "Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/field-level-encryption.html" + "Text": "Enable **Field-Level Encryption** for sensitive request fields and bind it to relevant cache behaviors. Apply **least privilege** to decryption keys, rotate and monitor keys, and separate duties. As **defense in depth**, minimize data collection, avoid logging secrets, require HTTPS end-to-end, and validate inputs.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_field_level_encryption_enabled" } }, "Categories": [ diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_geo_restrictions_enabled/cloudfront_distributions_geo_restrictions_enabled.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_geo_restrictions_enabled/cloudfront_distributions_geo_restrictions_enabled.metadata.json index 1e1ac9a6f4..bdc69f335b 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_geo_restrictions_enabled/cloudfront_distributions_geo_restrictions_enabled.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_geo_restrictions_enabled/cloudfront_distributions_geo_restrictions_enabled.metadata.json @@ -1,29 +1,38 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_geo_restrictions_enabled", - "CheckTitle": "Check if Geo restrictions are enabled in CloudFront distributions.", - "CheckType": [], + "CheckTitle": "CloudFront distribution has Geo restrictions enabled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsCloudFrontDistribution", - "Description": "Check if Geo restrictions are enabled in CloudFront distributions.", - "Risk": "Consider countries where service should not be accessed, by legal or compliance requirements. Additionally if not restricted the attack vector is increased.", - "RelatedUrl": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/georestrictions.html", + "Description": "**CloudFront distributions** have **geographic restrictions** configured to limit access by country using an allowlist or blocklist (`RestrictionType` not `none`).", + "Risk": "Absent geo restrictions, content is globally reachable, enabling:\n- Access from sanctioned or unlicensed regions (confidentiality/compliance)\n- Broader bot abuse, scraping, and DDoS staging (availability)\n- More credential-stuffing and fraud attempts against apps", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://repost.aws/knowledge-center/cloudfront-geo-restriction", + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/georestrictions.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/geo-restriction.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/geo-restriction.html", - "Terraform": "" + "CLI": "aws cloudfront get-distribution-config --id --output json > current-config.json && echo 'Manually edit current-config.json to add Restrictions.GeoRestriction with RestrictionType: \"whitelist\" and Locations: [\"US\"], then run:' && echo 'aws cloudfront update-distribution --id --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id --query \"ETag\" --output text)'", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - DomainName: \"\"\n Id: \"\"\n DefaultCacheBehavior:\n TargetOriginId: \"\"\n ViewerProtocolPolicy: allow-all\n CachePolicyId: \"\"\n Restrictions:\n GeoRestriction:\n RestrictionType: whitelist # CRITICAL: enables geo restrictions\n Locations: # CRITICAL: at least one allowed country\n - US\n```", + "Other": "1. In the AWS Console, go to CloudFront > Distributions\n2. Select the target distribution\n3. Open the Security tab > Geographic restrictions > Edit\n4. Choose Allow list (or Block list)\n5. Add at least one country to the list\n6. Save changes", + "Terraform": "```hcl\nresource \"aws_cloudfront_distribution\" \"\" {\n enabled = true\n\n origins {\n domain_name = \"\"\n origin_id = \"\"\n }\n\n default_cache_behavior {\n target_origin_id = \"\"\n viewer_protocol_policy = \"allow-all\"\n cache_policy_id = \"\"\n }\n\n restrictions {\n geo_restriction {\n restriction_type = \"whitelist\" # CRITICAL: enables geo restrictions\n locations = [\"US\"] # CRITICAL: at least one allowed country\n }\n }\n}\n```" }, "Recommendation": { - "Text": "If possible, define and enable Geo restrictions for this service.", - "Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/georestrictions.html" + "Text": "Apply **least privilege** to distribution scope: enable geo restrictions with a country **allowlist** where feasible, or maintain a precise blocklist aligned to legal, licensing, and threat models.\n\nLayer **defense in depth**: use WAF/bot controls, signed URLs or cookies, and monitoring to detect abuse and configuration drift.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_geo_restrictions_enabled" } }, - "Categories": [], + "Categories": [ + "internet-exposed" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Infrastructure Security" diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_https_enabled/cloudfront_distributions_https_enabled.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_https_enabled/cloudfront_distributions_https_enabled.metadata.json index 6517c2702f..ea9ed31f9d 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_https_enabled/cloudfront_distributions_https_enabled.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_https_enabled/cloudfront_distributions_https_enabled.metadata.json @@ -1,26 +1,35 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_https_enabled", - "CheckTitle": "Check if CloudFront distributions are set to HTTPS.", - "CheckType": [], + "CheckTitle": "CloudFront distribution has viewer protocol policy set to HTTPS only or redirect to HTTPS", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Effects/Data Exposure" + ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsCloudFrontDistribution", - "Description": "Check if CloudFront distributions are set to HTTPS.", - "Risk": "If not enabled sensitive information in transit is not protected. Surveillance and other threats are risks may exists.", - "RelatedUrl": "https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html", + "Description": "CloudFront distributions require viewer connections over **HTTPS** when the default cache behavior `viewer_protocol_policy` is `https-only` or `redirect-to-https`. Configurations that use `allow-all` permit HTTP.", + "Risk": "Allowing HTTP exposes traffic to **man-in-the-middle** interception and **session hijacking**, enabling theft of cookies, tokens, or PII. Attackers can **tamper** with responses, inject malware, or perform **downgrade/strip** attacks, undermining confidentiality and integrity.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/security-policy.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html", + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-https.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/aws/networking-policies/networking_32#cloudformation", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/security-policy.html", - "Terraform": "https://docs.prowler.com/checks/aws/networking-policies/networking_32#terraform" + "CLI": "aws cloudfront get-distribution-config --id --output json > current-config.json && echo 'Manually edit current-config.json to change DefaultCacheBehavior.ViewerProtocolPolicy to \"redirect-to-https\" or \"https-only\", then run:' && echo 'aws cloudfront update-distribution --id --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id --query \"ETag\" --output text)'", + "NativeIaC": "```yaml\n# CloudFormation: set ViewerProtocolPolicy to require HTTPS\nResources:\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n DefaultCacheBehavior:\n ViewerProtocolPolicy: https-only # Critical: requires HTTPS for viewers\n```", + "Other": "1. In the AWS Console, go to CloudFront > Distributions\n2. Select the target distribution and open the Behaviors tab\n3. Select the Default (*) behavior and click Edit\n4. Set Viewer protocol policy to Redirect HTTP to HTTPS (or HTTPS Only)\n5. Save changes and deploy", + "Terraform": "```hcl\n# Terraform: set viewer_protocol_policy to force HTTPS\nresource \"aws_cloudfront_distribution\" \"\" {\n default_cache_behavior {\n target_origin_id = \"\"\n viewer_protocol_policy = \"redirect-to-https\" # Critical: forces HTTP to HTTPS\n }\n}\n```" }, "Recommendation": { - "Text": "Use HTTPS everywhere possible. It will enforce privacy and protect against account hijacking and other threats.", - "Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-https.html" + "Text": "Enforce **HTTPS-only** access for viewers by setting `viewer_protocol_policy` to `https-only` or `redirect-to-https`; avoid `allow-all`. Extend encryption end-to-end to origins, enable **HSTS**, prefer modern TLS and ciphers, and mark cookies `Secure`. This supports **defense in depth** and prevents downgrade.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_https_enabled" } }, "Categories": [ diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_https_sni_enabled/cloudfront_distributions_https_sni_enabled.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_https_sni_enabled/cloudfront_distributions_https_sni_enabled.metadata.json index 6a4deed4cc..d6f190539f 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_https_sni_enabled/cloudfront_distributions_https_sni_enabled.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_https_sni_enabled/cloudfront_distributions_https_sni_enabled.metadata.json @@ -1,26 +1,34 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_https_sni_enabled", - "CheckTitle": "Check if CloudFront distributions are using SNI to serve HTTPS requests.", - "CheckType": [], + "CheckTitle": "CloudFront distribution serves HTTPS requests using SNI", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsCloudFrontDistribution", - "Description": "Check if CloudFront distributions are using SNI to serve HTTPS requests.", - "Risk": "If SNI is not used, CloudFront will allocate a dedicated IP address for each SSL certificate, leading to higher costs and inefficient IP address utilization. This could also complicate scaling and managing multiple distributions, especially if your domain requires multiple SSL certificates.", - "RelatedUrl": "https://www.cloudflare.com/es-es/learning/ssl/what-is-sni/", + "Description": "**CloudFront distributions** that use **custom SSL/TLS certificates** are configured to serve **HTTPS** using **Server Name Indication** (`ssl_support_method: sni-only`). It evaluates SNI use rather than dedicated IP during the TLS handshake.", + "Risk": "Without **SNI**, distributions use dedicated IP SSL, driving higher costs and inefficient IP usage. Dedicated IPs can strain quotas and hinder scaling, reducing **availability**. Managing IP-bound certificates adds **operational risk** during rotations and expansions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-sni.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-8", + "https://support.icompaas.com/support/solutions/articles/62000223557-ensure-cloudfront-sni-enabled", + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cnames-https-dedicated-ip-or-sni.html#cnames-https-sni" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-8", - "Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-sni.html" + "CLI": "aws cloudfront get-distribution-config --id --output json > current-config.json && echo 'Manually edit current-config.json to change ViewerCertificate.SslSupportMethod to sni-only', then run:' && echo 'aws cloudfront update-distribution --id --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id --query \"ETag\" --output text)'", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: \n DomainName: \n S3OriginConfig:\n OriginAccessIdentity: ''\n DefaultCacheBehavior:\n TargetOriginId: \n ViewerProtocolPolicy: allow-all\n ForwardedValues:\n QueryString: false\n Cookies:\n Forward: none\n MinTTL: 0\n ViewerCertificate:\n AcmCertificateArn: \n SslSupportMethod: sni-only # Critical: enable SNI for HTTPS\n MinimumProtocolVersion: TLSv1 # Required when using SNI with a custom cert\n```", + "Other": "1. In the AWS Console, go to CloudFront and open your distribution\n2. Select the Settings/General tab and click Edit\n3. Under SSL certificate, ensure your custom certificate is selected\n4. Set Client support to SNI only\n5. Click Save changes", + "Terraform": "```hcl\nresource \"aws_cloudfront_distribution\" \"\" {\n enabled = true\n\n origin {\n domain_name = \"\"\n origin_id = \"\"\n }\n\n default_cache_behavior {\n target_origin_id = \"\"\n viewer_protocol_policy = \"allow-all\"\n forwarded_values {\n query_string = false\n cookies { forward = \"none\" }\n }\n min_ttl = 0\n }\n\n viewer_certificate {\n acm_certificate_arn = \"\"\n ssl_support_method = \"sni-only\" # Critical: enable SNI for HTTPS\n minimum_protocol_version = \"TLSv1\" # Required with SNI\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure that your CloudFront distributions are configured to use Server Name Indication (SNI) when serving HTTPS requests with custom SSL/TLS certificates. This is the recommended approach for reducing costs and optimizing IP address usage.", - "Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cnames-https-dedicated-ip-or-sni.html#cnames-https-sni" + "Text": "Use **SNI** (`sni-only`) for **HTTPS** with custom certificates; avoid dedicated IP unless a critical, non-SNI client requires it. Document and periodically review exceptions, plan client upgrades, and adopt the latest **TLS security policy** to standardize secure, modern configurations.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_https_sni_enabled" } }, "Categories": [ diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled.metadata.json index 4b50a46bd0..53f22a70d0 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled.metadata.json @@ -1,30 +1,39 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_logging_enabled", - "CheckTitle": "Check if CloudFront distributions have logging enabled.", - "CheckType": [], + "CheckTitle": "CloudFront distribution has logging enabled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsCloudFrontDistribution", - "Description": "Check if CloudFront distributions have logging enabled.", - "Risk": "If not enabled monitoring of service use is not possible.", - "RelatedUrl": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html", + "Description": "**CloudFront distributions** record viewer requests using either **standard access logs** or an attached **real-time log configuration**.\n\nThe finding evaluates whether logging is configured so request metadata is captured for each distribution.", + "Risk": "Missing **CloudFront logs** blinds monitoring of edge requests, impeding detection of bot abuse, credential stuffing, origin probing, and cache-bypass attempts.\n\nThis delays incident response and weakens evidence for forensics, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html", + "https://repost.aws/knowledge-center/cloudfront-logging-requests", + "https://aws.amazon.com/awstv/watch/e895e7811ac/", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/enable-real-time-logging.html", + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/real-time-logs.html" + ], "Remediation": { "Code": { - "CLI": "aws cloudfront update-distribution --id --distribution-config logging.json --if-match ", - "NativeIaC": "https://docs.prowler.com/checks/aws/logging-policies/logging_20#cloudformation", - "Other": "https://docs.prowler.com/checks/aws/logging-policies/logging_20", - "Terraform": "https://docs.prowler.com/checks/aws/logging-policies/logging_20#terraform" + "CLI": "aws cloudfront get-distribution-config --id --output json > current-config.json && echo 'Manually edit current-config.json to add Logging.Bucket: .s3.amazonaws.com', then run:' && echo 'aws cloudfront update-distribution --id --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id --query \"ETag\" --output text)'", + "NativeIaC": "```yaml\n# CloudFormation: enable CloudFront standard access logging\nResources:\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: origin1\n DomainName: \n S3OriginConfig: {}\n DefaultCacheBehavior:\n TargetOriginId: origin1\n ViewerProtocolPolicy: allow-all\n Logging:\n Bucket: .s3.amazonaws.com # CRITICAL: enables standard access logs to S3 for this distribution\n # The presence of Logging with Bucket turns on access logging\n```", + "Other": "1. In the AWS Console, go to CloudFront and select your distribution\n2. Open the General tab and click Edit\n3. In Standard logging, set to On\n4. Select the S3 bucket to receive logs\n5. Ensure the S3 bucket has Object Ownership set to ACLs enabled (Bucket owner preferred/ObjectWriter)\n6. Save changes", + "Terraform": "```hcl\n# Add this block to your existing CloudFront distribution to enable access logging\nresource \"aws_cloudfront_distribution\" \"\" {\n # ... existing required config ...\n logging_config {\n bucket = \".s3.amazonaws.com\" # CRITICAL: enables standard access logs to S3\n }\n}\n```" }, "Recommendation": { - "Text": "Real-time monitoring can be achieved by directing CloudTrail Logs to CloudWatch Logs and establishing corresponding metric filters and alarms. Enable logging for services with defined log rotation. These logs are useful for Incident Response and forensics investigation among other use cases.", - "Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html" + "Text": "Enable **standard access logs** or **real-time logs** for all distributions.\n\nApply **least privilege** to log storage, enforce retention and immutability, and centralize ingestion with alerts.\n\nUse **defense-in-depth**: correlate with WAF metrics, sample real-time when needed, and audit new distributions for logging.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_logging_enabled" } }, "Categories": [ - "forensics-ready", "logging" ], "DependsOn": [], diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_multiple_origin_failover_configured/cloudfront_distributions_multiple_origin_failover_configured.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_multiple_origin_failover_configured/cloudfront_distributions_multiple_origin_failover_configured.metadata.json index 2d5e83b1e8..86e587ab34 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_multiple_origin_failover_configured/cloudfront_distributions_multiple_origin_failover_configured.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_multiple_origin_failover_configured/cloudfront_distributions_multiple_origin_failover_configured.metadata.json @@ -1,34 +1,39 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_multiple_origin_failover_configured", - "CheckTitle": "Check if CloudFront distributions have origin failover enabled.", + "CheckTitle": "CloudFront distribution has origin failover configured with at least two origins", "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "NIST 800-53 Controls" + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls", + "Effects/Denial of Service" ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id", + "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "AWSCloudFrontDistribution", - "Description": "Check if CloudFront distributions have origin failover enabled.", - "Risk": "Without origin failover, if the primary origin becomes unavailable, your CloudFront distribution may experience downtime, leading to potential service interruptions and a poor user experience.", - "RelatedUrl": "https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_OriginGroup.html", + "ResourceType": "AwsCloudFrontDistribution", + "Description": "**CloudFront distributions** are evaluated for an **origin group** configured with at least `2` origins to support automatic origin failover.", + "Risk": "Without **origin failover**, the origin becomes a **single point of failure**. Origin outages, regional incidents, or targeted **DoS** can cause **downtime**, elevated error rates, and latency, degrading **availability** and impacting user experience and SLAs.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/high_availability_origin_failover.html#concept_origin_groups.creating", + "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-4", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/origin-failover-enabled.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-4", - "Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/origin-failover-enabled.html" + "CLI": "aws cloudfront get-distribution-config --id --output json > current-config.json && echo 'Manually edit current-config.json to add OriginGroups with two origins and FailoverCriteria, then run:' && echo 'aws cloudfront update-distribution --id --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id --query \"ETag\" --output text)'", + "NativeIaC": "```yaml\n# CloudFormation: Add an origin group with two origins and use it in the default cache behavior\nResources:\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n Quantity: 2\n Items:\n - Id: primary\n DomainName: \n S3OriginConfig: {}\n - Id: secondary\n DomainName: \n S3OriginConfig: {}\n OriginGroups:\n Quantity: 1\n Items:\n - Id: # Critical: define origin group with 2 origins\n FailoverCriteria:\n StatusCodes:\n Quantity: 1\n Items: [500] # Critical: fail over on 500 to enable origin failover\n Members:\n Quantity: 2\n Items:\n - OriginId: primary\n - OriginId: secondary\n DefaultCacheBehavior:\n TargetOriginId: # Critical: use the origin group for requests\n ViewerProtocolPolicy: allow-all\n ForwardedValues:\n QueryString: false\n Cookies:\n Forward: none\n```", + "Other": "1. In the AWS Console, go to CloudFront and open your distribution\n2. Select the Origins tab and ensure two origins exist; add a second origin if needed\n3. In the Origin groups pane, click Create origin group\n4. Select the two origins; set one as primary and the other as secondary\n5. Choose at least one failover status code (e.g., 500) and create the group\n6. Go to Behaviors, edit the relevant cache behavior (or Default behavior)\n7. Set Origin to the new origin group and save changes\n8. Deploy/confirm the distribution update", + "Terraform": "```hcl\n# Configure an origin group with two origins and use it in the default cache behavior\nresource \"aws_cloudfront_distribution\" \"\" {\n enabled = true\n\n origin {\n domain_name = \"\"\n origin_id = \"primary\"\n s3_origin_config {}\n }\n\n origin {\n domain_name = \"\"\n origin_id = \"secondary\"\n s3_origin_config {}\n }\n\n origin_group {\n origin_id = \"\" # Critical: define origin group with 2 origins\n failover_criteria {\n status_codes = [500] # Critical: fail over on 500 to enable origin failover\n }\n member { origin_id = \"primary\" }\n member { origin_id = \"secondary\" }\n }\n\n default_cache_behavior {\n target_origin_id = \"\" # Critical: use the origin group for requests\n viewer_protocol_policy = \"allow-all\"\n forwarded_values {\n query_string = false\n cookies { forward = \"none\" }\n }\n }\n\n restrictions {\n geo_restriction { restriction_type = \"none\" }\n }\n\n viewer_certificate { cloudfront_default_certificate = true }\n}\n```" }, "Recommendation": { - "Text": "Configure origin failover in your CloudFront distribution by setting up an origin group with at least two origins to enhance availability and ensure traffic is redirected if the primary origin fails.", - "Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/high_availability_origin_failover.html#concept_origin_groups.creating" + "Text": "Enable **origin failover** by defining an origin group with primary and secondary origins. Distribute origins across independent zones or providers, set clear failover criteria (e.g., HTTP codes/timeouts), monitor health, and routinely test failover. Align with **resilience** and **defense-in-depth** to protect availability.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_multiple_origin_failover_configured" } }, "Categories": [ - "redundancy" + "resilience" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_origin_traffic_encrypted/cloudfront_distributions_origin_traffic_encrypted.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_origin_traffic_encrypted/cloudfront_distributions_origin_traffic_encrypted.metadata.json index 8864cd74e2..234e62835a 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_origin_traffic_encrypted/cloudfront_distributions_origin_traffic_encrypted.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_origin_traffic_encrypted/cloudfront_distributions_origin_traffic_encrypted.metadata.json @@ -1,29 +1,42 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_origin_traffic_encrypted", - "CheckTitle": "Check if CloudFront distributions encrypt traffic to custom origins.", - "CheckType": [], + "CheckTitle": "CloudFront distribution encrypts traffic to custom origins", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Security", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Effects/Data Exposure" + ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AWSCloudFrontDistribution", - "Description": "Check if CloudFront distributions encrypt traffic to custom origins.", - "Risk": "Allowing unencrypted HTTP traffic between CloudFront and custom origins can expose data to potential eavesdropping and manipulation, compromising data security and integrity.", - "RelatedUrl": "https://docs.aws.amazon.com/whitepapers/latest/secure-content-delivery-amazon-cloudfront/custom-origin-with-cloudfront.html", + "ResourceType": "AwsCloudFrontDistribution", + "Description": "**CloudFront distributions** are evaluated for **TLS to origins**. The check ensures custom origins use `origin_protocol_policy`=`https-only`, or `match-viewer` only when the viewer protocol policy disallows HTTP. For S3 origins, it inspects the viewer protocol policy and flags `allow-all` as permitting non-encrypted paths.", + "Risk": "Unencrypted origin links enable on-path interception and manipulation. Secrets, cookies, and PII can be exposed, and responses altered, undermining **confidentiality** and **integrity**. This increases chances of session hijacking, cache poisoning, and malicious content injection.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-https.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-traffic-to-origin-unencrypted.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-9", + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-https-cloudfront-to-custom-origin.html", + "https://docs.aws.amazon.com/whitepapers/latest/secure-content-delivery-amazon-cloudfront/custom-origin-with-cloudfront.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-9", - "Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-traffic-to-origin-unencrypted.html" + "CLI": "aws cloudfront get-distribution-config --id --output json > current-config.json && echo 'Manually edit current-config.json to change Origins[].CustomOriginConfig.OriginProtocolPolicy to https-only', then run:' && echo 'aws cloudfront update-distribution --id --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id --query \"ETag\" --output text)'", + "NativeIaC": "```yaml\n# CloudFormation: set CloudFront origin to use HTTPS only\nResources:\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: \n DomainName: \n CustomOriginConfig:\n OriginProtocolPolicy: https-only # FIX: Force CloudFront-to-origin over HTTPS only\n DefaultCacheBehavior:\n TargetOriginId: \n ViewerProtocolPolicy: allow-all\n ForwardedValues:\n QueryString: false\n```", + "Other": "1. In the AWS Console, open CloudFront and select your distribution\n2. Go to the Origins tab, select the custom origin, and click Edit\n3. Set Protocol to HTTPS only (Origin protocol policy = HTTPS Only)\n4. Click Save changes and wait for the distribution to deploy", + "Terraform": "```hcl\n# Terraform: set CloudFront origin to use HTTPS only\nresource \"aws_cloudfront_distribution\" \"\" {\n enabled = true\n\n origin {\n domain_name = \"\"\n origin_id = \"\"\n\n custom_origin_config {\n http_port = 80\n https_port = 443\n origin_protocol_policy = \"https-only\" # FIX: Force CloudFront-to-origin over HTTPS only\n origin_ssl_protocols = [\"TLSv1.2\"]\n }\n }\n\n default_cache_behavior {\n target_origin_id = \"\"\n viewer_protocol_policy = \"allow-all\"\n forwarded_values {\n query_string = false\n cookies { forward = \"none\" }\n }\n }\n\n restrictions { geo_restriction { restriction_type = \"none\" } }\n viewer_certificate { cloudfront_default_certificate = true }\n}\n```" }, "Recommendation": { - "Text": "Configure your CloudFront distributions to require HTTPS (TLS) for traffic to custom origins, ensuring all data transmitted between CloudFront and the origin is encrypted and protected from unauthorized access.", - "Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-https-cloudfront-to-custom-origin.html" + "Text": "Enforce end-to-end HTTPS. Set `origin_protocol_policy` to `https-only` and viewer policy to `https-only` or `redirect-to-https`. Use trusted certificates and modern TLS (`TLSv1.2+`), disabling weak protocols. Apply **least privilege** and **defense in depth** by restricting direct origin access and preferring private connectivity.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_origin_traffic_encrypted" } }, - "Categories": [], + "Categories": [ + "encryption" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_s3_origin_access_control/cloudfront_distributions_s3_origin_access_control.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_s3_origin_access_control/cloudfront_distributions_s3_origin_access_control.metadata.json index 8cd0c296b6..4d6a9a3823 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_s3_origin_access_control/cloudfront_distributions_s3_origin_access_control.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_s3_origin_access_control/cloudfront_distributions_s3_origin_access_control.metadata.json @@ -1,31 +1,41 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_s3_origin_access_control", - "CheckTitle": "Check if CloudFront distributions with S3 origin use OAC.", + "CheckTitle": "CloudFront distribution uses Origin Access Control (OAC) for all S3 origins", "CheckType": [ - "Data Exposure" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Effects/Data Exposure" ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AWSCloudFrontDistribution", - "Description": "Check if CloudFront distributions use origin access control.", - "Risk": "Without OAC, your S3 bucket could be accessed directly, bypassing CloudFront, which could expose your content to unauthorized access. Additionally, relying on Origin Access Identity (OAI) may limit functionality and security features, making your distribution less secure and more difficult to manage.", - "RelatedUrl": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html#migrate-from-oai-to-oac", + "ResourceType": "AwsCloudFrontDistribution", + "Description": "**CloudFront distributions** with **Amazon S3 origins** are expected to use **Origin Access Control** (`OAC`) on each S3 origin.\n\nThe evaluation inspects distributions that include `s3_origin_config` and identifies S3 origins that lack an associated OAC.", + "Risk": "Without **OAC**, S3 objects can be reached outside CloudFront, bypassing edge controls and weakening **confidentiality** and **integrity**.\n- Direct access enables data exfiltration\n- Loss of WAF, rate-limiting, and detailed logging; cost abuse\n- Limited support for signed writes and **SSE-KMS**, increasing tampering risk", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/s3-origin.html", + "https://repost.aws/knowledge-center/cloudfront-access-to-amazon-s3", + "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-13", + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/aws/iam-policies/ensure-aws-cloudfromt-distribution-with-s3-have-origin-access-set-to-enabled/", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-13", - "Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/s3-origin.html" + "CLI": "aws cloudfront create-origin-access-control --origin-access-control-config '{Name\":\"\",\"SigningProtocol\":\"sigv4\",\"SigningBehavior\":\"always\",\"OriginAccessControlOriginType\":\"s3\"}' && aws cloudfront get-distribution-config --id --output json > current-config.json && echo 'Manually edit current-config.json to add OriginAccessControlId to S3 origins, then run:' && echo 'aws cloudfront update-distribution --id --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id --query \"ETag\" --output text)'", + "NativeIaC": "```yaml\n# CloudFormation: attach OAC to S3 origin in a CloudFront distribution\nResources:\n ExampleOAC:\n Type: AWS::CloudFront::OriginAccessControl\n Properties:\n OriginAccessControlConfig:\n Name: \n OriginAccessControlOriginType: s3\n SigningBehavior: always\n SigningProtocol: sigv4\n\n ExampleDistribution:\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: s3-\n DomainName: .s3.amazonaws.com\n OriginAccessControlId: !Ref ExampleOAC # CRITICAL: attaches OAC to the S3 origin to satisfy the check\n S3OriginConfig:\n OriginAccessIdentity: \"\" # CRITICAL: disable OAI when using OAC\n DefaultCacheBehavior:\n TargetOriginId: s3-\n ViewerProtocolPolicy: redirect-to-https\n CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6\n```", + "Other": "1. In the AWS Console, open CloudFront and go to Security > Origin access > Origin access control (OAC). Click Create control setting, choose Origin type S3, keep Sign requests, and create the OAC.\n2. Open your CloudFront distribution, go to the Origins tab.\n3. For each S3 origin: click Edit, select Origin access control settings (recommended), choose the OAC created in step 1, and Save changes.\n4. Repeat step 3 for all S3 origins in the distribution.", + "Terraform": "```hcl\n# Terraform: attach OAC to S3 origin in a CloudFront distribution\nresource \"aws_cloudfront_origin_access_control\" \"oac\" {\n name = \"\"\n origin_access_control_origin_type = \"s3\"\n signing_behavior = \"always\"\n signing_protocol = \"sigv4\"\n}\n\nresource \"aws_cloudfront_distribution\" \"dist\" {\n enabled = true\n\n origin {\n domain_name = \".s3.amazonaws.com\"\n origin_id = \"s3-\"\n\n origin_access_control_id = aws_cloudfront_origin_access_control.oac.id # CRITICAL: attaches OAC to the S3 origin to satisfy the check\n\n s3_origin_config {\n origin_access_identity = \"\" # CRITICAL: disable OAI when using OAC\n }\n }\n\n default_cache_behavior {\n target_origin_id = \"s3-\"\n viewer_protocol_policy = \"redirect-to-https\"\n cache_policy_id = \"658327ea-f89d-4fab-a63d-7e88639e58f6\"\n }\n}\n```" }, "Recommendation": { - "Text": "Configure Origin Access Control (OAC) for CloudFront distributions that use an Amazon S3 origin. This will ensure that the content in your S3 bucket is accessible only through the specified CloudFront distribution, enhancing security by preventing direct access to the bucket.", - "Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html" + "Text": "Enable **Origin Access Control** for all S3 origins and keep buckets non-public.\n\nApply **least privilege**: scope bucket and key permissions to CloudFront and the intended distribution. Ensure origin requests are signed, migrate from legacy OAI, and use **defense in depth** with WAF and monitoring to protect and observe access.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_s3_origin_access_control" } }, - "Categories": [], + "Categories": [ + "trust-boundaries" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_s3_origin_non_existent_bucket/cloudfront_distributions_s3_origin_non_existent_bucket.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_s3_origin_non_existent_bucket/cloudfront_distributions_s3_origin_non_existent_bucket.metadata.json index 2227c18d65..d00a4e0dba 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_s3_origin_non_existent_bucket/cloudfront_distributions_s3_origin_non_existent_bucket.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_s3_origin_non_existent_bucket/cloudfront_distributions_s3_origin_non_existent_bucket.metadata.json @@ -1,32 +1,39 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_s3_origin_non_existent_bucket", - "CheckTitle": "CloudFront distributions should not point to non-existent S3 origins without static website hosting.", + "CheckTitle": "CloudFront distribution S3 origins reference existing buckets", "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls" ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AwsCloudFrontDistribution", - "Description": "This control checks whether Amazon CloudFront distributions are pointing to non-existent Amazon S3 origins without static website hosting. The control fails if the origin is configured to point to a non-existent bucket.", - "Risk": "Pointing a CloudFront distribution to a non-existent S3 bucket can allow malicious actors to create the bucket and potentially serve unauthorized content through your distribution, leading to security and integrity issues.", - "RelatedUrl": "https://docs.aws.amazon.com/whitepapers/latest/secure-content-delivery-amazon-cloudfront/s3-origin-with-cloudfront.html", + "Description": "**CloudFront distributions** with `S3OriginConfig` should reference existing **S3 bucket origins** (excluding static website hosting).\n\nIdentifies origins where the configured bucket name does not exist.", + "Risk": "**Dangling S3 origins** allow potential **bucket takeover**: an attacker could create the missing bucket and have CloudFront retrieve attacker-controlled objects *if access isn't restricted*.\n\nThis threatens **integrity** (content spoofing, cache poisoning) and **availability** (errors/timeouts), undermining user trust.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/whitepapers/latest/secure-content-delivery-amazon-cloudfront/s3-origin-with-cloudfront.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-12", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-existing-s3-bucket.html" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "", - "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/cloudfront-controls.html#cloudfront-12", - "Terraform": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-existing-s3-bucket.html" + "NativeIaC": "```yaml\n# CloudFormation: ensure the S3 bucket referenced by the CloudFront S3 origin exists\nResources:\n :\n Type: AWS::S3::Bucket\n Properties:\n BucketName: # Critical: must exactly match the bucket name used in the CloudFront origin's domain (before \".s3\") to make it exist\n```", + "Other": "1. In the AWS console, open CloudFront and select the distribution\n2. Go to Origins, select the S3 origin, and note the Domain Name (the bucket name is the text before \".s3\")\n3. Open the S3 console, click Create bucket, enter the exact bucket name from step 2, and create the bucket\n4. Re-run the check", + "Terraform": "```hcl\n# Ensure the S3 bucket referenced by the CloudFront S3 origin exists\nresource \"aws_s3_bucket\" \"\" {\n bucket = \"\" # Critical: must exactly match the bucket name used in the CloudFront origin's domain (before \".s3\")\n}\n```" }, "Recommendation": { - "Text": "Verify that all CloudFront distributions are configured to point to valid, existing S3 buckets. Update the origin settings as needed to ensure that your distributions are linked to appropriate and secure origins.", - "Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/HowToUpdateDistribution.html" + "Text": "Ensure origins reference valid, owned buckets; delete or update stale references. Enforce **origin access control** (or OAI) and tight bucket policies so only the distribution can access objects. Apply **least privilege**, manage bucket names, and monitor origin health to prevent misrouting.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_s3_origin_non_existent_bucket" } }, "Categories": [ - "trustboundaries" + "resilience" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_using_deprecated_ssl_protocols/cloudfront_distributions_using_deprecated_ssl_protocols.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_using_deprecated_ssl_protocols/cloudfront_distributions_using_deprecated_ssl_protocols.metadata.json index 49e22fe386..222d2ab1cb 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_using_deprecated_ssl_protocols/cloudfront_distributions_using_deprecated_ssl_protocols.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_using_deprecated_ssl_protocols/cloudfront_distributions_using_deprecated_ssl_protocols.metadata.json @@ -1,26 +1,34 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_using_deprecated_ssl_protocols", - "CheckTitle": "Check if CloudFront distributions are using deprecated SSL protocols.", - "CheckType": [], + "CheckTitle": "CloudFront distribution does not use SSLv3, TLSv1, or TLSv1.1 for origin connections", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id", + "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "AwsCloudFrontDistribution", - "Description": "Check if CloudFront distributions are using deprecated SSL protocols.", - "Risk": "Using insecure ciphers could affect privacy of in transit information.", - "RelatedUrl": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html", + "Description": "CloudFront distributions have origins whose `OriginSslProtocols` allow **deprecated SSL/TLS versions** (`SSLv3`, `TLSv1`, `TLSv1.1`) for CloudFront-to-origin HTTPS connections.", + "Risk": "Weak protocols between CloudFront and the origin allow downgrades and known exploits (e.g., POODLE/BEAST), enabling eavesdropping or content tampering. This compromises the **confidentiality** and **integrity** of data in transit, exposing cookies, credentials, and responses served to viewers.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html", + "https://trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-insecure-origin-ssl-protocols.html", + "https://support.icompaas.com/support/solutions/articles/62000223404-ensure-cloudfront-distributions-are-not-using-deprecated-ssl-protocols" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "", - "Other": "https://docs.prowler.com/checks/aws/networking-policies/networking_33", - "Terraform": "" + "CLI": "aws cloudfront get-distribution-config --id --output json > current-config.json && echo 'Manually edit current-config.json to change Origins[].CustomOriginConfig.OriginSslProtocols to [TLSv1.2], then run:' && echo 'aws cloudfront update-distribution --id --distribution-config file://current-config.json --if-match $(aws cloudfront get-distribution-config --id --query \"ETag\" --output text)'", + "NativeIaC": "```yaml\n# CloudFormation: set origin to allow only TLSv1.2 when connecting to the origin\nResources:\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: \n DomainName: \n CustomOriginConfig:\n OriginProtocolPolicy: https-only\n OriginSslProtocols:\n - TLSv1.2 # CRITICAL: restrict origin SSL protocols to TLSv1.2 to remove SSLv3/TLSv1/TLSv1.1\n DefaultCacheBehavior:\n TargetOriginId: \n ViewerProtocolPolicy: redirect-to-https\n```", + "Other": "1. Open the AWS Console and go to CloudFront\n2. Select the distribution and open the Origins tab\n3. Select the custom origin and click Edit\n4. Under Origin SSL protocols, select only TLSv1.2\n5. Save changes\n6. Repeat for any other custom origins in the distribution", + "Terraform": "```hcl\n# Terraform: set origin to allow only TLSv1.2 when connecting to the origin\nresource \"aws_cloudfront_distribution\" \"\" {\n enabled = true\n\n origin {\n domain_name = \"\"\n origin_id = \"\"\n\n custom_origin_config {\n http_port = 80\n https_port = 443\n origin_protocol_policy = \"https-only\"\n origin_ssl_protocols = [\"TLSv1.2\"] # CRITICAL: restrict origin SSL protocols to TLSv1.2\n }\n }\n\n default_cache_behavior {\n target_origin_id = \"\"\n viewer_protocol_policy = \"redirect-to-https\"\n allowed_methods = [\"GET\", \"HEAD\"]\n cached_methods = [\"GET\", \"HEAD\"]\n forwarded_values { query_string = false }\n }\n}\n```" }, "Recommendation": { - "Text": "Use a Security policy with ciphers that are as strong as possible. Drop legacy and insecure ciphers.", - "Url": "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html" + "Text": "Enforce **TLS 1.2+** for CloudFront-to-origin traffic. Remove `SSLv3`, `TLSv1`, `TLSv1.1` from allowed protocols and prefer modern cipher suites. Verify origin compatibility, update certificates and libraries, and periodically review policies as part of **defense in depth** and **least privilege**.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_using_deprecated_ssl_protocols" } }, "Categories": [ diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_using_waf/cloudfront_distributions_using_waf.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_using_waf/cloudfront_distributions_using_waf.metadata.json index c6d9f8ce87..d3329f54d5 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_using_waf/cloudfront_distributions_using_waf.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_using_waf/cloudfront_distributions_using_waf.metadata.json @@ -1,31 +1,40 @@ { "Provider": "aws", "CheckID": "cloudfront_distributions_using_waf", - "CheckTitle": "Check if CloudFront distributions are using WAF.", + "CheckTitle": "CloudFront distribution uses an AWS WAF web ACL", "CheckType": [ - "IAM" + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/PCI-DSS" ], "ServiceName": "cloudfront", "SubServiceName": "", - "ResourceIdTemplate": "arn:partition:cloudfront:region:account-id:distribution/resource-id", + "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "AwsCloudFrontDistribution", - "Description": "Check if CloudFront distributions are using WAF.", - "Risk": "Potential attacks and / or abuse of service, more even for even for internet reachable services.", - "RelatedUrl": "https://docs.aws.amazon.com/waf/latest/developerguide/cloudfront-features.html", + "Description": "**CloudFront distributions** are assessed for an associated **AWS WAF** web ACL that inspects and filters HTTP/S requests at the edge.\n\nThe finding highlights distributions without this web ACL association.", + "Risk": "Absent **WAF** on Internet-facing distributions exposes apps to layer-7 threats: SQLi/XSS and bot abuse can cause data exfiltration (**confidentiality**), unauthorized actions (**integrity**), and request floods that overload origins (**availability**). It may also raise egress and compute costs.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://repost.aws/questions/QUTY5hPVxgS6Caa3eZHX7-nQ/waf-on-alb-or-cloudfront", + "https://docs.aws.amazon.com/waf/latest/developerguide/cloudfront-features.html", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-integrated-with-waf.html" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/aws/general-policies/bc_aws_general_27#cloudformation", - "Other": "https://docs.prowler.com/checks/aws/general-policies/bc_aws_general_27", - "Terraform": "https://docs.prowler.com/checks/aws/general-policies/bc_aws_general_27#terraform" + "NativeIaC": "```yaml\n# CloudFormation: associate an AWS WAFv2 Web ACL with a CloudFront distribution\nResources:\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n Enabled: true\n Origins:\n - Id: origin1\n DomainName: \n S3OriginConfig: {}\n DefaultCacheBehavior:\n TargetOriginId: origin1\n ViewerProtocolPolicy: redirect-to-https\n ForwardedValues:\n QueryString: false\n WebACLId: # CRITICAL: Associates the WAFv2 Web ACL (ARN) to this distribution\n # This makes the distribution PASS by enabling WAF protection\n```", + "Other": "1. In the AWS Console, go to CloudFront > Distributions and select your distribution\n2. Click Edit (General/Settings)\n3. Set AWS WAF Web ACL to your Web ACL (scope: Global/CloudFront)\n4. Click Save/Yes, Edit and wait for Deployment to complete\n5. If no Web ACL exists: go to WAF & Shield > Web ACLs (scope: CloudFront), Create web ACL, then repeat steps 1-4 to associate it", + "Terraform": "```hcl\n# Add this to the existing CloudFront distribution resource\nresource \"aws_cloudfront_distribution\" \"\" {\n web_acl_id = \"\" # CRITICAL: Associates the WAFv2 Web ACL (ARN) to the distribution to PASS the check\n}\n```" }, "Recommendation": { - "Text": "Use AWS WAF to protect your service from common web exploits. These could affect availability and performance, compromise security, or consume excessive resources.", - "Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/aws/CloudFront/cloudfront-integrated-with-waf.html" + "Text": "Associate each distribution with an **AWS WAF web ACL** and apply defense-in-depth:\n- Use managed rule groups and rate limits\n- Add IP/geo and bot controls as needed\n- Enable logging, test new rules in `count` mode, and tune\n- Monitor metrics and update rules\n\nAlign controls with **least privilege** for requests.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_using_waf" } }, - "Categories": [], + "Categories": [ + "internet-exposed" + ], "DependsOn": [], "RelatedTo": [], "Notes": ""