diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index dd3446550a..264a47235e 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -9,18 +9,23 @@ All notable changes to the **Prowler API** are documented in this file. - Added new `GET /compliance-overviews` endpoints to retrieve compliance metadata and specific requirements statuses [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877). ### Changed -- Renamed field encrypted_password to password for M365 provider [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784) - Reworked `GET /compliance-overviews` to return proper requirement metrics [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877). -### Fixed -- Fixed the connection status verification before launching a scan [(#7831)](https://github.com/prowler-cloud/prowler/pull/7831) - --- ## [v1.8.3] (Prowler v5.7.3) +### Added +- Database backend to handle already closed connections [(#7935)](https://github.com/prowler-cloud/prowler/pull/7935). + +### Changed +- Renamed field encrypted_password to password for M365 provider [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784) + ### Fixed - Fixed transaction persistence with RLS operations [(#7916)](https://github.com/prowler-cloud/prowler/pull/7916). +- Reverted the change `get_with_retry` to use the original `get` method for retrieving tasks [(#7932)](https://github.com/prowler-cloud/prowler/pull/7932). +- Fixed the connection status verification before launching a scan [(#7831)](https://github.com/prowler-cloud/prowler/pull/7831) + --- diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py index 60940733c2..54b020597f 100644 --- a/api/src/backend/api/base_views.py +++ b/api/src/backend/api/base_views.py @@ -47,6 +47,8 @@ class BaseViewSet(ModelViewSet): class BaseRLSViewSet(BaseViewSet): def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + # Ideally, this logic would be in the `.setup()` method but DRF view sets don't call it # https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.setup if request.auth is None: @@ -61,8 +63,6 @@ class BaseRLSViewSet(BaseViewSet): self._rls_cm = rls_transaction(tenant_id) self._rls_cm.__enter__() - super().initial(request, *args, **kwargs) - def finalize_response(self, request, response, *args, **kwargs): response = super().finalize_response(request, response, *args, **kwargs) @@ -115,6 +115,8 @@ class BaseTenantViewset(BaseViewSet): pass # Tenant might not exist, handle gracefully def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if request.auth is None: raise NotAuthenticated @@ -127,8 +129,6 @@ class BaseTenantViewset(BaseViewSet): self._rls_cm = rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR) self._rls_cm.__enter__() - super().initial(request, *args, **kwargs) - def finalize_response(self, request, response, *args, **kwargs): response = super().finalize_response(request, response, *args, **kwargs) @@ -141,9 +141,11 @@ class BaseTenantViewset(BaseViewSet): class BaseUserViewset(BaseViewSet): def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + # TODO refactor after improving RLS on users if request.stream is not None and request.stream.method == "POST": - return super().initial(request, *args, **kwargs) + return if request.auth is None: raise NotAuthenticated @@ -156,8 +158,6 @@ class BaseUserViewset(BaseViewSet): self._rls_cm = rls_transaction(tenant_id) self._rls_cm.__enter__() - super().initial(request, *args, **kwargs) - def finalize_response(self, request, response, *args, **kwargs): response = super().finalize_response(request, response, *args, **kwargs) diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 96b5e313f3..46e7211962 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -190,6 +190,8 @@ def generate_compliance_overview_template(prowler_compliance: dict): total_checks = len(requirement.Checks) checks_dict = {check: None for check in requirement.Checks} + req_status_val = "MANUAL" if total_checks == 0 else "PASS" + # Build requirement dictionary requirement_dict = { "name": requirement.Name or requirement.Id, @@ -204,20 +206,18 @@ def generate_compliance_overview_template(prowler_compliance: dict): "manual": 0, "total": total_checks, }, - "status": "PASS", + "status": req_status_val, } - # Update requirements status - if total_checks == 0: + # Update requirements status counts for the framework + if req_status_val == "MANUAL": requirements_status["manual"] += 1 + elif req_status_val == "PASS": + requirements_status["passed"] += 1 # Add requirement to compliance requirements compliance_requirements[requirement.Id] = requirement_dict - # Calculate pending requirements - pending_requirements = total_requirements - requirements_status["manual"] - requirements_status["passed"] = pending_requirements - # Build compliance dictionary compliance_dict = { "framework": compliance_data.Framework, diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index b4a5c3a894..52e32b8c38 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -1,9 +1,7 @@ import json import re -import time from uuid import UUID, uuid4 -from config.env import env from cryptography.fernet import Fernet from django.conf import settings from django.contrib.auth.models import AbstractBaseUser @@ -354,42 +352,6 @@ class ProviderGroupMembership(RowLevelSecurityProtectedModel): resource_name = "provider_groups-provider" -class TaskManager(models.Manager): - def get_with_retry( - self, - id: str, - max_retries: int = None, - delay_seconds: float = None, - ): - """ - Retry fetching a Task by ID in case it hasn't been created yet. - - Args: - id (str): The Celery task ID (expected to match Task model PK). - max_retries (int, optional): Number of retry attempts. Defaults to env TASK_RETRY_ATTEMPTS or 5. - delay_seconds (float, optional): Delay between retries in seconds. Defaults to env TASK_RETRY_DELAY_SECONDS or 0.1. - - Returns: - Task: The retrieved Task instance. - - Raises: - Task.DoesNotExist: If the task is not found after all retries. - """ - max_retries = max_retries or env.int("TASK_RETRY_ATTEMPTS", default=5) - delay_seconds = delay_seconds or env.float( - "TASK_RETRY_DELAY_SECONDS", default=0.1 - ) - - for _attempt in range(max_retries): - try: - return self.get(id=id) - except self.model.DoesNotExist: - time.sleep(delay_seconds) - raise self.model.DoesNotExist( - f"Task with ID {id} not found after {max_retries} retries." - ) - - class Task(RowLevelSecurityProtectedModel): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) inserted_at = models.DateTimeField(auto_now_add=True, editable=False) @@ -402,8 +364,6 @@ class Task(RowLevelSecurityProtectedModel): blank=True, ) - objects = TaskManager() - class Meta(RowLevelSecurityProtectedModel.Meta): db_table = "tasks" diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py index 6b405536b3..bf3208a4d1 100644 --- a/api/src/backend/api/tests/test_compliance.py +++ b/api/src/backend/api/tests/test_compliance.py @@ -1,12 +1,12 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch from api.compliance import ( + generate_compliance_overview_template, + generate_scan_compliance, get_prowler_provider_checks, get_prowler_provider_compliance, - load_prowler_compliance, load_prowler_checks, - generate_scan_compliance, - generate_compliance_overview_template, + load_prowler_compliance, ) from api.models import Provider @@ -69,7 +69,7 @@ class TestCompliance: load_prowler_compliance() - from api.compliance import PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, PROWLER_CHECKS + from api.compliance import PROWLER_CHECKS, PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE assert PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE == { "template_key": "template_value" @@ -268,7 +268,7 @@ class TestCompliance: "manual": 0, "total": 0, }, - "status": "PASS", + "status": "MANUAL", }, }, "requirements_status": { diff --git a/api/src/backend/api/tests/test_models.py b/api/src/backend/api/tests/test_models.py index de2ec7c59e..c2beeb3583 100644 --- a/api/src/backend/api/tests/test_models.py +++ b/api/src/backend/api/tests/test_models.py @@ -1,9 +1,6 @@ -import uuid -from unittest import mock - import pytest -from api.models import Resource, ResourceTag, Task +from api.models import Resource, ResourceTag @pytest.mark.django_db @@ -123,35 +120,3 @@ class TestResourceModel: # compliance={}, # ) # assert Finding.objects.filter(uid=long_uid).exists() - - -@pytest.mark.django_db -class TestTaskManager: - def test_get_with_retry_success(self): - task_id = uuid.uuid4() - call_counter = {"count": 0} - - def side_effect(*args, **kwargs): - if call_counter["count"] < 2: - call_counter["count"] += 1 - raise Task.DoesNotExist() - return Task(id=task_id) - - with mock.patch.object(Task.objects, "get", side_effect=side_effect): - task = Task.objects.get_with_retry( - task_id, max_retries=5, delay_seconds=0.01 - ) - - assert task.id == task_id - assert call_counter["count"] == 2 - - def test_get_with_retry_fail(self): - non_existent_id = uuid.uuid4() - - with mock.patch.object(Task.objects, "get", side_effect=Task.DoesNotExist): - with pytest.raises(Task.DoesNotExist) as excinfo: - Task.objects.get_with_retry( - non_existent_id, max_retries=3, delay_seconds=0.01 - ) - - assert str(non_existent_id) in str(excinfo.value) diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 512ba79a93..a588b0ca01 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -4837,6 +4837,24 @@ class TestComplianceOverviewViewSet: assert "description" in attributes assert "status" in attributes + def test_compliance_overview_requirements_manual( + self, authenticated_client, compliance_requirements_overviews_fixture + ): + scan_id = str(compliance_requirements_overviews_fixture[0].scan.id) + # Compliance with a manual requirement + compliance_id = "aws_account_security_onboarding_aws" + + response = authenticated_client.get( + reverse("complianceoverview-requirements"), + { + "filter[scan_id]": scan_id, + "filter[compliance_id]": compliance_id, + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data[-1]["attributes"]["status"] == "MANUAL" + def test_compliance_overview_requirements_missing_scan_id( self, authenticated_client ): diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 394913cb6c..96f1fbb5b4 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -1092,7 +1092,7 @@ class ProviderViewSet(BaseRLSViewSet): task = check_provider_connection_task.delay( provider_id=pk, tenant_id=self.request.tenant_id ) - prowler_task = Task.objects.get_with_retry(id=task.id) + prowler_task = Task.objects.get(id=task.id) serializer = TaskSerializer(prowler_task) return Response( data=serializer.data, @@ -1115,7 +1115,7 @@ class ProviderViewSet(BaseRLSViewSet): task = delete_provider_task.delay( provider_id=pk, tenant_id=self.request.tenant_id ) - prowler_task = Task.objects.get_with_retry(id=task.id) + prowler_task = Task.objects.get(id=task.id) serializer = TaskSerializer(prowler_task) return Response( data=serializer.data, @@ -1495,7 +1495,7 @@ class ScanViewSet(BaseRLSViewSet): }, ) - prowler_task = Task.objects.get_with_retry(id=task.id) + prowler_task = Task.objects.get(id=task.id) scan.task_id = task.id scan.save(update_fields=["task_id"]) @@ -2733,7 +2733,10 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): "requirement_id", "framework", "version", "description" ) .distinct() - .annotate(total_instances=Count("id")) + .annotate( + total_instances=Count("id"), + manual_count=Count("id", filter=Q(requirement_status="MANUAL")), + ) ) passed_instances = ( @@ -2751,8 +2754,13 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): requirement_id = requirement["requirement_id"] total_instances = requirement["total_instances"] passed_count = passed_counts.get(requirement_id, 0) - - requirement_status = "PASS" if passed_count == total_instances else "FAIL" + is_manual = requirement["manual_count"] == total_instances + if is_manual: + requirement_status = "MANUAL" + elif passed_count == total_instances: + requirement_status = "PASS" + else: + requirement_status = "FAIL" requirements_summary.append( { @@ -3123,7 +3131,7 @@ class ScheduleViewSet(BaseRLSViewSet): with transaction.atomic(): task = schedule_provider_scan(provider_instance) - prowler_task = Task.objects.get_with_retry(id=task.id) + prowler_task = Task.objects.get(id=task.id) self.response_serializer_class = TaskSerializer output_serializer = self.get_serializer(prowler_task) diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 5de1d9ca71..db4a23e464 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -128,6 +128,7 @@ DJANGO_GUID = { } DATABASE_ROUTERS = ["api.db_router.MainRouter"] +POSTGRES_EXTRA_DB_BACKEND_BASE = "database_backend" # Password validation diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index be215ee59c..f3c99a42ce 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -846,8 +846,23 @@ def compliance_requirements_overviews_fixture(scans_fixture, tenants_fixture): total_checks=2, ) - # Create a different compliance framework for testing requirement_overview5 = ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan1, + compliance_id="aws_account_security_onboarding_aws", + framework="AWS-Account-Security-Onboarding", + version="1.0", + description="Description for AWS Account Security Onboarding (MANUAL)", + region="eu-west-2", + requirement_id="requirement3", + requirement_status=StatusChoices.MANUAL, + passed_checks=0, + failed_checks=0, + total_checks=0, + ) + + # Create a different compliance framework for testing + requirement_overview6 = ComplianceRequirementOverview.objects.create( tenant=tenant, scan=scan1, compliance_id="cis_1.4_aws", @@ -868,6 +883,7 @@ def compliance_requirements_overviews_fixture(scans_fixture, tenants_fixture): requirement_overview3, requirement_overview4, requirement_overview5, + requirement_overview6, ) diff --git a/api/src/backend/database_backend/__init__.py b/api/src/backend/database_backend/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/src/backend/database_backend/base.py b/api/src/backend/database_backend/base.py new file mode 100644 index 0000000000..69fee7df4b --- /dev/null +++ b/api/src/backend/database_backend/base.py @@ -0,0 +1,15 @@ +import django.db +from django.db.backends.postgresql.base import ( + DatabaseWrapper as BuiltinPostgresDatabaseWrapper, +) +from psycopg2 import InterfaceError + + +class DatabaseWrapper(BuiltinPostgresDatabaseWrapper): + def create_cursor(self, name=None): + try: + return super().create_cursor(name=name) + except InterfaceError: + django.db.close_old_connections() + django.db.connection.connect() + return super().create_cursor(name=name) diff --git a/docs/getting-started/requirements.md b/docs/getting-started/requirements.md index e5d6087c1f..3f3a48cbb2 100644 --- a/docs/getting-started/requirements.md +++ b/docs/getting-started/requirements.md @@ -170,6 +170,9 @@ These two new environment variables are **required** to execute the PowerShell m - `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 + 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. diff --git a/docs/tutorials/microsoft365/getting-started-m365.md b/docs/tutorials/microsoft365/getting-started-m365.md index 2fde79fe84..2dd0515b4d 100644 --- a/docs/tutorials/microsoft365/getting-started-m365.md +++ b/docs/tutorials/microsoft365/getting-started-m365.md @@ -138,6 +138,10 @@ Follow these steps to assign the permissions: ![Grant Admin Consent](./img/grant-admin-consent-delegated.png) + The final result of permission assignment should be this: + + ![Final Permission Assignment](./img/final-permissions-m365.png) + --- ### Assign required roles to your user diff --git a/docs/tutorials/microsoft365/img/final-permissions-m365.png b/docs/tutorials/microsoft365/img/final-permissions-m365.png new file mode 100644 index 0000000000..c26d80ef66 Binary files /dev/null and b/docs/tutorials/microsoft365/img/final-permissions-m365.png differ diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 2d4a622c30..27023b6827 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.8.0] (Prowler v5.8.0) +## [v5.8.0] (Prowler UNRELEASED) ### Added - Add CIS 1.11 compliance framework for Kubernetes. [(#7790)](https://github.com/prowler-cloud/prowler/pull/7790) @@ -25,7 +25,17 @@ All notable changes to the **Prowler SDK** are documented in this file. --- -### [v5.7.2] Fixed +## [v5.7.3] (Prowler v5.7.3) + +### Fixed +- Automatically encrypt password in Microsoft365 provider. [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784) +- Remove last encrypted password appearances. [(#7825)](https://github.com/prowler-cloud/prowler/pull/7825) + +--- + +## [v5.7.2] (Prowler v5.7.2) + +### Fixed - Fix `m365_powershell test_credentials` to use sanitized credentials. [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761) - Fix `admincenter_users_admins_reduced_license_footprint` check logic to pass when admin user has no license. [(#7779)](https://github.com/prowler-cloud/prowler/pull/7779) - Fix `m365_powershell` to close the PowerShell sessions in msgraph services. [(#7816)](https://github.com/prowler-cloud/prowler/pull/7816) @@ -60,14 +70,12 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update and upgrade CIS for all the providers [(#7738)](https://github.com/prowler-cloud/prowler/pull/7738) - Cover policies with conditions with SNS endpoint in `sns_topics_not_publicly_accessible`. [(#7750)](https://github.com/prowler-cloud/prowler/pull/7750) - Change severity logic for `ec2_securitygroup_allow_ingress_from_internet_to_all_ports` check. [(#7764)](https://github.com/prowler-cloud/prowler/pull/7764) -- Automatically encrypt password in Microsoft365 provider. [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784) --- ## [v5.6.0] (Prowler v5.6.0) ### Added - - Add SOC2 compliance framework to Azure. [(#7489)](https://github.com/prowler-cloud/prowler/pull/7489) - Add check for unused Service Accounts in GCP. [(#7419)](https://github.com/prowler-cloud/prowler/pull/7419) - Add Powershell to Microsoft365. [(#7331)](https://github.com/prowler-cloud/prowler/pull/7331) @@ -117,7 +125,6 @@ All notable changes to the **Prowler SDK** are documented in this file. - Add Microsoft User and User Credential auth to reports [(#7681)](https://github.com/prowler-cloud/prowler/pull/7681) ### Fixed - - Fix package name location in pyproject.toml while replicating for prowler-cloud. [(#7531)](https://github.com/prowler-cloud/prowler/pull/7531) - Remove cache in PyPI release action. [(#7532)](https://github.com/prowler-cloud/prowler/pull/7532) - Add the correct values for logger.info inside iam service. [(#7526)](https://github.com/prowler-cloud/prowler/pull/7526) @@ -138,7 +145,6 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [v5.5.1] (Prowler v5.5.1) ### Fixed - - Add default name to contacts in Azure Defender. [(#7483)](https://github.com/prowler-cloud/prowler/pull/7483) - 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) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index e668d5e09e..42d5e8584a 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to the **Prowler UI** are documented in this file. ## [v1.8.0] (Prowler v5.8.0) – Not released +### 🐞 Fixes + +- Fix sync between filter buttons and URL when filters change. [(#7928)](https://github.com/prowler-cloud/prowler/pull/7928) +- Improve heatmap perfomance. [(#7934)](https://github.com/prowler-cloud/prowler/pull/7934) + ### 🚀 Added - New profile page with details about the user and their roles. [(#7780)](https://github.com/prowler-cloud/prowler/pull/7780) @@ -13,6 +18,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Add compliance detail view: ENS [(#7853)](https://github.com/prowler-cloud/prowler/pull/7853) - Add compliance detail view: ISO [(#7897)](https://github.com/prowler-cloud/prowler/pull/7897) - Add compliance detail view: CIS [(#7913)](https://github.com/prowler-cloud/prowler/pull/7913) +- Add compliance detail view: AWS Well-Architected Framework [(#7925)](https://github.com/prowler-cloud/prowler/pull/7925) ### 🔄 Changed @@ -21,12 +27,19 @@ All notable changes to the **Prowler UI** are documented in this file. --- +## [v1.7.3] (Prowler v5.7.3) + +### 🐞 Fixes + +- Fix encrypted password typo in `formSchemas`. [(#7828)](https://github.com/prowler-cloud/prowler/pull/7828) + +--- + ## [v1.7.2] (Prowler v5.7.2) ### 🐞 Fixes - Download report behaviour updated to show feedback based on API response. [(#7758)](https://github.com/prowler-cloud/prowler/pull/7758) -- Compliace detail page, now available for ENS. [(#7853)](https://github.com/prowler-cloud/prowler/pull/7853) - Missing KISA and ProwlerThreat icons added to the compliance page. [(#7860)(https://github.com/prowler-cloud/prowler/pull/7860)] - Retrieve more than 10 scans in /compliance page. [(#7865)](https://github.com/prowler-cloud/prowler/pull/7865) - Improve CustomDropdownFilter component. [(#7868)(https://github.com/prowler-cloud/prowler/pull/7868)] diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 189a33531a..98a83d403b 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -24,7 +24,6 @@ import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance" import { ContentLayout } from "@/components/ui"; import { calculateCategoryHeatmapData, - calculateRegionHeatmapData, getComplianceMapper, } from "@/lib/compliance/commons"; import { ScanProps } from "@/types"; @@ -154,6 +153,7 @@ export default async function ComplianceDetail({ uniqueRegions={uniqueRegions} showSearch={false} framework={compliancetitle} + showProviders={false} /> @@ -192,16 +187,12 @@ const SSRComplianceContent = async ({ region, filter, logoPath, - uniqueRegions, - isRegionFiltered, }: { complianceId: string; scanId: string; region?: string; filter?: string; logoPath?: string; - uniqueRegions: string[]; - isRegionFiltered: boolean; }) => { if (!scanId) { return ( @@ -209,14 +200,7 @@ const SSRComplianceContent = async ({ - + @@ -236,20 +220,15 @@ const SSRComplianceContent = async ({ // Determine framework from the first attribute item const framework = attributesData?.data?.[0]?.attributes?.framework; const mapper = getComplianceMapper(framework); + + // Use the same data for both compliance view and heatmap const data = mapper.mapComplianceData( attributesData, requirementsData, filter, ); - // Calculate region heatmap data using already obtained data - const regionHeatmapData = await calculateRegionHeatmapData( - complianceId, - scanId, - uniqueRegions, - attributesData, - mapper, - ); + // Calculate category heatmap data const categoryHeatmapData = calculateCategoryHeatmapData(data); const totalRequirements: RequirementsTotals = data.reduce( @@ -277,12 +256,7 @@ const SSRComplianceContent = async ({ manual={totalRequirements.manual} /> - + diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index d22c4b2fe4..ae13888bdd 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -12,7 +12,7 @@ import { Accordion } from "@/components/ui/accordion/Accordion"; import { DataTable } from "@/components/ui/table"; import { createDict } from "@/lib"; import { getComplianceMapper } from "@/lib/compliance/commons"; -import { ComplianceId, Requirement } from "@/types/compliance"; +import { Requirement } from "@/types/compliance"; import { FindingProps, FindingsResponse } from "@/types/components"; interface ClientAccordionContentProps { @@ -32,7 +32,7 @@ export const ClientAccordionContent = ({ const [expandedFindings, setExpandedFindings] = useState([]); const searchParams = useSearchParams(); const pageNumber = searchParams.get("page") || "1"; - const complianceId = searchParams.get("complianceId") as ComplianceId; + const complianceId = searchParams.get("complianceId"); const defaultSort = "severity,status,-inserted_at"; const sort = searchParams.get("sort") || defaultSort; const loadedPageRef = useRef(null); @@ -116,8 +116,8 @@ export const ClientAccordionContent = ({ return (
{renderDetails()} -

