mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-13 06:54:16 +00:00
Merge branch 'master' into PRWLR-7160-findings-page-scan-id-filter-improvement
# Conflicts: # ui/components/ui/custom/custom-dropdown-filter.tsx
This commit is contained in:
commit
5cb9bb53e4
33 files changed
+622
-350
No files matched your search
+9
-4
@@ -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)
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ DJANGO_GUID = {
|
||||
}
|
||||
|
||||
DATABASE_ROUTERS = ["api.db_router.MainRouter"]
|
||||
POSTGRES_EXTRA_DB_BACKEND_BASE = "database_backend"
|
||||
|
||||
|
||||
# Password validation
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -138,6 +138,10 @@ Follow these steps to assign the permissions:
|
||||
|
||||

|
||||
|
||||
The final result of permission assignment should be this:
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Assign required roles to your user
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
+12
-6
@@ -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)
|
||||
|
||||
+14
-1
@@ -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)]
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<Suspense
|
||||
@@ -175,11 +175,6 @@ export default async function ComplianceDetail({
|
||||
region={regionFilter}
|
||||
filter={cisProfileFilter}
|
||||
logoPath={logoPath}
|
||||
uniqueRegions={uniqueRegions}
|
||||
isRegionFiltered={
|
||||
!!regionFilter &&
|
||||
regionFilter.split(",").length < uniqueRegions.length
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
@@ -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 ({
|
||||
<ChartsWrapper logoPath={logoPath}>
|
||||
<PieChart pass={0} fail={0} manual={0} />
|
||||
<BarChart sections={[]} />
|
||||
<HeatmapChart
|
||||
regions={[]}
|
||||
categories={[]}
|
||||
isRegionFiltered={
|
||||
!!region && region.split(",").length < uniqueRegions.length
|
||||
}
|
||||
filteredRegionName={region}
|
||||
/>
|
||||
<HeatmapChart categories={[]} />
|
||||
</ChartsWrapper>
|
||||
<ClientAccordionWrapper items={[]} defaultExpandedKeys={[]} />
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
<BarChart sections={topFailedSections} />
|
||||
<HeatmapChart
|
||||
regions={regionHeatmapData}
|
||||
categories={categoryHeatmapData}
|
||||
isRegionFiltered={isRegionFiltered}
|
||||
filteredRegionName={region}
|
||||
/>
|
||||
<HeatmapChart categories={categoryHeatmapData} />
|
||||
</ChartsWrapper>
|
||||
|
||||
<Spacer className="h-1 w-full rounded-full bg-gray-200 dark:bg-gray-800" />
|
||||
|
||||
@@ -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<FindingProps[]>([]);
|
||||
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<string | null>(null);
|
||||
@@ -116,8 +116,8 @@ export const ClientAccordionContent = ({
|
||||
return (
|
||||
<div className="w-full">
|
||||
{renderDetails()}
|
||||
<p className="text-sm text-gray-500">
|
||||
This requirement has no checks; therefore, there are no findings.
|
||||
<p className="mt-2 text-sm font-medium text-gray-800">
|
||||
⚠️ This requirement has no checks; therefore, there are no findings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -164,7 +164,11 @@ export const ClientAccordionContent = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <div>There are no findings for this regions</div>;
|
||||
return (
|
||||
<div className="text-sm font-medium text-gray-800">
|
||||
⚠️ There are no findings for this regions
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -172,7 +176,7 @@ export const ClientAccordionContent = ({
|
||||
{renderDetails()}
|
||||
|
||||
{checks.length > 0 && (
|
||||
<div className="mb-6 mt-2">
|
||||
<div className="mb-2 mt-2">
|
||||
<Accordion
|
||||
items={accordionChecksItems}
|
||||
variant="light"
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ export const ComplianceAccordionRequirementTitle = ({
|
||||
}: ComplianceAccordionRequirementTitleProps) => {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex w-3/4 items-center gap-1">
|
||||
<span className="whitespace-nowrap text-sm">{name}</span>
|
||||
<div className="flex w-5/6 items-center gap-1">
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
<StatusFindingBadge status={status} />
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ComplianceAccordionTitle = ({
|
||||
<div className="flex flex-col items-start justify-between gap-1 md:flex-row md:items-center md:gap-2">
|
||||
<div className="overflow-hidden md:min-w-0 md:flex-1">
|
||||
<span
|
||||
className="block w-full overflow-hidden truncate text-ellipsis text-sm"
|
||||
className="block max-w-[600px] overflow-hidden truncate text-ellipsis text-sm"
|
||||
title={label}
|
||||
>
|
||||
{label.charAt(0).toUpperCase() + label.slice(1)}
|
||||
|
||||
@@ -71,9 +71,15 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
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<ComplianceCardProps> = ({
|
||||
fullWidth
|
||||
isHoverable
|
||||
shadow="sm"
|
||||
isPressable
|
||||
isPressable={isPressable}
|
||||
onPress={navigateToDetail}
|
||||
>
|
||||
<CardBody className="flex flex-row items-center justify-between space-x-4 dark:bg-prowler-blue-800">
|
||||
|
||||
@@ -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<CategoryData | null>(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 (
|
||||
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[400px]">
|
||||
<h3 className="whitespace-nowrap text-xs font-semibold uppercase tracking-wide">
|
||||
{isRegionFiltered
|
||||
? "Categories Failure Rate"
|
||||
: "Failure Rate by Region"}
|
||||
Sections Failure Rate
|
||||
</h3>
|
||||
<div className="flex h-[320px] w-full items-center justify-center">
|
||||
<p className="text-sm text-gray-500">{noDataMessage}</p>
|
||||
<p className="text-sm text-gray-500">No category data available</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = ({
|
||||
<div className="flex h-[320px] w-[400px] flex-col items-center justify-between lg:w-[400px]">
|
||||
<div>
|
||||
<h3 className="whitespace-nowrap text-xs font-semibold uppercase tracking-wide">
|
||||
{isRegionFiltered
|
||||
? "Categories Failure Rate"
|
||||
: "Failure Rate by Region"}
|
||||
Sections Failure Rate
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="h-full w-full p-4">
|
||||
{/* 3x3 Grid */}
|
||||
<div className="grid h-full w-full grid-cols-3 gap-1">
|
||||
<div className="h-full w-full p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full w-full gap-1",
|
||||
heatmapData.length < 3 ? "grid-cols-1" : "grid-cols-3",
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows:
|
||||
heatmapData.length < 3
|
||||
? `repeat(${heatmapData.length}, ${heatmapData.length}fr)`
|
||||
: `repeat(${Math.min(Math.ceil(heatmapData.length / 3), 3)}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{heatmapData.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="flex items-center justify-center rounded border"
|
||||
className="flex items-center justify-center rounded border p-1"
|
||||
style={{
|
||||
backgroundColor: getHeatmapColor(item.failurePercentage),
|
||||
borderColor: theme === "dark" ? "#374151" : "#e5e7eb",
|
||||
@@ -111,16 +101,15 @@ export const HeatmapChart = ({
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="w-full px-1 text-center">
|
||||
<div
|
||||
className="text-xs font-semibold"
|
||||
className="truncate text-xs font-semibold"
|
||||
style={{
|
||||
color: theme === "dark" ? "#ffffff" : "#000000",
|
||||
}}
|
||||
title={capitalizeFirstLetter(item.name)}
|
||||
>
|
||||
{isRegionFiltered
|
||||
? capitalizeFirstLetter(item.name)
|
||||
: item.name}
|
||||
{capitalizeFirstLetter(item.name)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs"
|
||||
@@ -148,9 +137,7 @@ export const HeatmapChart = ({
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 font-semibold">
|
||||
{isRegionFiltered
|
||||
? capitalizeFirstLetter(hoveredItem.name)
|
||||
: hoveredItem.name}
|
||||
{capitalizeFirstLetter(hoveredItem.name)}
|
||||
</div>
|
||||
<div>Failure Rate: {hoveredItem.failurePercentage}%</div>
|
||||
<div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
{requirement.description && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Description
|
||||
</h4>
|
||||
<p className="text-sm">{requirement.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.well_architected_name && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Best Practice
|
||||
</h4>
|
||||
<p className="text-sm">{requirement.well_architected_name}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.well_architected_question_id && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Question ID
|
||||
</h4>
|
||||
<p className="text-sm">{requirement.well_architected_question_id}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.well_architected_practice_id && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Practice ID
|
||||
</h4>
|
||||
<p className="text-sm">{requirement.well_architected_practice_id}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.level_of_risk && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Level of Risk
|
||||
</h4>
|
||||
<SeverityBadge
|
||||
severity={
|
||||
requirement.level_of_risk.toString().toLowerCase() as
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.assessment_method && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Assessment Method
|
||||
</h4>
|
||||
<p className="text-sm">{requirement.assessment_method}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.implementation_guidance_url && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
|
||||
Implementation Guidance
|
||||
</h4>
|
||||
<Link
|
||||
href={requirement.implementation_guidance_url as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all text-sm text-blue-600 underline hover:text-blue-800"
|
||||
>
|
||||
{requirement.implementation_guidance_url}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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) => {
|
||||
</h4>
|
||||
{/* Prettier -> "plugins": ["prettier-plugin-tailwindcss"] is not ready yet to "prose": */}
|
||||
{/* eslint-disable-next-line */}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<ReactMarkdown>{requirement.remediation_procedure}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +97,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => {
|
||||
Audit Procedure
|
||||
</h4>
|
||||
{/* eslint-disable-next-line */}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<ReactMarkdown>{requirement.audit_procedure}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,14 +132,14 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => {
|
||||
{processReferences(requirement.references).map(
|
||||
(url: string, index: number) => (
|
||||
<div key={index}>
|
||||
<a
|
||||
<Link
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all text-blue-600 underline hover:text-blue-800"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -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 && <FilterControls search />}
|
||||
<Spacer y={8} />
|
||||
<DataCompliance scans={scans} />
|
||||
{showProviders && <DataCompliance scans={scans} />}
|
||||
{allFilters.length > 0 && (
|
||||
<>
|
||||
<Spacer y={8} />
|
||||
{showProviders && <Spacer y={8} />}
|
||||
<DataTableFilterCustom filters={allFilters} defaultOpen={true} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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<ComplianceScanInfoProps> = ({
|
||||
entityId={scan.providerInfo.uid}
|
||||
hideCopyButton
|
||||
/>
|
||||
<Divider orientation="vertical" className="mx-2 h-6" />
|
||||
<div className="flex flex-col items-start">
|
||||
<p className="text-xs text-default-500">
|
||||
{scan.attributes.name || "- -"}
|
||||
</p>
|
||||
<DateWithTime inline dateTime={scan.attributes.completed_at} />
|
||||
</div>
|
||||
{scan.attributes && (
|
||||
<>
|
||||
<Divider orientation="vertical" className="mx-2 h-6" />
|
||||
<div className="flex flex-col items-start">
|
||||
<p className="text-xs text-default-500">
|
||||
{scan.attributes.name || "- -"}
|
||||
</p>
|
||||
<DateWithTime inline dateTime={scan.attributes.completed_at} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -138,7 +138,7 @@ export const Accordion = ({
|
||||
indicator={<ChevronDown className="text-gray-500" />}
|
||||
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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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<string, RequirementItemData>();
|
||||
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: (
|
||||
<ComplianceAccordionTitle
|
||||
label={category.name}
|
||||
pass={category.pass}
|
||||
fail={category.fail}
|
||||
manual={category.manual}
|
||||
isParentLevel={true}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: category.controls.map((control, i: number) => {
|
||||
return {
|
||||
key: `${framework.name}-${category.name}-control-${i}`,
|
||||
title: (
|
||||
<ComplianceAccordionTitle
|
||||
label={control.label}
|
||||
pass={control.pass}
|
||||
fail={control.fail}
|
||||
manual={control.manual}
|
||||
/>
|
||||
),
|
||||
content: "",
|
||||
items: control.requirements.map((requirement, j: number) => {
|
||||
const itemKey = `${framework.name}-${category.name}-control-${i}-req-${j}`;
|
||||
|
||||
return {
|
||||
key: itemKey,
|
||||
title: (
|
||||
<ComplianceAccordionRequirementTitle
|
||||
type=""
|
||||
name={
|
||||
(requirement.well_architected_name as string) ||
|
||||
requirement.name
|
||||
}
|
||||
status={requirement.status as FindingStatus}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
<ClientAccordionContent
|
||||
requirement={requirement}
|
||||
scanId={scanId || ""}
|
||||
framework={framework.name}
|
||||
disableFindings={
|
||||
requirement.check_ids.length === 0 &&
|
||||
requirement.manual === 0
|
||||
}
|
||||
/>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
}),
|
||||
isDisabled:
|
||||
control.pass === 0 && control.fail === 0 && control.manual === 0,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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<string, ComplianceMapper> = {
|
||||
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<RegionData[]> => {
|
||||
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[] => {
|
||||
|
||||
+17
-12
@@ -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[];
|
||||
};
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user