- This requirement has no checks; therefore, there are no findings. +

+ ⚠️ This requirement has no checks; therefore, there are no findings.

); @@ -164,7 +164,11 @@ export const ClientAccordionContent = ({ ); } - return
There are no findings for this regions
; + return ( +
+ ⚠️ There are no findings for this regions +
+ ); }; return ( @@ -172,7 +176,7 @@ export const ClientAccordionContent = ({ {renderDetails()} {checks.length > 0 && ( -
+
{ return (
-
- {name} +
+ {name}
diff --git a/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx b/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx index 0f3f681b59..c9fa69aa8d 100644 --- a/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx +++ b/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx @@ -24,7 +24,7 @@ export const ComplianceAccordionTitle = ({
{label.charAt(0).toUpperCase() + label.slice(1)} diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 10a473236f..720c5acd77 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -71,9 +71,15 @@ export const ComplianceCard: React.FC = ({ return "success"; }; + const isPressable = + id.includes("ens") || + id.includes("iso") || + id.includes("cis_") || + id.includes("pillar"); + const navigateToDetail = () => { // We will unlock this while developing the rest of complainces. - if (!id.includes("ens") && !id.includes("iso") && !id.includes("cis_")) { + if (!isPressable) { return; } @@ -101,7 +107,7 @@ export const ComplianceCard: React.FC = ({ fullWidth isHoverable shadow="sm" - isPressable + isPressable={isPressable} onPress={navigateToDetail} > diff --git a/ui/components/compliance/compliance-charts/heatmap-chart.tsx b/ui/components/compliance/compliance-charts/heatmap-chart.tsx index d4cb3b3a0a..bf5474595f 100644 --- a/ui/components/compliance/compliance-charts/heatmap-chart.tsx +++ b/ui/components/compliance/compliance-charts/heatmap-chart.tsx @@ -1,15 +1,13 @@ "use client"; +import { cn } from "@nextui-org/react"; import { useTheme } from "next-themes"; import { useState } from "react"; -import { CategoryData, RegionData } from "@/types/compliance"; +import { CategoryData } from "@/types/compliance"; interface HeatmapChartProps { - regions: RegionData[]; categories?: CategoryData[]; - isRegionFiltered?: boolean; // Indicates if a region filter is active - filteredRegionName?: string; // Name of the filtered region } const getHeatmapColor = (percentage: number): string => { @@ -32,48 +30,32 @@ const capitalizeFirstLetter = (text: string): string => { ); }; -export const HeatmapChart = ({ - regions, - categories = [], - isRegionFiltered = false, -}: HeatmapChartProps) => { +export const HeatmapChart = ({ categories = [] }: HeatmapChartProps) => { const { theme } = useTheme(); - const [hoveredItem, setHoveredItem] = useState< - RegionData | CategoryData | null - >(null); + const [hoveredItem, setHoveredItem] = useState(null); const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); - // Determine what data to show and prepare it - const dataToShow = isRegionFiltered ? categories : regions; - const heatmapData = dataToShow + // Use categories data and prepare it + const heatmapData = categories .filter((item) => item.totalRequirements > 0) .sort((a, b) => b.failurePercentage - a.failurePercentage) .slice(0, 9); // Exactly 9 items for 3x3 grid // Check if there are no items with data - if (!dataToShow || dataToShow.length === 0 || heatmapData.length === 0) { - const noDataMessage = isRegionFiltered - ? "No category data available" - : "No regional data available"; - + if (!categories.length || heatmapData.length === 0) { return (

- {isRegionFiltered - ? "Categories Failure Rate" - : "Failure Rate by Region"} + Sections Failure Rate

-

{noDataMessage}

+

No category data available

); } - const handleMouseEnter = ( - item: RegionData | CategoryData, - event: React.MouseEvent, - ) => { + const handleMouseEnter = (item: CategoryData, event: React.MouseEvent) => { setHoveredItem(item); setMousePosition({ x: event.clientX, y: event.clientY }); }; @@ -90,19 +72,27 @@ export const HeatmapChart = ({

- {isRegionFiltered - ? "Categories Failure Rate" - : "Failure Rate by Region"} + Sections Failure Rate

-
- {/* 3x3 Grid */} -
+
+
{heatmapData.map((item) => (
-
+
- {isRegionFiltered - ? capitalizeFirstLetter(item.name) - : item.name} + {capitalizeFirstLetter(item.name)}
- {isRegionFiltered - ? capitalizeFirstLetter(hoveredItem.name) - : hoveredItem.name} + {capitalizeFirstLetter(hoveredItem.name)}
Failure Rate: {hoveredItem.failurePercentage}%
diff --git a/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx b/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx new file mode 100644 index 0000000000..35cc5c7114 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx @@ -0,0 +1,91 @@ +import Link from "next/link"; + +import { SeverityBadge } from "@/components/ui/table"; +import { Requirement } from "@/types/compliance"; + +export const AWSWellArchitectedCustomDetails = ({ + requirement, +}: { + requirement: Requirement; +}) => { + return ( +
+ {requirement.description && ( +
+

+ Description +

+

{requirement.description}

+
+ )} + + {requirement.well_architected_name && ( +
+

+ Best Practice +

+

{requirement.well_architected_name}

+
+ )} + + {requirement.well_architected_question_id && ( +
+

+ Question ID +

+

{requirement.well_architected_question_id}

+
+ )} + + {requirement.well_architected_practice_id && ( +
+

+ Practice ID +

+

{requirement.well_architected_practice_id}

+
+ )} + + {requirement.level_of_risk && ( +
+

+ Level of Risk +

+ +
+ )} + + {requirement.assessment_method && ( +
+

+ Assessment Method +

+

{requirement.assessment_method}

+
+ )} + + {requirement.implementation_guidance_url && ( +
+

+ Implementation Guidance +

+ + {requirement.implementation_guidance_url} + +
+ )} +
+ ); +}; diff --git a/ui/components/compliance/compliance-custom-details/cis-details.tsx b/ui/components/compliance/compliance-custom-details/cis-details.tsx index e4d99db68f..ebb04e48d8 100644 --- a/ui/components/compliance/compliance-custom-details/cis-details.tsx +++ b/ui/components/compliance/compliance-custom-details/cis-details.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import ReactMarkdown from "react-markdown"; import { Requirement } from "@/types/compliance"; @@ -83,7 +84,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { {/* Prettier -> "plugins": ["prettier-plugin-tailwindcss"] is not ready yet to "prose": */} {/* eslint-disable-next-line */} -
+
{requirement.remediation_procedure}
@@ -96,7 +97,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { Audit Procedure {/* eslint-disable-next-line */} -
+
{requirement.audit_procedure}
@@ -131,14 +132,14 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { {processReferences(requirement.references).map( (url: string, index: number) => ( ), )} diff --git a/ui/components/compliance/compliance-header/compliance-header.tsx b/ui/components/compliance/compliance-header/compliance-header.tsx index e6bfe0dd2d..abe54adf11 100644 --- a/ui/components/compliance/compliance-header/compliance-header.tsx +++ b/ui/components/compliance/compliance-header/compliance-header.tsx @@ -14,6 +14,7 @@ interface ComplianceHeaderProps { showSearch?: boolean; showRegionFilter?: boolean; framework?: string; // Framework name to show specific filters + showProviders?: boolean; } export const ComplianceHeader = ({ @@ -22,6 +23,7 @@ export const ComplianceHeader = ({ showSearch = true, showRegionFilter = true, framework, + showProviders = true, }: ComplianceHeaderProps) => { const frameworkFilters = []; @@ -56,10 +58,10 @@ export const ComplianceHeader = ({ <> {showSearch && } - + {showProviders && } {allFilters.length > 0 && ( <> - + {showProviders && } )} diff --git a/ui/components/compliance/compliance-header/compliance-scan-info.tsx b/ui/components/compliance/compliance-header/compliance-scan-info.tsx index 5e422ffee2..732c2b09fa 100644 --- a/ui/components/compliance/compliance-header/compliance-scan-info.tsx +++ b/ui/components/compliance/compliance-header/compliance-scan-info.tsx @@ -9,10 +9,10 @@ interface ComplianceScanInfoProps { providerInfo: { provider: ProviderType; alias?: string; - uid: string; + uid?: string; }; - attributes: { - name: string; + attributes?: { + name?: string; completed_at: string; }; }; @@ -29,13 +29,17 @@ export const ComplianceScanInfo: React.FC = ({ entityId={scan.providerInfo.uid} hideCopyButton /> - -
-

- {scan.attributes.name || "- -"} -

- -
+ {scan.attributes && ( + <> + +
+

+ {scan.attributes.name || "- -"} +

+ +
+ + )}
); }; diff --git a/ui/components/ui/accordion/Accordion.tsx b/ui/components/ui/accordion/Accordion.tsx index 1bbcbc9ed8..8117d5eea6 100644 --- a/ui/components/ui/accordion/Accordion.tsx +++ b/ui/components/ui/accordion/Accordion.tsx @@ -138,7 +138,7 @@ export const Accordion = ({ indicator={} classNames={{ base: index === 0 || index === 1 ? "my-1" : "my-1", - title: "text-sm font-medium max-w-full overflow-hidden truncate", + title: "text-sm", subtitle: "text-xs text-gray-500", trigger: "py-2 px-2 rounded-lg data-[hover=true]:bg-gray-50 dark:data-[hover=true]:bg-gray-800/50 w-full flex items-center", diff --git a/ui/components/ui/custom/custom-dropdown-filter.tsx b/ui/components/ui/custom/custom-dropdown-filter.tsx index 8cd0d7dd75..be27e6ef7a 100644 --- a/ui/components/ui/custom/custom-dropdown-filter.tsx +++ b/ui/components/ui/custom/custom-dropdown-filter.tsx @@ -44,57 +44,75 @@ export const CustomDropdownFilter = ({ return filterParam ? filterParam.split(",") : []; }, [searchParams, filter?.key]); - // Sync URL state with component state - useEffect(() => { - if (activeFilterValue.length > 0) { - const newSelection = new Set(activeFilterValue); + // Helper function to handle URL filter values sync + const syncWithActiveFilters = useCallback(() => { + const newSelection = new Set(activeFilterValue); + if ( + newSelection.size === filterValues.length && + filter?.showSelectAll !== false + ) { + newSelection.add("all"); + } + setGroupSelected(newSelection); + }, [activeFilterValue, filterValues, filter?.showSelectAll]); + + const resetComponentState = useCallback(() => { + setGroupSelected(new Set()); + hasUserInteracted.current = false; + }, []); + + const applyDefaultValues = useCallback(() => { + if (filter?.defaultToSelectAll && filterValues.length > 0) { + const newSelection = new Set(filterValues); + if (filter?.showSelectAll !== false) { + newSelection.add("all"); + } + setGroupSelected(newSelection); + } else if (filter?.defaultValues && filter.defaultValues.length > 0) { + const validDefaultValues = filter.defaultValues.filter((value) => + filterValues.includes(value), + ); + const newSelection = new Set(validDefaultValues); + + // Add "all" if all items are selected and showSelectAll is not false if ( - newSelection.size === filterValues.length && + validDefaultValues.length === filterValues.length && filter?.showSelectAll !== false ) { newSelection.add("all"); } setGroupSelected(newSelection); - } else if (!hasUserInteracted.current) { - // Handle default behavior when no URL params exist - // Only apply defaults if user hasn't interacted yet - // Only set visual state, don't trigger URL changes automatically - if (filter?.defaultToSelectAll && filterValues.length > 0) { - const newSelection = new Set(filterValues); - if (filter?.showSelectAll !== false) { - newSelection.add("all"); - } - setGroupSelected(newSelection); - // DON'T notify parent automatically - wait for user interaction - } else if (filter?.defaultValues && filter.defaultValues.length > 0) { - // Handle specific default values - const validDefaultValues = filter.defaultValues.filter((value) => - filterValues.includes(value), - ); - const newSelection = new Set(validDefaultValues); - - // Add "all" if all items are selected and showSelectAll is not false - if ( - validDefaultValues.length === filterValues.length && - filter?.showSelectAll !== false - ) { - newSelection.add("all"); - } - - setGroupSelected(newSelection); - // DON'T notify parent automatically - wait for user interaction - } else { - setGroupSelected(new Set()); - } + } else { + setGroupSelected(new Set()); } }, [ - activeFilterValue, filterValues, filter?.defaultToSelectAll, filter?.defaultValues, filter?.showSelectAll, ]); + useEffect(() => { + const hasActiveFilters = activeFilterValue.length > 0; + const userHasInteracted = hasUserInteracted.current; + + if (hasActiveFilters) { + // URL has filter values - sync component state with URL + syncWithActiveFilters(); + } else if (userHasInteracted) { + // URL has no filters but user had interacted - reset component state + resetComponentState(); + } else { + // URL has no filters and user hasn't interacted - apply defaults + applyDefaultValues(); + } + }, [ + activeFilterValue, + syncWithActiveFilters, + resetComponentState, + applyDefaultValues, + ]); + const updateSelection = useCallback( (newValues: string[]) => { // Mark that user has interacted with the filter diff --git a/ui/hooks/use-url-filters.ts b/ui/hooks/use-url-filters.ts index c29de37327..83bd225791 100644 --- a/ui/hooks/use-url-filters.ts +++ b/ui/hooks/use-url-filters.ts @@ -16,8 +16,10 @@ export const useUrlFilters = () => { (key: string, value: string | string[] | null) => { const params = new URLSearchParams(searchParams.toString()); - // Always reset page to 1 when a filter is applied - params.set("page", "1"); + // Only reset page to 1 if page parameter already exists + if (params.has("page")) { + params.set("page", "1"); + } const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; @@ -40,7 +42,11 @@ export const useUrlFilters = () => { const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; params.delete(filterKey); - params.set("page", "1"); + + // Only reset page to 1 if page parameter already exists + if (params.has("page")) { + params.set("page", "1"); + } router.push(`${pathname}?${params.toString()}`, { scroll: false }); }, diff --git a/ui/lib/compliance/aws-well-architected.tsx b/ui/lib/compliance/aws-well-architected.tsx new file mode 100644 index 0000000000..d6fd809987 --- /dev/null +++ b/ui/lib/compliance/aws-well-architected.tsx @@ -0,0 +1,221 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { + AttributesData, + AWSWellArchitectedAttributesMetadata, + Framework, + Requirement, + RequirementItemData, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +export const mapComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirements = requirementsData?.data || []; + + // Create a map for quick lookup of requirements by id + const requirementsMap = new Map(); + requirements.forEach((req: RequirementItemData) => { + requirementsMap.set(req.id, req); + }); + + const frameworks: Framework[] = []; + + // Process attributes and merge with requirements data + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes + ?.metadata as unknown as AWSWellArchitectedAttributesMetadata[]; + const attrs = metadataArray?.[0]; + if (!attrs) continue; + + // Get corresponding requirement data + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const frameworkName = attributeItem.attributes.framework; + const sectionName = attrs.Section || ""; + const subSectionName = attrs.SubSection || ""; + const description = attributeItem.attributes.description; + const status = requirementData.attributes.status || ""; + const checks = attributeItem.attributes.attributes.check_ids || []; + const requirementName = id; + + if (!sectionName || !subSectionName) { + continue; + } + + // Find or create framework + let framework = frameworks.find((f) => f.name === frameworkName); + if (!framework) { + framework = { + name: frameworkName, + pass: 0, + fail: 0, + manual: 0, + categories: [], + }; + frameworks.push(framework); + } + + // Find or create category (Section) + let category = framework.categories.find((c) => c.name === sectionName); + if (!category) { + category = { + name: sectionName, + pass: 0, + fail: 0, + manual: 0, + controls: [], + }; + framework.categories.push(category); + } + + // Find or create control (SubSection) + let control = category.controls.find((c) => c.label === subSectionName); + if (!control) { + control = { + label: subSectionName, + pass: 0, + fail: 0, + manual: 0, + requirements: [], + }; + category.controls.push(control); + } + + // Create requirement + const finalStatus: RequirementStatus = status as RequirementStatus; + const requirement: Requirement = { + name: requirementName, + description: description, + status: finalStatus, + check_ids: checks, + pass: finalStatus === "PASS" ? 1 : 0, + fail: finalStatus === "FAIL" ? 1 : 0, + manual: finalStatus === "MANUAL" ? 1 : 0, + well_architected_name: attrs.Name, + well_architected_question_id: attrs.WellArchitectedQuestionId, + well_architected_practice_id: attrs.WellArchitectedPracticeId, + level_of_risk: attrs.LevelOfRisk, + assessment_method: attrs.AssessmentMethod, + implementation_guidance_url: attrs.ImplementationGuidanceUrl, + }; + + control.requirements.push(requirement); + } + + // Calculate counters + frameworks.forEach((framework) => { + framework.pass = 0; + framework.fail = 0; + framework.manual = 0; + + framework.categories.forEach((category) => { + category.pass = 0; + category.fail = 0; + category.manual = 0; + + category.controls.forEach((control) => { + control.pass = 0; + control.fail = 0; + control.manual = 0; + + control.requirements.forEach((requirement) => { + if (requirement.status === "MANUAL") { + control.manual++; + } else if (requirement.status === "PASS") { + control.pass++; + } else if (requirement.status === "FAIL") { + control.fail++; + } + }); + + category.pass += control.pass; + category.fail += control.fail; + category.manual += control.manual; + }); + + framework.pass += category.pass; + framework.fail += category.fail; + framework.manual += category.manual; + }); + }); + + return frameworks; +}; + +export const toAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + return data.flatMap((framework) => + framework.categories.map((category) => { + return { + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.map((control, i: number) => { + return { + key: `${framework.name}-${category.name}-control-${i}`, + title: ( + + ), + content: "", + items: control.requirements.map((requirement, j: number) => { + const itemKey = `${framework.name}-${category.name}-control-${i}-req-${j}`; + + return { + key: itemKey, + title: ( + + ), + content: ( + + ), + items: [], + }; + }), + isDisabled: + control.pass === 0 && control.fail === 0 && control.manual === 0, + }; + }), + }; + }), + ); +}; diff --git a/ui/lib/compliance/commons.ts b/ui/lib/compliance/commons.ts index 6d0c3c7779..c62933b635 100644 --- a/ui/lib/compliance/commons.ts +++ b/ui/lib/compliance/commons.ts @@ -1,5 +1,6 @@ import React from "react"; +import { AWSWellArchitectedCustomDetails } from "@/components/compliance/compliance-custom-details/aws-well-architected-details"; import { CISCustomDetails } from "@/components/compliance/compliance-custom-details/cis-details"; import { ENSCustomDetails } from "@/components/compliance/compliance-custom-details/ens-details"; import { ISOCustomDetails } from "@/components/compliance/compliance-custom-details/iso-details"; @@ -9,11 +10,14 @@ import { CategoryData, FailedSection, Framework, - RegionData, Requirement, RequirementsData, } from "@/types/compliance"; +import { + mapComplianceData as mapAWSWellArchitectedComplianceData, + toAccordionItems as toAWSWellArchitectedAccordionItems, +} from "./aws-well-architected"; import { mapComplianceData as mapCISComplianceData, toAccordionItems as toCISAccordionItems, @@ -101,6 +105,20 @@ const complianceMappers: Record = { getDetailsComponent: (requirement: Requirement) => React.createElement(CISCustomDetails, { requirement }), }, + "AWS-Well-Architected-Framework-Security-Pillar": { + mapComplianceData: mapAWSWellArchitectedComplianceData, + toAccordionItems: toAWSWellArchitectedAccordionItems, + getTopFailedSections, + getDetailsComponent: (requirement: Requirement) => + React.createElement(AWSWellArchitectedCustomDetails, { requirement }), + }, + "AWS-Well-Architected-Framework-Reliability-Pillar": { + mapComplianceData: mapAWSWellArchitectedComplianceData, + toAccordionItems: toAWSWellArchitectedAccordionItems, + getTopFailedSections, + getDetailsComponent: (requirement: Requirement) => + React.createElement(AWSWellArchitectedCustomDetails, { requirement }), + }, }; // Default mapper (fallback to ENS for backward compatibility) @@ -119,85 +137,6 @@ export const getComplianceMapper = (framework?: string): ComplianceMapper => { return complianceMappers[framework] || defaultMapper; }; -export const calculateRegionHeatmapData = async ( - complianceId: string, - scanId: string, - uniqueRegions: string[], - attributesData: AttributesData, - mapper: ComplianceMapper, -): Promise => { - if (!complianceId || !scanId || !uniqueRegions?.length) { - return []; - } - - try { - const { getComplianceRequirements } = await import("@/actions/compliances"); - - // Get data for each region in parallel - const regionPromises = uniqueRegions.map(async (region) => { - try { - // Only need to fetch requirements data per region - const regionRequirementsData = await getComplianceRequirements({ - complianceId, - scanId, - region, // Filter by specific region - }); - - // Map the data using the provided mapper - const mappedData = mapper.mapComplianceData( - attributesData, - regionRequirementsData, - ); - - // Calculate totals for this region - const regionTotals = mappedData.reduce( - (acc, framework) => ({ - pass: acc.pass + framework.pass, - fail: acc.fail + framework.fail, - manual: acc.manual + framework.manual, - }), - { pass: 0, fail: 0, manual: 0 }, - ); - - const totalRequirements = - regionTotals.pass + regionTotals.fail + regionTotals.manual; - const failurePercentage = - totalRequirements > 0 - ? Math.round((regionTotals.fail / totalRequirements) * 100) - : 0; - - return { - name: region, - failurePercentage, - totalRequirements, - failedRequirements: regionTotals.fail, - }; - } catch (error) { - console.error(`Error fetching data for region ${region}:`, error); - return { - name: region, - failurePercentage: 0, - totalRequirements: 0, - failedRequirements: 0, - }; - } - }); - - const regionData = await Promise.all(regionPromises); - - // Filter, sort and limit to top 9 regions for 3x3 grid - const filteredData = regionData - .filter((region) => region.totalRequirements > 0) - .sort((a, b) => b.failurePercentage - a.failurePercentage) - .slice(0, 9); - - return filteredData; - } catch (error) { - console.error("Error calculating region heatmap data:", error); - return []; - } -}; - export const calculateCategoryHeatmapData = ( complianceData: Framework[], ): CategoryData[] => { diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index 4fa4cf7e1a..994c90d200 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -1,16 +1,5 @@ export type RequirementStatus = "PASS" | "FAIL" | "MANUAL" | "No findings"; -export type ComplianceId = - | "ens_rd2022_aws" - | "iso27001_2013_aws" - | "iso27001_2022_aws" - | "cis_1.4_aws" - | "cis_1.5_aws" - | "cis_2.0_aws" - | "cis_3.0_aws" - | "cis_4.0_aws" - | "cis_5.0_aws"; - export interface CompliancesOverview { data: ComplianceOverviewData[]; } @@ -112,6 +101,18 @@ export interface CISAttributesMetadata { References: string; } +export interface AWSWellArchitectedAttributesMetadata { + Name: string; + WellArchitectedQuestionId: string; + WellArchitectedPracticeId: string; + Section: string; + SubSection: string; + LevelOfRisk: string; + AssessmentMethod: string; + Description: string; + ImplementationGuidanceUrl: string; +} + export interface AttributesItemData { type: "compliance-requirements-attributes"; id: string; @@ -120,7 +121,11 @@ export interface AttributesItemData { version: string; description: string; attributes: { - metadata: ENSAttributesMetadata[] | ISO27001AttributesMetadata[]; + metadata: + | ENSAttributesMetadata[] + | ISO27001AttributesMetadata[] + | CISAttributesMetadata[] + | AWSWellArchitectedAttributesMetadata[]; check_ids: string[]; }; };