diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 937c090f85..dd3446550a 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -6,28 +6,38 @@ All notable changes to the **Prowler API** are documented in this file.
### Added
- Support GCP Service Account key. [(#7824)](https://github.com/prowler-cloud/prowler/pull/7824)
+- 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)
+
+### Fixed
+- Fixed transaction persistence with RLS operations [(#7916)](https://github.com/prowler-cloud/prowler/pull/7916).
+
+---
+
## [v1.8.2] (Prowler v5.7.2)
### Fixed
- Fixed task lookup to use task_kwargs instead of task_args for scan report resolution. [(#7830)](https://github.com/prowler-cloud/prowler/pull/7830)
- Fixed Kubernetes UID validation to allow valid context names [(#7871)](https://github.com/prowler-cloud/prowler/pull/7871)
- Fixed a race condition when creating background tasks [(#7876)](https://github.com/prowler-cloud/prowler/pull/7876).
+- Fixed an error when modifying or retrieving tenants due to missing user UUID in transaction context [(#7890)](https://github.com/prowler-cloud/prowler/pull/7890).
---
## [v1.8.1] (Prowler v5.7.1)
### Fixed
-- Added database index to improve performance on finding lookup. [(#7800)](https://github.com/prowler-cloud/prowler/pull/7800)
+- Added database index to improve performance on finding lookup [(#7800)](https://github.com/prowler-cloud/prowler/pull/7800).
---
diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py
index 3e965482df..54b020597f 100644
--- a/api/src/backend/api/base_views.py
+++ b/api/src/backend/api/base_views.py
@@ -1,5 +1,4 @@
from django.core.exceptions import ObjectDoesNotExist
-from django.db import transaction
from rest_framework import permissions
from rest_framework.exceptions import NotAuthenticated
from rest_framework.filters import SearchFilter
@@ -47,11 +46,9 @@ class BaseViewSet(ModelViewSet):
class BaseRLSViewSet(BaseViewSet):
- def dispatch(self, request, *args, **kwargs):
- with transaction.atomic():
- return super().dispatch(request, *args, **kwargs)
-
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,9 +58,19 @@ class BaseRLSViewSet(BaseViewSet):
if tenant_id is None:
raise NotAuthenticated("Tenant ID is not present in token")
- with rls_transaction(tenant_id):
- self.request.tenant_id = tenant_id
- return super().initial(request, *args, **kwargs)
+ self.request.tenant_id = tenant_id
+
+ self._rls_cm = rls_transaction(tenant_id)
+ self._rls_cm.__enter__()
+
+ def finalize_response(self, request, response, *args, **kwargs):
+ response = super().finalize_response(request, response, *args, **kwargs)
+
+ if hasattr(self, "_rls_cm"):
+ self._rls_cm.__exit__(None, None, None)
+ del self._rls_cm
+
+ return response
def get_serializer_context(self):
context = super().get_serializer_context()
@@ -73,8 +80,7 @@ class BaseRLSViewSet(BaseViewSet):
class BaseTenantViewset(BaseViewSet):
def dispatch(self, request, *args, **kwargs):
- with transaction.atomic():
- tenant = super().dispatch(request, *args, **kwargs)
+ tenant = super().dispatch(request, *args, **kwargs)
try:
# If the request is a POST, create the admin role
@@ -109,16 +115,8 @@ class BaseTenantViewset(BaseViewSet):
pass # Tenant might not exist, handle gracefully
def initial(self, request, *args, **kwargs):
- if (
- request.resolver_match.url_name != "tenant-detail"
- and request.method != "DELETE"
- ):
- user_id = str(request.user.id)
+ super().initial(request, *args, **kwargs)
- with rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR):
- return super().initial(request, *args, **kwargs)
-
- # TODO: DRY this when we have time
if request.auth is None:
raise NotAuthenticated
@@ -126,20 +124,28 @@ class BaseTenantViewset(BaseViewSet):
if tenant_id is None:
raise NotAuthenticated("Tenant ID is not present in token")
- with rls_transaction(tenant_id):
- self.request.tenant_id = tenant_id
- return super().initial(request, *args, **kwargs)
+ user_id = str(request.user.id)
+
+ self._rls_cm = rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR)
+ self._rls_cm.__enter__()
+
+ def finalize_response(self, request, response, *args, **kwargs):
+ response = super().finalize_response(request, response, *args, **kwargs)
+
+ if hasattr(self, "_rls_cm"):
+ self._rls_cm.__exit__(None, None, None)
+ del self._rls_cm
+
+ return response
class BaseUserViewset(BaseViewSet):
- def dispatch(self, request, *args, **kwargs):
- with transaction.atomic():
- return super().dispatch(request, *args, **kwargs)
-
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
@@ -147,6 +153,16 @@ class BaseUserViewset(BaseViewSet):
if tenant_id is None:
raise NotAuthenticated("Tenant ID is not present in token")
- with rls_transaction(tenant_id):
- self.request.tenant_id = tenant_id
- return super().initial(request, *args, **kwargs)
+ self.request.tenant_id = tenant_id
+
+ self._rls_cm = rls_transaction(tenant_id)
+ self._rls_cm.__enter__()
+
+ def finalize_response(self, request, response, *args, **kwargs):
+ response = super().finalize_response(request, response, *args, **kwargs)
+
+ if hasattr(self, "_rls_cm"):
+ self._rls_cm.__exit__(None, None, None)
+ del self._rls_cm
+
+ return response
diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py
index ca98b6b592..d163a02fd3 100644
--- a/api/src/backend/api/db_utils.py
+++ b/api/src/backend/api/db_utils.py
@@ -1,3 +1,4 @@
+import re
import secrets
import uuid
from contextlib import contextmanager
@@ -152,6 +153,28 @@ def delete_related_daily_task(provider_id: str):
PeriodicTask.objects.filter(name=task_name).delete()
+def create_objects_in_batches(
+ tenant_id: str, model, objects: list, batch_size: int = 500
+):
+ """
+ Bulk-create model instances in repeated, per-tenant RLS transactions.
+
+ All chunks execute in their own transaction, so no single transaction
+ grows too large.
+
+ Args:
+ tenant_id (str): UUID string of the tenant under which to set RLS.
+ model: Django model class whose `.objects.bulk_create()` will be called.
+ objects (list): List of model instances (unsaved) to bulk-create.
+ batch_size (int): Maximum number of objects per bulk_create call.
+ """
+ total = len(objects)
+ for i in range(0, total, batch_size):
+ chunk = objects[i : i + batch_size]
+ with rls_transaction(value=tenant_id, parameter=POSTGRES_TENANT_VAR):
+ model.objects.bulk_create(chunk, batch_size)
+
+
# Postgres Enums
@@ -227,6 +250,72 @@ def register_enum(apps, schema_editor, enum_class): # noqa: F841
register_adapter(enum_class, enum_adapter)
+def _should_create_index_on_partition(
+ partition_name: str, all_partitions: bool = False
+) -> bool:
+ """
+ Determine if we should create an index on this partition.
+
+ Args:
+ partition_name: The name of the partition (e.g., "findings_2025_aug", "findings_default")
+ all_partitions: If True, create on all partitions. If False, only current/future partitions.
+
+ Returns:
+ bool: True if index should be created on this partition, False otherwise.
+ """
+ if all_partitions:
+ return True
+
+ # Extract date from partition name if it follows the pattern
+ # Partition names look like: findings_2025_aug, findings_2025_jul, etc.
+ date_pattern = r"(\d{4})_([a-z]{3})$"
+ match = re.search(date_pattern, partition_name)
+
+ if not match:
+ # If we can't parse the date, include it to be safe (e.g., default partition)
+ return True
+
+ try:
+ year_str, month_abbr = match.groups()
+ year = int(year_str)
+
+ # Map month abbreviations to numbers
+ month_map = {
+ "jan": 1,
+ "feb": 2,
+ "mar": 3,
+ "apr": 4,
+ "may": 5,
+ "jun": 6,
+ "jul": 7,
+ "aug": 8,
+ "sep": 9,
+ "oct": 10,
+ "nov": 11,
+ "dec": 12,
+ }
+
+ month = month_map.get(month_abbr.lower())
+ if month is None:
+ # Unknown month abbreviation, include it to be safe
+ return True
+
+ partition_date = datetime(year, month, 1, tzinfo=timezone.utc)
+
+ # Get current month start
+ now = datetime.now(timezone.utc)
+ current_month_start = now.replace(
+ day=1, hour=0, minute=0, second=0, microsecond=0
+ )
+
+ # Include current month and future partitions
+ return partition_date >= current_month_start
+
+ except (ValueError, TypeError):
+ # If date parsing fails, include it to be safe
+ return True
+
+
def create_index_on_partitions(
apps, # noqa: F841
schema_editor,
@@ -235,16 +324,39 @@ def create_index_on_partitions(
columns: str,
method: str = "BTREE",
where: str = "",
+ all_partitions: bool = True,
):
"""
- Create an index on every existing partition of `parent_table`.
+ Create an index on existing partitions of `parent_table`.
Args:
parent_table: The name of the root table (e.g. "findings").
index_name: A short name for the index (will be prefixed per-partition).
columns: The parenthesized column list, e.g. "tenant_id, scan_id, status".
- method: The index method—BTREE, GIN, etc. Defaults to BTREE.
- where: Optional WHERE clause (without the leading "WHERE"), e.g. "status = 'FAIL'".
+ method: The index method—BTREE, GIN, etc. Defaults to BTREE.
+ where: Optional WHERE clause (without the leading "WHERE"), e.g. "status = 'FAIL'".
+ all_partitions: Whether to create indexes on all partitions or just current/future ones.
+ Defaults to False (current/future only) to avoid maintenance overhead
+ on old partitions where the index may not be needed.
+
+ Examples:
+ # Create index only on current and future partitions (recommended for new indexes)
+ create_index_on_partitions(
+ apps, schema_editor,
+ parent_table="findings",
+ index_name="new_performance_idx",
+ columns="tenant_id, status, severity",
+ all_partitions=False # Default behavior
+ )
+
+ # Create index on all partitions (use when migrating existing critical indexes)
+ create_index_on_partitions(
+ apps, schema_editor,
+ parent_table="findings",
+ index_name="critical_existing_idx",
+ columns="tenant_id, scan_id",
+ all_partitions=True
+ )
"""
with connection.cursor() as cursor:
cursor.execute(
@@ -259,13 +371,14 @@ def create_index_on_partitions(
where_sql = f" WHERE {where}" if where else ""
for partition in partitions:
- idx_name = f"{partition.replace('.', '_')}_{index_name}"
- sql = (
- f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {idx_name} "
- f"ON {partition} USING {method} ({columns})"
- f"{where_sql};"
- )
- schema_editor.execute(sql)
+ if _should_create_index_on_partition(partition, all_partitions):
+ idx_name = f"{partition.replace('.', '_')}_{index_name}"
+ sql = (
+ f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {idx_name} "
+ f"ON {partition} USING {method} ({columns})"
+ f"{where_sql};"
+ )
+ schema_editor.execute(sql)
def drop_index_on_partitions(
@@ -279,7 +392,7 @@ def drop_index_on_partitions(
Args:
parent_table: The name of the root table (e.g. "findings").
- index_name: The same short name used when creating them.
+ index_name: The same short name used when creating them.
"""
with connection.cursor() as cursor:
cursor.execute(
diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py
index 12bc788d68..14f7227d9a 100644
--- a/api/src/backend/api/exceptions.py
+++ b/api/src/backend/api/exceptions.py
@@ -3,7 +3,7 @@ from rest_framework import status
from rest_framework.exceptions import APIException
from rest_framework_json_api.exceptions import exception_handler
from rest_framework_json_api.serializers import ValidationError
-from rest_framework_simplejwt.exceptions import TokenError, InvalidToken
+from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
class ModelValidationError(ValidationError):
@@ -32,6 +32,31 @@ class InvitationTokenExpiredException(APIException):
default_code = "token_expired"
+# Task Management Exceptions (non-HTTP)
+class TaskManagementError(Exception):
+ """Base exception for task management errors."""
+
+ def __init__(self, task=None):
+ self.task = task
+ super().__init__()
+
+
+class TaskFailedException(TaskManagementError):
+ """Raised when a task has failed."""
+
+
+class TaskNotFoundException(TaskManagementError):
+ """Raised when a task is not found."""
+
+
+class TaskInProgressException(TaskManagementError):
+ """Raised when a task is running but there's no related Task object to return."""
+
+ def __init__(self, task_result=None):
+ self.task_result = task_result
+ super().__init__()
+
+
def custom_exception_handler(exc, context):
if isinstance(exc, django_validation_error):
if hasattr(exc, "error_dict"):
@@ -39,7 +64,12 @@ def custom_exception_handler(exc, context):
else:
exc = ValidationError(detail=exc.messages[0], code=exc.code)
elif isinstance(exc, (TokenError, InvalidToken)):
- exc.detail["messages"] = [
- message_item["message"] for message_item in exc.detail["messages"]
- ]
+ if (
+ hasattr(exc, "detail")
+ and isinstance(exc.detail, dict)
+ and "messages" in exc.detail
+ ):
+ exc.detail["messages"] = [
+ message_item["message"] for message_item in exc.detail["messages"]
+ ]
return exception_handler(exc, context)
diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py
index 9e95016e1d..35ebb6a611 100644
--- a/api/src/backend/api/filters.py
+++ b/api/src/backend/api/filters.py
@@ -22,7 +22,7 @@ from api.db_utils import (
StatusEnumField,
)
from api.models import (
- ComplianceOverview,
+ ComplianceRequirementOverview,
Finding,
Integration,
Invitation,
@@ -637,12 +637,11 @@ class RoleFilter(FilterSet):
class ComplianceOverviewFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
- provider_type = ChoiceFilter(choices=Provider.ProviderChoices.choices)
- provider_type__in = ChoiceInFilter(choices=Provider.ProviderChoices.choices)
- scan_id = UUIDFilter(field_name="scan__id")
+ scan_id = UUIDFilter(field_name="scan_id")
+ region = CharFilter(field_name="region")
class Meta:
- model = ComplianceOverview
+ model = ComplianceRequirementOverview
fields = {
"inserted_at": ["date", "gte", "lte"],
"compliance_id": ["exact", "icontains"],
diff --git a/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py b/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py
new file mode 100644
index 0000000000..82bbb136a5
--- /dev/null
+++ b/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py
@@ -0,0 +1,124 @@
+# Generated by Django 5.1.8 on 2025-05-21 11:37
+
+import uuid
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import api.db_utils
+import api.rls
+from api.rls import RowLevelSecurityConstraint
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("api", "0026_provider_secret_gcp_service_account"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ComplianceRequirementOverview",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("inserted_at", models.DateTimeField(auto_now_add=True)),
+ ("compliance_id", models.TextField(blank=False)),
+ ("framework", models.TextField(blank=False)),
+ ("version", models.TextField(blank=True)),
+ ("description", models.TextField(blank=True)),
+ ("region", models.TextField(blank=False)),
+ ("requirement_id", models.TextField(blank=False)),
+ (
+ "requirement_status",
+ api.db_utils.StatusEnumField(
+ choices=[
+ ("FAIL", "Fail"),
+ ("PASS", "Pass"),
+ ("MANUAL", "Manual"),
+ ]
+ ),
+ ),
+ ("passed_checks", models.IntegerField(default=0)),
+ ("failed_checks", models.IntegerField(default=0)),
+ ("total_checks", models.IntegerField(default=0)),
+ (
+ "scan",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="compliance_requirements_overviews",
+ related_query_name="compliance_requirements_overview",
+ to="api.scan",
+ ),
+ ),
+ (
+ "tenant",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
+ ),
+ ),
+ ],
+ options={
+ "db_table": "compliance_requirements_overviews",
+ "abstract": False,
+ "indexes": [
+ models.Index(
+ fields=["tenant_id", "scan_id"], name="cro_tenant_scan_idx"
+ ),
+ models.Index(
+ fields=["tenant_id", "scan_id", "compliance_id"],
+ name="cro_scan_comp_idx",
+ ),
+ models.Index(
+ fields=["tenant_id", "scan_id", "compliance_id", "region"],
+ name="cro_scan_comp_reg_idx",
+ ),
+ models.Index(
+ fields=[
+ "tenant_id",
+ "scan_id",
+ "compliance_id",
+ "requirement_id",
+ ],
+ name="cro_scan_comp_req_idx",
+ ),
+ models.Index(
+ fields=[
+ "tenant_id",
+ "scan_id",
+ "compliance_id",
+ "requirement_id",
+ "region",
+ ],
+ name="cro_scan_comp_req_reg_idx",
+ ),
+ ],
+ "constraints": [
+ models.UniqueConstraint(
+ fields=(
+ "tenant_id",
+ "scan_id",
+ "compliance_id",
+ "requirement_id",
+ "region",
+ ),
+ name="unique_tenant_compliance_requirement_overview",
+ )
+ ],
+ },
+ ),
+ migrations.AddConstraint(
+ model_name="ComplianceRequirementOverview",
+ constraint=RowLevelSecurityConstraint(
+ "tenant_id",
+ name="rls_on_compliancerequirementoverview",
+ statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
+ ),
+ ),
+ ]
diff --git a/api/src/backend/api/migrations/0028_findings_check_index_partitions.py b/api/src/backend/api/migrations/0028_findings_check_index_partitions.py
new file mode 100644
index 0000000000..ad61f3004f
--- /dev/null
+++ b/api/src/backend/api/migrations/0028_findings_check_index_partitions.py
@@ -0,0 +1,29 @@
+from functools import partial
+
+from django.db import migrations
+
+from api.db_utils import create_index_on_partitions, drop_index_on_partitions
+
+
+class Migration(migrations.Migration):
+ atomic = False
+
+ dependencies = [
+ ("api", "0027_compliance_requirement_overviews"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ partial(
+ create_index_on_partitions,
+ parent_table="findings",
+ index_name="find_tenant_scan_check_idx",
+ columns="tenant_id, scan_id, check_id",
+ ),
+ reverse_code=partial(
+ drop_index_on_partitions,
+ parent_table="findings",
+ index_name="find_tenant_scan_check_idx",
+ ),
+ )
+ ]
diff --git a/api/src/backend/api/migrations/0029_findings_check_index_parent.py b/api/src/backend/api/migrations/0029_findings_check_index_parent.py
new file mode 100644
index 0000000000..8b975782f1
--- /dev/null
+++ b/api/src/backend/api/migrations/0029_findings_check_index_parent.py
@@ -0,0 +1,17 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("api", "0028_findings_check_index_partitions"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="finding",
+ index=models.Index(
+ fields=["tenant_id", "scan_id", "check_id"],
+ name="find_tenant_scan_check_idx",
+ ),
+ ),
+ ]
diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py
index 9564399868..b4a5c3a894 100644
--- a/api/src/backend/api/models.py
+++ b/api/src/backend/api/models.py
@@ -802,6 +802,10 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel):
GinIndex(fields=["resource_services"], name="gin_find_service_idx"),
GinIndex(fields=["resource_regions"], name="gin_find_region_idx"),
GinIndex(fields=["resource_types"], name="gin_find_rtype_idx"),
+ models.Index(
+ fields=["tenant_id", "scan_id", "check_id"],
+ name="find_tenant_scan_check_idx",
+ ),
]
class JSONAPIMeta:
@@ -1183,6 +1187,78 @@ class ComplianceOverview(RowLevelSecurityProtectedModel):
resource_name = "compliance-overviews"
+class ComplianceRequirementOverview(RowLevelSecurityProtectedModel):
+ id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
+ inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
+ compliance_id = models.TextField(blank=False)
+ framework = models.TextField(blank=False)
+ version = models.TextField(blank=True)
+ description = models.TextField(blank=True)
+ region = models.TextField(blank=False)
+
+ requirement_id = models.TextField(blank=False)
+ requirement_status = StatusEnumField(choices=StatusChoices)
+ passed_checks = models.IntegerField(default=0)
+ failed_checks = models.IntegerField(default=0)
+ total_checks = models.IntegerField(default=0)
+
+ scan = models.ForeignKey(
+ Scan,
+ on_delete=models.CASCADE,
+ related_name="compliance_requirements_overviews",
+ related_query_name="compliance_requirements_overview",
+ )
+
+ class Meta(RowLevelSecurityProtectedModel.Meta):
+ db_table = "compliance_requirements_overviews"
+
+ constraints = [
+ models.UniqueConstraint(
+ fields=(
+ "tenant_id",
+ "scan_id",
+ "compliance_id",
+ "requirement_id",
+ "region",
+ ),
+ name="unique_tenant_compliance_requirement_overview",
+ ),
+ RowLevelSecurityConstraint(
+ field="tenant_id",
+ name="rls_on_%(class)s",
+ statements=["SELECT", "INSERT", "DELETE"],
+ ),
+ ]
+ indexes = [
+ models.Index(fields=["tenant_id", "scan_id"], name="cro_tenant_scan_idx"),
+ models.Index(
+ fields=["tenant_id", "scan_id", "compliance_id"],
+ name="cro_scan_comp_idx",
+ ),
+ models.Index(
+ fields=["tenant_id", "scan_id", "compliance_id", "region"],
+ name="cro_scan_comp_reg_idx",
+ ),
+ models.Index(
+ fields=["tenant_id", "scan_id", "compliance_id", "requirement_id"],
+ name="cro_scan_comp_req_idx",
+ ),
+ models.Index(
+ fields=[
+ "tenant_id",
+ "scan_id",
+ "compliance_id",
+ "requirement_id",
+ "region",
+ ],
+ name="cro_scan_comp_req_reg_idx",
+ ),
+ ]
+
+ class JSONAPIMeta:
+ resource_name = "compliance-requirements-overviews"
+
+
class ScanSummary(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
diff --git a/api/src/backend/api/pagination.py b/api/src/backend/api/pagination.py
index 8f37c9ba78..b9742416bb 100644
--- a/api/src/backend/api/pagination.py
+++ b/api/src/backend/api/pagination.py
@@ -1,4 +1,4 @@
-from rest_framework_json_api.pagination import JsonApiPageNumberPagination
+from drf_spectacular_jsonapi.schemas.pagination import JsonApiPageNumberPagination
class ComplianceOverviewPagination(JsonApiPageNumberPagination):
diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml
index c7c20b5d56..30c64e31a4 100644
--- a/api/src/backend/api/specs/v1.yaml
+++ b/api/src/backend/api/specs/v1.yaml
@@ -10,9 +10,7 @@ paths:
/api/v1/compliance-overviews:
get:
operationId: compliance_overviews_list
- description: Retrieve an overview of all the compliance in a given scan. If
- no region filters are provided, the region with the most fails will be returned
- by default.
+ description: Retrieve an overview of all the compliance in a given scan.
summary: List compliance overviews for a scan
parameters:
- in: query
@@ -22,15 +20,13 @@ paths:
items:
type: string
enum:
- - inserted_at
- - compliance_id
+ - id
- framework
- version
- - requirements_status
- - region
- - provider_type
- - scan
- - url
+ - requirements_passed
+ - requirements_failed
+ - requirements_manual
+ - total_requirements
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
@@ -74,44 +70,6 @@ paths:
schema:
type: string
format: date-time
- - in: query
- name: filter[provider_type]
- schema:
- type: string
- enum:
- - aws
- - azure
- - gcp
- - kubernetes
- - m365
- description: |-
- * `aws` - AWS
- * `azure` - Azure
- * `gcp` - GCP
- * `kubernetes` - Kubernetes
- * `m365` - M365
- - in: query
- name: filter[provider_type__in]
- schema:
- type: array
- items:
- type: string
- enum:
- - aws
- - azure
- - gcp
- - kubernetes
- - m365
- description: |-
- Multiple values may be separated by commas.
-
- * `aws` - AWS
- * `azure` - Azure
- * `gcp` - GCP
- * `kubernetes` - Kubernetes
- * `m365` - M365
- explode: false
- style: form
- in: query
name: filter[region]
schema:
@@ -171,14 +129,8 @@ paths:
items:
type: string
enum:
- - inserted_at
- - -inserted_at
- compliance_id
- -compliance_id
- - framework
- - -framework
- - region
- - -region
explode: false
tags:
- Compliance Overview
@@ -190,41 +142,43 @@ paths:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/PaginatedComplianceOverviewList'
- description: ''
- /api/v1/compliance-overviews/{id}:
+ description: Compliance overviews obtained successfully
+ '202':
+ content:
+ application/vnd.api+json:
+ schema:
+ $ref: '#/components/schemas/PaginatedTaskList'
+ description: The task is in progress
+ '500':
+ description: Compliance overviews generation task failed
+ /api/v1/compliance-overviews/attributes:
get:
- operationId: compliance_overviews_retrieve
- description: Fetch detailed information about a specific compliance overview
- by its ID, including detailed requirement information and check's status.
- summary: Retrieve data from a specific compliance overview
+ operationId: compliance_overviews_attributes_retrieve
+ description: Retrieve detailed attribute information for all requirements in
+ a specific compliance framework along with the associated check IDs for each
+ requirement.
+ summary: Get compliance requirement attributes
parameters:
- in: query
- name: fields[compliance-overviews]
+ name: fields[compliance-requirements-attributes]
schema:
type: array
items:
type: string
enum:
- - inserted_at
- - compliance_id
+ - id
- framework
- version
- - requirements_status
- - region
- - provider_type
- - scan
- - url
- description
- - requirements
+ - attributes
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- - in: path
- name: id
+ - in: query
+ name: filter[compliance_id]
schema:
type: string
- format: uuid
- description: A UUID string identifying this compliance overview.
+ description: Compliance framework ID to get attributes for.
required: true
tags:
- Compliance Overview
@@ -235,8 +189,8 @@ paths:
content:
application/vnd.api+json:
schema:
- $ref: '#/components/schemas/ComplianceOverviewFullResponse'
- description: ''
+ $ref: '#/components/schemas/PaginatedComplianceOverviewAttributesList'
+ description: Compliance attributes obtained successfully
/api/v1/compliance-overviews/metadata:
get:
operationId: compliance_overviews_metadata_retrieve
@@ -271,8 +225,142 @@ paths:
content:
application/vnd.api+json:
schema:
- $ref: '#/components/schemas/ComplianceOverviewMetadataResponse'
- description: ''
+ $ref: '#/components/schemas/OpenApiResponseResponse'
+ description: Compliance overviews metadata obtained successfully
+ '202':
+ description: The task is in progress
+ '500':
+ description: Compliance overviews generation task failed
+ /api/v1/compliance-overviews/requirements:
+ get:
+ operationId: compliance_overviews_requirements_retrieve
+ description: Retrieve a detailed overview of compliance requirements in a given
+ scan, grouped by compliance framework. This endpoint provides requirement-level
+ details and aggregates status across regions.
+ summary: List compliance requirements overview for a scan
+ parameters:
+ - in: query
+ name: fields[compliance-requirements-details]
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - id
+ - framework
+ - version
+ - description
+ - status
+ description: endpoint return only specific fields in the response on a per-type
+ basis by including a fields[TYPE] query parameter.
+ explode: false
+ - in: query
+ name: filter[compliance_id]
+ schema:
+ type: string
+ description: Compliance ID.
+ required: true
+ - in: query
+ name: filter[compliance_id__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[framework]
+ schema:
+ type: string
+ - in: query
+ name: filter[framework__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[framework__iexact]
+ schema:
+ type: string
+ - in: query
+ name: filter[inserted_at]
+ schema:
+ type: string
+ format: date
+ - in: query
+ name: filter[inserted_at__date]
+ schema:
+ type: string
+ format: date
+ - in: query
+ name: filter[inserted_at__gte]
+ schema:
+ type: string
+ format: date-time
+ - in: query
+ name: filter[inserted_at__lte]
+ schema:
+ type: string
+ format: date-time
+ - in: query
+ name: filter[region]
+ schema:
+ type: string
+ - in: query
+ name: filter[region__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[region__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - in: query
+ name: filter[scan_id]
+ schema:
+ type: string
+ format: uuid
+ description: Related scan ID.
+ required: true
+ - name: filter[search]
+ required: false
+ in: query
+ description: A search term.
+ schema:
+ type: string
+ - in: query
+ name: filter[version]
+ schema:
+ type: string
+ - in: query
+ name: filter[version__icontains]
+ schema:
+ type: string
+ - name: sort
+ required: false
+ in: query
+ description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)'
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - compliance_id
+ - -compliance_id
+ explode: false
+ tags:
+ - Compliance Overview
+ security:
+ - jwtAuth: []
+ responses:
+ '200':
+ content:
+ application/vnd.api+json:
+ schema:
+ $ref: '#/components/schemas/PaginatedComplianceOverviewDetailList'
+ description: Compliance requirement details obtained successfully
+ '202':
+ description: The task is in progress
+ '500':
+ description: Compliance overviews generation task failed
/api/v1/findings:
get:
operationId: findings_list
@@ -6839,80 +6927,37 @@ components:
properties:
type:
allOf:
- - $ref: '#/components/schemas/Type7f7Enum'
+ - $ref: '#/components/schemas/ComplianceOverviewTypeEnum'
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
- id:
- type: string
- format: uuid
+ id: {}
attributes:
type: object
properties:
- inserted_at:
+ id:
type: string
- format: date-time
- readOnly: true
- compliance_id:
- type: string
- maxLength: 100
framework:
type: string
- maxLength: 100
version:
type: string
- maxLength: 50
- requirements_status:
- type: object
- properties:
- passed:
- type: integer
- failed:
- type: integer
- manual:
- type: integer
- total:
- type: integer
- readOnly: true
- region:
- type: string
- maxLength: 50
- provider_type:
- type: string
- nullable: true
- readOnly: true
+ requirements_passed:
+ type: integer
+ requirements_failed:
+ type: integer
+ requirements_manual:
+ type: integer
+ total_requirements:
+ type: integer
required:
- - compliance_id
+ - id
- framework
- relationships:
- type: object
- properties:
- scan:
- type: object
- properties:
- data:
- type: object
- properties:
- id:
- type: string
- format: uuid
- type:
- type: string
- enum:
- - scans
- title: Resource Type Name
- description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
- member is used to describe resource objects that share common
- attributes and relationships.
- required:
- - id
- - type
- required:
- - data
- description: The identifier of the related object.
- title: Resource Identifier
- nullable: true
- ComplianceOverviewFull:
+ - version
+ - requirements_passed
+ - requirements_failed
+ - requirements_manual
+ - total_requirements
+ ComplianceOverviewAttributes:
type: object
required:
- type
@@ -6921,134 +6966,78 @@ components:
properties:
type:
allOf:
- - $ref: '#/components/schemas/Type7f7Enum'
+ - $ref: '#/components/schemas/ComplianceOverviewAttributesTypeEnum'
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
- id:
- type: string
- format: uuid
+ id: {}
attributes:
type: object
properties:
- inserted_at:
+ id:
type: string
- format: date-time
- readOnly: true
- compliance_id:
- type: string
- maxLength: 100
framework:
type: string
- maxLength: 100
version:
type: string
- maxLength: 50
- requirements_status:
- type: object
- properties:
- passed:
- type: integer
- failed:
- type: integer
- manual:
- type: integer
- total:
- type: integer
- readOnly: true
- region:
- type: string
- maxLength: 50
- provider_type:
- type: string
- nullable: true
- readOnly: true
description:
type: string
- requirements:
- type: object
- properties:
- requirement_id:
- type: object
- properties:
- name:
- type: string
- checks:
- type: object
- properties:
- check_name:
- type: object
- properties:
- status:
- type: string
- enum:
- - PASS
- - FAIL
- - null
- description: Each key in the 'checks' object is a check name,
- with values as 'PASS', 'FAIL', or null.
- status:
- type: string
- enum:
- - PASS
- - FAIL
- - MANUAL
- attributes:
- type: array
- items:
- type: object
- description:
- type: string
- checks_status:
- type: object
- properties:
- total:
- type: integer
- pass:
- type: integer
- fail:
- type: integer
- manual:
- type: integer
- readOnly: true
+ attributes: {}
required:
- - compliance_id
+ - id
- framework
- relationships:
+ - version
+ - description
+ - attributes
+ ComplianceOverviewAttributesTypeEnum:
+ type: string
+ enum:
+ - compliance-requirements-attributes
+ ComplianceOverviewDetail:
+ type: object
+ required:
+ - type
+ - id
+ additionalProperties: false
+ properties:
+ type:
+ allOf:
+ - $ref: '#/components/schemas/ComplianceOverviewDetailTypeEnum'
+ description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
+ member is used to describe resource objects that share common attributes
+ and relationships.
+ id: {}
+ attributes:
type: object
properties:
- scan:
- type: object
- properties:
- data:
- type: object
- properties:
- id:
- type: string
- format: uuid
- type:
- type: string
- enum:
- - scans
- title: Resource Type Name
- description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
- member is used to describe resource objects that share common
- attributes and relationships.
- required:
- - id
- - type
- required:
- - data
- description: The identifier of the related object.
- title: Resource Identifier
- nullable: true
- ComplianceOverviewFullResponse:
- type: object
- properties:
- data:
- $ref: '#/components/schemas/ComplianceOverviewFull'
- required:
- - data
+ id:
+ type: string
+ framework:
+ type: string
+ version:
+ type: string
+ description:
+ type: string
+ status:
+ enum:
+ - FAIL
+ - PASS
+ - MANUAL
+ type: string
+ description: |-
+ * `FAIL` - Fail
+ * `PASS` - Pass
+ * `MANUAL` - Manual
+ required:
+ - id
+ - framework
+ - version
+ - description
+ - status
+ ComplianceOverviewDetailTypeEnum:
+ type: string
+ enum:
+ - compliance-requirements-details
ComplianceOverviewMetadata:
type: object
required:
@@ -7072,17 +7061,14 @@ components:
type: string
required:
- regions
- ComplianceOverviewMetadataResponse:
- type: object
- properties:
- data:
- $ref: '#/components/schemas/ComplianceOverviewMetadata'
- required:
- - data
ComplianceOverviewMetadataTypeEnum:
type: string
enum:
- compliance-overviews-metadata
+ ComplianceOverviewTypeEnum:
+ type: string
+ enum:
+ - compliance-overviews
Finding:
type: object
required:
@@ -8386,7 +8372,7 @@ components:
type: object
properties:
data:
- $ref: '#/components/schemas/Membership'
+ $ref: '#/components/schemas/ComplianceOverviewMetadata'
required:
- data
OverviewFinding:
@@ -8601,29 +8587,33 @@ components:
type: string
enum:
- findings-severity-overview
+ PaginatedComplianceOverviewAttributesList:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/ComplianceOverviewAttributes'
+ required:
+ - data
+ PaginatedComplianceOverviewDetailList:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/ComplianceOverviewDetail'
+ required:
+ - data
PaginatedComplianceOverviewList:
type: object
- required:
- - count
- - results
properties:
- count:
- type: integer
- example: 123
- next:
- type: string
- nullable: true
- format: uri
- example: http://api.example.org/accounts/?page[number]=4
- previous:
- type: string
- nullable: true
- format: uri
- example: http://api.example.org/accounts/?page[number]=2
- results:
+ data:
type: array
items:
$ref: '#/components/schemas/ComplianceOverview'
+ required:
+ - data
PaginatedFindingList:
type: object
properties:
@@ -11904,6 +11894,7 @@ components:
type: object
required:
- type
+ - id
additionalProperties: false
properties:
type:
@@ -11912,6 +11903,9 @@ components:
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
+ id:
+ type: string
+ format: uuid
attributes:
type: object
properties:
@@ -12326,10 +12320,6 @@ components:
type: string
enum:
- roles
- Type7f7Enum:
- type: string
- enum:
- - compliance-overviews
Type8cdEnum:
type: string
enum:
diff --git a/api/src/backend/api/tests/test_db_utils.py b/api/src/backend/api/tests/test_db_utils.py
index e22b1417bc..3373dafed0 100644
--- a/api/src/backend/api/tests/test_db_utils.py
+++ b/api/src/backend/api/tests/test_db_utils.py
@@ -3,9 +3,13 @@ from enum import Enum
from unittest.mock import patch
import pytest
+from django.conf import settings
+from freezegun import freeze_time
from api.db_utils import (
+ _should_create_index_on_partition,
batch_delete,
+ create_objects_in_batches,
enum_to_choices,
generate_random_token,
one_week_from_now,
@@ -138,3 +142,88 @@ class TestBatchDelete:
)
assert Provider.objects.all().count() == 0
assert summary == {"api.Provider": create_test_providers}
+
+
+class TestShouldCreateIndexOnPartition:
+ @freeze_time("2025-05-15 00:00:00Z")
+ @pytest.mark.parametrize(
+ "partition_name, all_partitions, expected",
+ [
+ ("any_name", True, True),
+ ("findings_default", True, True),
+ ("findings_2022_jan", True, True),
+ ("foo_bar", False, True),
+ ("findings_2025_MAY", False, True),
+ ("findings_2025_may", False, True),
+ ("findings_2025_jun", False, True),
+ ("findings_2025_apr", False, False),
+ ("findings_2025_xyz", False, True),
+ ],
+ )
+ def test_partition_inclusion_logic(self, partition_name, all_partitions, expected):
+ assert (
+ _should_create_index_on_partition(partition_name, all_partitions)
+ is expected
+ )
+
+ @freeze_time("2025-05-15 00:00:00Z")
+ def test_invalid_date_components(self):
+ # even if regex matches but int conversion fails, we fallback True
+ # (e.g. year too big, month number parse error)
+ bad_name = "findings_99999_jan"
+ assert _should_create_index_on_partition(bad_name, False) is True
+
+ bad_name2 = "findings_2025_abc"
+ # abc not in month_map → fallback True
+ assert _should_create_index_on_partition(bad_name2, False) is True
+
+
+@pytest.mark.django_db
+class TestCreateObjectsInBatches:
+ @pytest.fixture
+ def tenant(self, tenants_fixture):
+ return tenants_fixture[0]
+
+ def make_provider_instances(self, tenant, count):
+ """
+ Return a list of `count` unsaved Provider instances for the given tenant.
+ """
+ base_uid = 1000
+ return [
+ Provider(
+ tenant=tenant,
+ uid=str(base_uid + i),
+ provider=Provider.ProviderChoices.AWS,
+ )
+ for i in range(count)
+ ]
+
+ def test_exact_multiple_of_batch(self, tenant):
+ total = 6
+ batch_size = 3
+ objs = self.make_provider_instances(tenant, total)
+
+ create_objects_in_batches(str(tenant.id), Provider, objs, batch_size=batch_size)
+
+ qs = Provider.objects.filter(tenant=tenant)
+ assert qs.count() == total
+
+ def test_non_multiple_of_batch(self, tenant):
+ total = 7
+ batch_size = 3
+ objs = self.make_provider_instances(tenant, total)
+
+ create_objects_in_batches(str(tenant.id), Provider, objs, batch_size=batch_size)
+
+ qs = Provider.objects.filter(tenant=tenant)
+ assert qs.count() == total
+
+ def test_batch_size_default(self, tenant):
+ default_size = settings.DJANGO_DELETION_BATCH_SIZE
+ total = default_size + 2
+ objs = self.make_provider_instances(tenant, total)
+
+ create_objects_in_batches(str(tenant.id), Provider, objs)
+
+ qs = Provider.objects.filter(tenant=tenant)
+ assert qs.count() == total
diff --git a/api/src/backend/api/tests/test_mixins.py b/api/src/backend/api/tests/test_mixins.py
new file mode 100644
index 0000000000..7daf9d5ff6
--- /dev/null
+++ b/api/src/backend/api/tests/test_mixins.py
@@ -0,0 +1,379 @@
+import json
+from uuid import uuid4
+
+import pytest
+from django_celery_results.models import TaskResult
+from rest_framework import status
+from rest_framework.response import Response
+
+from api.exceptions import (
+ TaskFailedException,
+ TaskInProgressException,
+ TaskNotFoundException,
+)
+from api.models import Task, User
+from api.rls import Tenant
+from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin
+
+
+@pytest.mark.django_db
+class TestPaginateByPkMixin:
+ @pytest.fixture
+ def tenant(self):
+ return Tenant.objects.create(name="Test Tenant")
+
+ @pytest.fixture
+ def users(self, tenant):
+ # Create 5 users with proper email field
+ users = []
+ for i in range(5):
+ user = User.objects.create(email=f"user{i}@example.com", name=f"User {i}")
+ users.append(user)
+ return users
+
+ class DummyView(PaginateByPkMixin):
+ def __init__(self, page):
+ self._page = page
+
+ def paginate_queryset(self, qs):
+ return self._page
+
+ def get_serializer(self, queryset, many):
+ class S:
+ def __init__(self, data):
+ # serialize to list of ids
+ self.data = [obj.id for obj in data] if many else queryset.id
+
+ return S(queryset)
+
+ def get_paginated_response(self, data):
+ return Response({"results": data}, status=status.HTTP_200_OK)
+
+ def test_no_pagination(self, users):
+ base_qs = User.objects.all().order_by("id")
+ view = self.DummyView(page=None)
+ resp = view.paginate_by_pk(
+ request=None, base_queryset=base_qs, manager=User.objects
+ )
+ # since no pagination, should return all ids in order
+ expected = [u.id for u in base_qs]
+ assert isinstance(resp, Response)
+ assert resp.data == expected
+
+ def test_with_pagination(self, users):
+ base_qs = User.objects.all().order_by("id")
+ # simulate paging to first 2 ids
+ page = [base_qs[1].id, base_qs[3].id]
+ view = self.DummyView(page=page)
+ resp = view.paginate_by_pk(
+ request=None, base_queryset=base_qs, manager=User.objects
+ )
+ # should fetch only those two users, in the same order as page
+ assert resp.status_code == status.HTTP_200_OK
+ assert resp.data == {"results": page}
+
+
+@pytest.mark.django_db
+class TestTaskManagementMixin:
+ class DummyView(TaskManagementMixin):
+ pass
+
+ @pytest.fixture
+ def tenant(self):
+ return Tenant.objects.create(name="Test Tenant")
+
+ @pytest.fixture(autouse=True)
+ def cleanup(self):
+ Task.objects.all().delete()
+ TaskResult.objects.all().delete()
+
+ def test_no_task_and_no_taskresult_raises_not_found(self):
+ view = self.DummyView()
+ with pytest.raises(TaskNotFoundException):
+ view.check_task_status("task_xyz", {"foo": "bar"})
+
+ def test_no_task_and_no_taskresult_returns_none_when_not_raising(self):
+ view = self.DummyView()
+ result = view.check_task_status(
+ "task_xyz", {"foo": "bar"}, raise_on_not_found=False
+ )
+ assert result is None
+
+ def test_taskresult_pending_raises_in_progress(self):
+ task_kwargs = {"foo": "bar"}
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="task_xyz",
+ task_kwargs=json.dumps(task_kwargs),
+ status="PENDING",
+ )
+ view = self.DummyView()
+ with pytest.raises(TaskInProgressException) as excinfo:
+ view.check_task_status("task_xyz", task_kwargs, raise_on_not_found=False)
+ assert hasattr(excinfo.value, "task_result")
+ assert excinfo.value.task_result == tr
+
+ def test_taskresult_started_raises_in_progress(self):
+ task_kwargs = {"foo": "bar"}
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="task_xyz",
+ task_kwargs=json.dumps(task_kwargs),
+ status="STARTED",
+ )
+ view = self.DummyView()
+ with pytest.raises(TaskInProgressException) as excinfo:
+ view.check_task_status("task_xyz", task_kwargs, raise_on_not_found=False)
+ assert hasattr(excinfo.value, "task_result")
+ assert excinfo.value.task_result == tr
+
+ def test_taskresult_progress_raises_in_progress(self):
+ task_kwargs = {"foo": "bar"}
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="task_xyz",
+ task_kwargs=json.dumps(task_kwargs),
+ status="PROGRESS",
+ )
+ view = self.DummyView()
+ with pytest.raises(TaskInProgressException) as excinfo:
+ view.check_task_status("task_xyz", task_kwargs, raise_on_not_found=False)
+ assert hasattr(excinfo.value, "task_result")
+ assert excinfo.value.task_result == tr
+
+ def test_taskresult_failure_raises_failed(self):
+ task_kwargs = {"a": 1}
+ TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="task_fail",
+ task_kwargs=json.dumps(task_kwargs),
+ status="FAILURE",
+ )
+ view = self.DummyView()
+ with pytest.raises(TaskFailedException):
+ view.check_task_status("task_fail", task_kwargs, raise_on_not_found=False)
+
+ def test_taskresult_failure_returns_none_when_not_raising(self):
+ task_kwargs = {"a": 1}
+ TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="task_fail",
+ task_kwargs=json.dumps(task_kwargs),
+ status="FAILURE",
+ )
+ view = self.DummyView()
+ result = view.check_task_status(
+ "task_fail", task_kwargs, raise_on_failed=False, raise_on_not_found=False
+ )
+ assert result is None
+
+ def test_taskresult_success_returns_none(self):
+ task_kwargs = {"x": 2}
+ TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="task_ok",
+ task_kwargs=json.dumps(task_kwargs),
+ status="SUCCESS",
+ )
+ view = self.DummyView()
+ # should not raise, and returns None
+ assert (
+ view.check_task_status("task_ok", task_kwargs, raise_on_not_found=False)
+ is None
+ )
+
+ def test_taskresult_revoked_returns_none(self):
+ task_kwargs = {"x": 2}
+ TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="task_revoked",
+ task_kwargs=json.dumps(task_kwargs),
+ status="REVOKED",
+ )
+ view = self.DummyView()
+ # should not raise, and returns None
+ assert (
+ view.check_task_status(
+ "task_revoked", task_kwargs, raise_on_not_found=False
+ )
+ is None
+ )
+
+ def test_task_with_failed_status_raises_failed(self, tenant):
+ task_kwargs = {"provider_id": "test"}
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan_task",
+ task_kwargs=json.dumps(task_kwargs),
+ status="FAILURE",
+ )
+ task = Task.objects.create(tenant=tenant, task_runner_task=tr)
+ view = self.DummyView()
+ with pytest.raises(TaskFailedException) as excinfo:
+ view.check_task_status("scan_task", task_kwargs)
+ # Check that the exception contains the expected task
+ assert hasattr(excinfo.value, "task")
+ assert excinfo.value.task == task
+
+ def test_task_with_cancelled_status_raises_failed(self, tenant):
+ task_kwargs = {"provider_id": "test"}
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan_task",
+ task_kwargs=json.dumps(task_kwargs),
+ status="REVOKED",
+ )
+ task = Task.objects.create(tenant=tenant, task_runner_task=tr)
+ view = self.DummyView()
+ with pytest.raises(TaskFailedException) as excinfo:
+ view.check_task_status("scan_task", task_kwargs)
+ # Check that the exception contains the expected task
+ assert hasattr(excinfo.value, "task")
+ assert excinfo.value.task == task
+
+ def test_task_with_failed_status_returns_task_when_not_raising(self, tenant):
+ task_kwargs = {"provider_id": "test"}
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan_task",
+ task_kwargs=json.dumps(task_kwargs),
+ status="FAILURE",
+ )
+ task = Task.objects.create(tenant=tenant, task_runner_task=tr)
+ view = self.DummyView()
+ result = view.check_task_status("scan_task", task_kwargs, raise_on_failed=False)
+ assert result == task
+
+ def test_task_with_completed_status_returns_none(self, tenant):
+ task_kwargs = {"provider_id": "test"}
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan_task",
+ task_kwargs=json.dumps(task_kwargs),
+ status="SUCCESS",
+ )
+ Task.objects.create(tenant=tenant, task_runner_task=tr)
+ view = self.DummyView()
+ result = view.check_task_status("scan_task", task_kwargs)
+ assert result is None
+
+ def test_task_with_executing_status_returns_task(self, tenant):
+ task_kwargs = {"provider_id": "test"}
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan_task",
+ task_kwargs=json.dumps(task_kwargs),
+ status="STARTED",
+ )
+ task = Task.objects.create(tenant=tenant, task_runner_task=tr)
+ view = self.DummyView()
+ result = view.check_task_status("scan_task", task_kwargs)
+ assert result is not None
+ assert result.pk == task.pk
+
+ def test_task_with_pending_status_returns_task(self, tenant):
+ task_kwargs = {"provider_id": "test"}
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan_task",
+ task_kwargs=json.dumps(task_kwargs),
+ status="PENDING",
+ )
+ task = Task.objects.create(tenant=tenant, task_runner_task=tr)
+ view = self.DummyView()
+ result = view.check_task_status("scan_task", task_kwargs)
+ assert result is not None
+ assert result.pk == task.pk
+
+ def test_get_task_response_if_running_returns_none_for_completed_task(self, tenant):
+ task_kwargs = {"provider_id": "test"}
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan_task",
+ task_kwargs=json.dumps(task_kwargs),
+ status="SUCCESS",
+ )
+ Task.objects.create(tenant=tenant, task_runner_task=tr)
+ view = self.DummyView()
+ result = view.get_task_response_if_running("scan_task", task_kwargs)
+ assert result is None
+
+ def test_get_task_response_if_running_returns_none_for_no_task(self):
+ view = self.DummyView()
+ result = view.get_task_response_if_running(
+ "nonexistent", {"foo": "bar"}, raise_on_not_found=False
+ )
+ assert result is None
+
+ def test_get_task_response_if_running_returns_202_for_executing_task(self, tenant):
+ task_kwargs = {"provider_id": "test"}
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan_task",
+ task_kwargs=json.dumps(task_kwargs),
+ status="STARTED",
+ )
+ task = Task.objects.create(tenant=tenant, task_runner_task=tr)
+ view = self.DummyView()
+ result = view.get_task_response_if_running("scan_task", task_kwargs)
+
+ assert isinstance(result, Response)
+ assert result.status_code == status.HTTP_202_ACCEPTED
+ assert "Content-Location" in result.headers
+ # The response should contain the serialized task data
+ assert result.data is not None
+ assert "id" in result.data
+ assert str(result.data["id"]) == str(task.id)
+
+ def test_get_task_response_if_running_returns_none_for_available_task(self, tenant):
+ task_kwargs = {"provider_id": "test"}
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan_task",
+ task_kwargs=json.dumps(task_kwargs),
+ status="PENDING",
+ )
+ Task.objects.create(tenant=tenant, task_runner_task=tr)
+ view = self.DummyView()
+ result = view.get_task_response_if_running("scan_task", task_kwargs)
+ # PENDING maps to AVAILABLE, which is not EXECUTING, so should return None
+ assert result is None
+
+ def test_kwargs_filtering_works_correctly(self, tenant):
+ # Create tasks with different kwargs
+ task_kwargs_1 = {"provider_id": "test1", "scan_type": "full"}
+ task_kwargs_2 = {"provider_id": "test2", "scan_type": "quick"}
+
+ tr1 = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan_task",
+ task_kwargs=json.dumps(task_kwargs_1),
+ status="STARTED",
+ )
+ tr2 = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan_task",
+ task_kwargs=json.dumps(task_kwargs_2),
+ status="STARTED",
+ )
+
+ task1 = Task.objects.create(tenant=tenant, task_runner_task=tr1)
+ task2 = Task.objects.create(tenant=tenant, task_runner_task=tr2)
+
+ view = self.DummyView()
+
+ # Should find task1 when searching for its kwargs
+ result1 = view.check_task_status("scan_task", {"provider_id": "test1"})
+ assert result1 is not None
+ assert result1.pk == task1.pk
+
+ # Should find task2 when searching for its kwargs
+ result2 = view.check_task_status("scan_task", {"provider_id": "test2"})
+ assert result2 is not None
+ assert result2.pk == task2.pk
+
+ # Should not find anything when searching for non-existent kwargs
+ result3 = view.check_task_status(
+ "scan_task", {"provider_id": "test3"}, raise_on_not_found=False
+ )
+ assert result3 is None
diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py
index ac6c6b51a6..512ba79a93 100644
--- a/api/src/backend/api/tests/test_views.py
+++ b/api/src/backend/api/tests/test_views.py
@@ -15,10 +15,10 @@ from django.conf import settings
from django.urls import reverse
from django_celery_results.models import TaskResult
from rest_framework import status
+from rest_framework.response import Response
from api.compliance import get_compliance_frameworks
from api.models import (
- ComplianceOverview,
Integration,
Invitation,
Membership,
@@ -35,6 +35,7 @@ from api.models import (
UserRoleRelationship,
)
from api.rls import Tenant
+from api.v1.views import ComplianceOverviewViewSet
TODAY = str(datetime.today().date())
@@ -4761,210 +4762,248 @@ class TestComplianceOverviewViewSet:
assert len(response.json()["data"]) == 0
def test_compliance_overview_list(
- self, authenticated_client, compliance_overviews_fixture
+ self, authenticated_client, compliance_requirements_overviews_fixture
):
# List compliance overviews with existing data
- compliance_overview1, compliance_overview2 = compliance_overviews_fixture
- scan_id = str(compliance_overview1.scan.id)
+ requirement_overview1 = compliance_requirements_overviews_fixture[0]
+ scan_id = str(requirement_overview1.scan.id)
response = authenticated_client.get(
reverse("complianceoverview-list"),
{"filter[scan_id]": scan_id},
)
assert response.status_code == status.HTTP_200_OK
- assert (
- len(response.json()["data"]) == 1
- ) # Due to the custom get_queryset method, only one compliance_id
+ data = response.json()["data"]
+ assert len(data) == 2 # Two compliance frameworks
- def test_compliance_overview_list_missing_scan_id(self, authenticated_client):
- # Attempt to list compliance overviews without providing filter[scan_id]
- response = authenticated_client.get(reverse("complianceoverview-list"))
+ # Check that we get aggregated data for each compliance framework
+ framework_ids = [item["id"] for item in data]
+ assert "aws_account_security_onboarding_aws" in framework_ids
+ assert "cis_1.4_aws" in framework_ids
+
+ # Check structure of response
+ for item in data:
+ assert "id" in item
+ assert "attributes" in item
+ attributes = item["attributes"]
+ assert "framework" in attributes
+ assert "version" in attributes
+ assert "requirements_passed" in attributes
+ assert "requirements_failed" in attributes
+ assert "requirements_manual" in attributes
+ assert "total_requirements" in attributes
+
+ def test_compliance_overview_metadata(
+ self, authenticated_client, compliance_requirements_overviews_fixture
+ ):
+ requirement_overview1 = compliance_requirements_overviews_fixture[0]
+ scan_id = str(requirement_overview1.scan.id)
+
+ response = authenticated_client.get(
+ reverse("complianceoverview-metadata"),
+ {"filter[scan_id]": scan_id},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ assert "attributes" in data
+ assert "regions" in data["attributes"]
+ assert isinstance(data["attributes"]["regions"], list)
+
+ def test_compliance_overview_requirements(
+ self, authenticated_client, compliance_requirements_overviews_fixture
+ ):
+ requirement_overview1 = compliance_requirements_overviews_fixture[0]
+ scan_id = str(requirement_overview1.scan.id)
+ compliance_id = requirement_overview1.compliance_id
+
+ 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 len(data) > 0
+
+ # Check structure of requirements response
+ for item in data:
+ assert "id" in item
+ assert "attributes" in item
+ attributes = item["attributes"]
+ assert "framework" in attributes
+ assert "version" in attributes
+ assert "description" in attributes
+ assert "status" in attributes
+
+ def test_compliance_overview_requirements_missing_scan_id(
+ self, authenticated_client
+ ):
+ response = authenticated_client.get(
+ reverse("complianceoverview-requirements"),
+ {"filter[compliance_id]": "aws_account_security_onboarding_aws"},
+ )
assert response.status_code == status.HTTP_400_BAD_REQUEST
- assert response.json()["errors"][0]["source"]["pointer"] == "filter[scan_id]"
- assert response.json()["errors"][0]["code"] == "required"
+
+ def test_compliance_overview_requirements_missing_compliance_id(
+ self, authenticated_client, compliance_requirements_overviews_fixture
+ ):
+ requirement_overview1 = compliance_requirements_overviews_fixture[0]
+ scan_id = str(requirement_overview1.scan.id)
+
+ response = authenticated_client.get(
+ reverse("complianceoverview-requirements"),
+ {"filter[scan_id]": scan_id},
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_compliance_overview_attributes(self, authenticated_client):
+ response = authenticated_client.get(
+ reverse("complianceoverview-attributes"),
+ {"filter[compliance_id]": "aws_account_security_onboarding_aws"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ assert len(data) > 0
+
+ # Check structure of attributes response
+ for item in data:
+ assert "id" in item
+ assert "attributes" in item
+ attributes = item["attributes"]
+ assert "framework" in attributes
+ assert "version" in attributes
+ assert "description" in attributes
+ assert "attributes" in attributes
+ assert "metadata" in attributes["attributes"]
+ assert "check_ids" in attributes["attributes"]
+
+ def test_compliance_overview_attributes_missing_compliance_id(
+ self, authenticated_client
+ ):
+ response = authenticated_client.get(
+ reverse("complianceoverview-attributes"),
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_compliance_overview_task_management_integration(
+ self, authenticated_client, compliance_requirements_overviews_fixture
+ ):
+ """Test that task management mixin is properly integrated"""
+ from unittest.mock import patch
+
+ requirement_overview1 = compliance_requirements_overviews_fixture[0]
+ scan_id = str(requirement_overview1.scan.id)
+
+ # Mock a running task
+ with patch.object(
+ ComplianceOverviewViewSet, "get_task_response_if_running"
+ ) as mock_task_response:
+ mock_response = Response(
+ {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED
+ )
+ mock_task_response.return_value = mock_response
+
+ response = authenticated_client.get(
+ reverse("complianceoverview-list"),
+ {"filter[scan_id]": scan_id},
+ )
+ assert response.status_code == status.HTTP_202_ACCEPTED
+ mock_task_response.assert_called_once()
+
+ def test_compliance_overview_task_failed_exception(
+ self, authenticated_client, compliance_requirements_overviews_fixture
+ ):
+ """Test handling of TaskFailedException"""
+ from unittest.mock import patch
+
+ from api.exceptions import TaskFailedException
+
+ requirement_overview1 = compliance_requirements_overviews_fixture[0]
+ scan_id = str(requirement_overview1.scan.id)
+
+ # Mock a failed task
+ with patch.object(
+ ComplianceOverviewViewSet, "get_task_response_if_running"
+ ) as mock_task_response:
+ mock_task_response.side_effect = TaskFailedException("Task failed")
+
+ response = authenticated_client.get(
+ reverse("complianceoverview-list"),
+ {"filter[scan_id]": scan_id},
+ )
+ assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
+ assert "Task failed to generate compliance overview data" in str(
+ response.data
+ )
@pytest.mark.parametrize(
- "filter_name, filter_value, expected_count",
+ "filter_name, filter_value_attr, expected_count_min",
[
- ("compliance_id", "aws_account_security_onboarding_aws", 1),
- ("compliance_id.icontains", "security_onboarding", 1),
- ("framework", "AWS-Account-Security-Onboarding", 1),
- ("framework.icontains", "security-onboarding", 1),
- ("version", "1.0", 1),
- ("version", "2.0", 0),
- ("version.icontains", "0", 1),
- ("region", "eu-west-1", 1),
- ("region.icontains", "west-1", 1),
- ("region.in", "eu-west-1,eu-west-2", 1),
- ("inserted_at.date", "2024-01-01", 0),
- ("inserted_at.date", TODAY, 1),
- ("inserted_at.gte", "2024-01-01", 1),
+ ("scan_id", "scan.id", 1),
+ ("compliance_id", "compliance_id", 1),
+ ("framework", "framework", 1),
+ ("version", "version", 1),
+ ("region", "region", 1),
],
)
def test_compliance_overview_filters(
self,
authenticated_client,
- compliance_overviews_fixture,
+ compliance_requirements_overviews_fixture,
filter_name,
- filter_value,
- expected_count,
+ filter_value_attr,
+ expected_count_min,
):
- # Test filtering compliance overviews
- compliance_overview1 = compliance_overviews_fixture[0]
- scan_id = str(compliance_overview1.scan.id)
+ requirement_overview = compliance_requirements_overviews_fixture[0]
+ scan_id = str(requirement_overview.scan.id)
+
+ filter_value = requirement_overview
+ for attr in filter_value_attr.split("."):
+ filter_value = getattr(filter_value, attr)
+
+ filter_value = str(filter_value)
+
+ query_params = {
+ "filter[scan_id]": scan_id,
+ f"filter[{filter_name}]": filter_value,
+ }
+
+ if filter_name == "scan_id":
+ query_params = {"filter[scan_id]": filter_value}
response = authenticated_client.get(
reverse("complianceoverview-list"),
- {
- "filter[scan_id]": scan_id,
- f"filter[{filter_name}]": filter_value,
- },
- )
- assert response.status_code == status.HTTP_200_OK
- assert len(response.json()["data"]) == expected_count
-
- @pytest.mark.parametrize(
- "filter_name",
- ["invalid_filter", "unknown_field"],
- )
- def test_compliance_overview_filters_invalid(
- self, authenticated_client, compliance_overviews_fixture, filter_name
- ):
- # Test handling of invalid filters
- compliance_overview1 = compliance_overviews_fixture[0]
- scan_id = str(compliance_overview1.scan.id)
-
- response = authenticated_client.get(
- reverse("complianceoverview-list"),
- {
- "filter[scan_id]": scan_id,
- f"filter[{filter_name}]": "some_value",
- },
- )
- assert response.status_code == status.HTTP_400_BAD_REQUEST
-
- @pytest.mark.parametrize(
- "sort_field",
- ["inserted_at", "-inserted_at", "compliance_id", "-compliance_id"],
- )
- def test_compliance_overview_sort(
- self, authenticated_client, compliance_overviews_fixture, sort_field
- ):
- # Test sorting compliance overviews
- compliance_overview1 = compliance_overviews_fixture[0]
- scan_id = str(compliance_overview1.scan.id)
-
- response = authenticated_client.get(
- reverse("complianceoverview-list"),
- {
- "filter[scan_id]": scan_id,
- "sort": sort_field,
- },
- )
- assert response.status_code == status.HTTP_200_OK
-
- def test_compliance_overview_sort_invalid(
- self, authenticated_client, compliance_overviews_fixture
- ):
- # Test handling of invalid sort parameters
- compliance_overview1 = compliance_overviews_fixture[0]
- scan_id = str(compliance_overview1.scan.id)
-
- response = authenticated_client.get(
- reverse("complianceoverview-list"),
- {
- "filter[scan_id]": scan_id,
- "sort": "invalid_field",
- },
- )
- assert response.status_code == status.HTTP_400_BAD_REQUEST
- assert response.json()["errors"][0]["code"] == "invalid"
- assert "invalid sort parameter" in response.json()["errors"][0]["detail"]
-
- def test_compliance_overview_retrieve(
- self, authenticated_client, compliance_overviews_fixture
- ):
- # Retrieve a specific compliance overview
- compliance_overview1 = compliance_overviews_fixture[0]
-
- response = authenticated_client.get(
- reverse(
- "complianceoverview-detail",
- kwargs={"pk": compliance_overview1.id},
- ),
- )
- assert response.status_code == status.HTTP_200_OK
- data = response.json()["data"]
- assert data["id"] == str(compliance_overview1.id)
- attributes = data["attributes"]
- assert attributes["compliance_id"] == compliance_overview1.compliance_id
- assert attributes["framework"] == compliance_overview1.framework
- assert attributes["version"] == compliance_overview1.version
- assert attributes["region"] == compliance_overview1.region
- assert attributes["description"] == compliance_overview1.description
- assert "requirements" in attributes
-
- def test_compliance_overview_invalid_retrieve(self, authenticated_client):
- # Attempt to retrieve a compliance overview with an invalid ID
- response = authenticated_client.get(
- reverse(
- "complianceoverview-detail",
- kwargs={"pk": "invalid-id"},
- ),
- )
- assert response.status_code == status.HTTP_404_NOT_FOUND
-
- def test_compliance_overview_list_queryset(
- self, authenticated_client, compliance_overviews_fixture
- ):
- compliance_overview1, compliance_overview2 = compliance_overviews_fixture
- scan_id = str(compliance_overview1.scan.id)
-
- response = authenticated_client.get(
- reverse("complianceoverview-list"),
- {"filter[scan_id]": scan_id},
- )
- # No filters, most fails should be returned
- assert len(response.json()["data"]) == 1
- assert response.json()["data"][0]["id"] == str(compliance_overview2.id)
-
- compliance_overview1.requirements_failed = 5
- compliance_overview1.save()
-
- response = authenticated_client.get(
- reverse("complianceoverview-list"),
- {"filter[scan_id]": scan_id},
- )
- # No filters, now compliance_overview1 has more fails
- assert len(response.json()["data"]) == 1
- assert response.json()["data"][0]["id"] == str(compliance_overview1.id)
-
- def test_compliance_overview_metadata(
- self, authenticated_client, compliance_overviews_fixture
- ):
- response = authenticated_client.get(
- reverse("complianceoverview-metadata"),
- {"filter[scan_id]": str(compliance_overviews_fixture[0].scan_id)},
- )
- data = response.json()
-
- expected_regions = set(
- ComplianceOverview.objects.all()
- .values_list("region", flat=True)
- .distinct("region")
+ query_params,
)
assert response.status_code == status.HTTP_200_OK
- assert data["data"]["type"] == "compliance-overviews-metadata"
- assert data["data"]["id"] is None
- assert set(data["data"]["attributes"]["regions"]) == expected_regions
+ response_data = response.json()
- def test_compliance_overview_metadata_missing_scan_id(self, authenticated_client):
- # Attempt to list compliance overviews without providing filter[scan_id]
- response = authenticated_client.get(reverse("complianceoverview-metadata"))
- assert response.status_code == status.HTTP_400_BAD_REQUEST
- assert response.json()["errors"][0]["source"]["pointer"] == "filter[scan_id]"
- assert response.json()["errors"][0]["code"] == "required"
+ assert len(response_data["data"]) >= expected_count_min
+
+ if response_data["data"]:
+ first_item = response_data["data"][0]
+ assert "id" in first_item
+ assert "type" in first_item
+ assert first_item["type"] == "compliance-overviews"
+ assert "attributes" in first_item
+
+ attributes = first_item["attributes"]
+ assert "framework" in attributes
+ assert "version" in attributes
+ assert "requirements_passed" in attributes
+ assert "requirements_failed" in attributes
+ assert "requirements_manual" in attributes
+ assert "total_requirements" in attributes
+
+ if filter_name == "compliance_id":
+ assert first_item["id"] == filter_value
+ elif filter_name == "framework":
+ assert attributes["framework"] == filter_value
+ elif filter_name == "version":
+ assert attributes["version"] == filter_value
@pytest.mark.django_db
diff --git a/api/src/backend/api/v1/mixins.py b/api/src/backend/api/v1/mixins.py
index 85250c0eef..fde14a23c5 100644
--- a/api/src/backend/api/v1/mixins.py
+++ b/api/src/backend/api/v1/mixins.py
@@ -1,5 +1,16 @@
+from django.urls import reverse
+from django_celery_results.models import TaskResult
+from rest_framework import status
from rest_framework.response import Response
+from api.exceptions import (
+ TaskFailedException,
+ TaskInProgressException,
+ TaskNotFoundException,
+)
+from api.models import StateChoices, Task
+from api.v1.serializers import TaskSerializer
+
class PaginateByPkMixin:
"""
@@ -31,3 +42,181 @@ class PaginateByPkMixin:
serialized = self.get_serializer(queryset, many=True).data
return self.get_paginated_response(serialized)
+
+
+class TaskManagementMixin:
+ """
+ Mixin to manage task status checking.
+
+ This mixin provides functionality to check if a task with specific parameters
+ is running, completed, failed, or doesn't exist. It returns the task when running
+ and raises specific exceptions for failed/not found scenarios that can be handled
+ at the view level.
+ """
+
+ def check_task_status(
+ self,
+ task_name: str,
+ task_kwargs: dict,
+ raise_on_failed: bool = True,
+ raise_on_not_found: bool = True,
+ ) -> Task | None:
+ """
+ Check the status of a task with given name and kwargs.
+
+ This method first checks for a related Task object, and if not found,
+ checks TaskResult directly. If a TaskResult is found and running but
+ there's no related Task, it raises TaskInProgressException.
+
+ Args:
+ task_name (str): The name of the task to check
+ task_kwargs (dict): The kwargs to match against the task
+ raise_on_failed (bool): Whether to raise exception if task failed
+ raise_on_not_found (bool): Whether to raise exception if task not found
+
+ Returns:
+ Task | None: The task instance if found (regardless of state), None if not found and raise_on_not_found=False
+
+ Raises:
+ TaskFailedException: If task failed and raise_on_failed=True
+ TaskNotFoundException: If task not found and raise_on_not_found=True
+ TaskInProgressException: If task is running but no related Task object exists
+ """
+ # First, try to find a Task object with related TaskResult
+ try:
+ # Build the filter for task kwargs
+ task_filter = {
+ "task_runner_task__task_name": task_name,
+ }
+
+ # Add kwargs filters - we need to check if the task kwargs contain our parameters
+ for key, value in task_kwargs.items():
+ task_filter["task_runner_task__task_kwargs__contains"] = str(value)
+
+ task = (
+ Task.objects.filter(**task_filter)
+ .select_related("task_runner_task")
+ .order_by("-inserted_at")
+ .first()
+ )
+
+ if task:
+ # Get task state using the same logic as TaskSerializer
+ task_state_mapping = {
+ "PENDING": StateChoices.AVAILABLE,
+ "STARTED": StateChoices.EXECUTING,
+ "PROGRESS": StateChoices.EXECUTING,
+ "SUCCESS": StateChoices.COMPLETED,
+ "FAILURE": StateChoices.FAILED,
+ "REVOKED": StateChoices.CANCELLED,
+ }
+
+ celery_status = (
+ task.task_runner_task.status if task.task_runner_task else None
+ )
+ task_state = task_state_mapping.get(
+ celery_status or "", StateChoices.AVAILABLE
+ )
+
+ # Check task state and raise exceptions accordingly
+ if task_state in (StateChoices.FAILED, StateChoices.CANCELLED):
+ if raise_on_failed:
+ raise TaskFailedException(task=task)
+ return task
+ elif task_state == StateChoices.COMPLETED:
+ return None
+
+ return task
+
+ except Task.DoesNotExist:
+ pass
+
+ # If no Task found, check TaskResult directly
+ try:
+ # Build the filter for TaskResult
+ task_result_filter = {
+ "task_name": task_name,
+ }
+
+ # Add kwargs filters - check if the task kwargs contain our parameters
+ for key, value in task_kwargs.items():
+ task_result_filter["task_kwargs__contains"] = str(value)
+
+ task_result = (
+ TaskResult.objects.filter(**task_result_filter)
+ .order_by("-date_created")
+ .first()
+ )
+
+ if task_result:
+ # Check if the TaskResult indicates a running task
+ if task_result.status in ["PENDING", "STARTED", "PROGRESS"]:
+ # Task is running but no related Task object exists
+ raise TaskInProgressException(task_result=task_result)
+ elif task_result.status == "FAILURE":
+ if raise_on_failed:
+ raise TaskFailedException(task=None)
+ # For other statuses (SUCCESS, REVOKED), we don't have a Task to return,
+ # so we treat it as not found
+
+ except TaskResult.DoesNotExist:
+ pass
+
+ # No task found at all
+ if raise_on_not_found:
+ raise TaskNotFoundException()
+ return None
+
+ def get_task_response_if_running(
+ self,
+ task_name: str,
+ task_kwargs: dict,
+ raise_on_failed: bool = True,
+ raise_on_not_found: bool = True,
+ ) -> Response | None:
+ """
+ Get a 202 response with task details if the task is currently running.
+
+ This method is useful for endpoints that should return task status when
+ a background task is in progress, similar to the compliance overview endpoints.
+
+ Args:
+ task_name (str): The name of the task to check
+ task_kwargs (dict): The kwargs to match against the task
+
+ Returns:
+ Response | None: 202 response with task details if running, None otherwise
+ """
+ task = self.check_task_status(
+ task_name=task_name,
+ task_kwargs=task_kwargs,
+ raise_on_failed=raise_on_failed,
+ raise_on_not_found=raise_on_not_found,
+ )
+
+ if not task:
+ return None
+
+ # Get task state
+ task_state_mapping = {
+ "PENDING": StateChoices.AVAILABLE,
+ "STARTED": StateChoices.EXECUTING,
+ "PROGRESS": StateChoices.EXECUTING,
+ "SUCCESS": StateChoices.COMPLETED,
+ "FAILURE": StateChoices.FAILED,
+ "REVOKED": StateChoices.CANCELLED,
+ }
+
+ celery_status = task.task_runner_task.status if task.task_runner_task else None
+ task_state = task_state_mapping.get(celery_status or "", StateChoices.AVAILABLE)
+
+ if task_state == StateChoices.EXECUTING:
+ self.response_serializer_class = TaskSerializer
+ serializer = TaskSerializer(task)
+ return Response(
+ data=serializer.data,
+ status=status.HTTP_202_ACCEPTED,
+ headers={
+ "Content-Location": reverse("task-detail", kwargs={"pk": task.id})
+ },
+ )
diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py
index 7beb311d5f..ad3cf71813 100644
--- a/api/src/backend/api/v1/serializers.py
+++ b/api/src/backend/api/v1/serializers.py
@@ -14,7 +14,6 @@ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.tokens import RefreshToken
from api.models import (
- ComplianceOverview,
Finding,
Integration,
IntegrationProviderRelationship,
@@ -31,6 +30,7 @@ from api.models import (
RoleProviderGroupRelationship,
Scan,
StateChoices,
+ StatusChoices,
Task,
User,
UserRoleRelationship,
@@ -1679,130 +1679,61 @@ class RoleProviderGroupRelationshipSerializer(RLSSerializer, BaseWriteSerializer
# Compliance overview
-class ComplianceOverviewSerializer(RLSSerializer):
+class ComplianceOverviewSerializer(serializers.Serializer):
"""
- Serializer for the ComplianceOverview model.
+ Serializer for compliance requirement status aggregated by compliance framework.
+
+ This serializer is used to format aggregated compliance framework data,
+ providing counts of passed, failed, and manual requirements along with
+ an overall global status for each framework.
"""
- requirements_status = serializers.SerializerMethodField(
- read_only=True, method_name="get_requirements_status"
- )
- provider_type = serializers.SerializerMethodField(read_only=True)
+ # Add ID field which will be used for resource identification
+ id = serializers.CharField()
+ framework = serializers.CharField()
+ version = serializers.CharField()
+ requirements_passed = serializers.IntegerField()
+ requirements_failed = serializers.IntegerField()
+ requirements_manual = serializers.IntegerField()
+ total_requirements = serializers.IntegerField()
- class Meta:
- model = ComplianceOverview
- fields = [
- "id",
- "inserted_at",
- "compliance_id",
- "framework",
- "version",
- "requirements_status",
- "region",
- "provider_type",
- "scan",
- "url",
- ]
-
- @extend_schema_field(
- {
- "type": "object",
- "properties": {
- "passed": {"type": "integer"},
- "failed": {"type": "integer"},
- "manual": {"type": "integer"},
- "total": {"type": "integer"},
- },
- }
- )
- def get_requirements_status(self, obj):
- return {
- "passed": obj.requirements_passed,
- "failed": obj.requirements_failed,
- "manual": obj.requirements_manual,
- "total": obj.total_requirements,
- }
-
- @extend_schema_field(serializers.CharField(allow_null=True))
- def get_provider_type(self, obj):
- """
- Retrieves the provider_type from scan.provider.provider_type.
- """
- try:
- return obj.scan.provider.provider
- except AttributeError:
- return None
+ class JSONAPIMeta:
+ resource_name = "compliance-overviews"
-class ComplianceOverviewFullSerializer(ComplianceOverviewSerializer):
- requirements = serializers.SerializerMethodField(read_only=True)
+class ComplianceOverviewDetailSerializer(serializers.Serializer):
+ """
+ Serializer for detailed compliance requirement information.
- class Meta(ComplianceOverviewSerializer.Meta):
- fields = ComplianceOverviewSerializer.Meta.fields + [
- "description",
- "requirements",
- ]
+ This serializer formats the aggregated requirement data, showing detailed status
+ and counts for each requirement across all regions.
+ """
- @extend_schema_field(
- {
- "type": "object",
- "properties": {
- "requirement_id": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "checks": {
- "type": "object",
- "properties": {
- "check_name": {
- "type": "object",
- "properties": {
- "status": {
- "type": "string",
- "enum": ["PASS", "FAIL", None],
- },
- },
- }
- },
- "description": "Each key in the 'checks' object is a check name, with values as "
- "'PASS', 'FAIL', or null.",
- },
- "status": {
- "type": "string",
- "enum": ["PASS", "FAIL", "MANUAL"],
- },
- "attributes": {
- "type": "array",
- "items": {
- "type": "object",
- },
- },
- "description": {"type": "string"},
- "checks_status": {
- "type": "object",
- "properties": {
- "total": {"type": "integer"},
- "pass": {"type": "integer"},
- "fail": {"type": "integer"},
- "manual": {"type": "integer"},
- },
- },
- },
- }
- },
- }
- )
- def get_requirements(self, obj):
- """
- Returns the detailed structure of requirements.
- """
- return obj.requirements
+ id = serializers.CharField()
+ framework = serializers.CharField()
+ version = serializers.CharField()
+ description = serializers.CharField()
+ status = serializers.ChoiceField(choices=StatusChoices.choices)
+
+ class JSONAPIMeta:
+ resource_name = "compliance-requirements-details"
+
+
+class ComplianceOverviewAttributesSerializer(serializers.Serializer):
+ id = serializers.CharField()
+ framework = serializers.CharField()
+ version = serializers.CharField()
+ description = serializers.CharField()
+ attributes = serializers.JSONField()
+
+ class JSONAPIMeta:
+ resource_name = "compliance-requirements-attributes"
class ComplianceOverviewMetadataSerializer(serializers.Serializer):
regions = serializers.ListField(child=serializers.CharField(), allow_empty=True)
- class Meta:
+ class JSONAPIMeta:
resource_name = "compliance-overviews-metadata"
diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py
index 8fc87d2808..394913cb6c 100644
--- a/api/src/backend/api/v1/views.py
+++ b/api/src/backend/api/v1/views.py
@@ -17,7 +17,7 @@ from django.conf import settings as django_settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.search import SearchQuery
from django.db import transaction
-from django.db.models import Count, Exists, F, OuterRef, Prefetch, Q, Subquery, Sum
+from django.db.models import Count, Exists, F, OuterRef, Prefetch, Q, Sum
from django.db.models.functions import Coalesce
from django.http import HttpResponse
from django.urls import reverse
@@ -26,10 +26,10 @@ from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django_celery_beat.models import PeriodicTask
from drf_spectacular.settings import spectacular_settings
+from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
- OpenApiTypes,
extend_schema,
extend_schema_view,
)
@@ -58,8 +58,12 @@ from tasks.tasks import (
)
from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
-from api.compliance import get_compliance_frameworks
+from api.compliance import (
+ PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
+ get_compliance_frameworks,
+)
from api.db_router import MainRouter
+from api.exceptions import TaskFailedException
from api.filters import (
ComplianceOverviewFilter,
FindingFilter,
@@ -81,6 +85,7 @@ from api.filters import (
)
from api.models import (
ComplianceOverview,
+ ComplianceRequirementOverview,
Finding,
Integration,
Invitation,
@@ -111,9 +116,10 @@ from api.utils import (
validate_invitation,
)
from api.uuid_utils import datetime_to_uuid7, uuid7_start
-from api.v1.mixins import PaginateByPkMixin
+from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin
from api.v1.serializers import (
- ComplianceOverviewFullSerializer,
+ ComplianceOverviewAttributesSerializer,
+ ComplianceOverviewDetailSerializer,
ComplianceOverviewMetadataSerializer,
ComplianceOverviewSerializer,
FindingDynamicFilterSerializer,
@@ -2391,8 +2397,7 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
list=extend_schema(
tags=["Compliance Overview"],
summary="List compliance overviews for a scan",
- description="Retrieve an overview of all the compliance in a given scan. If no region filters are provided, the"
- " region with the most fails will be returned by default.",
+ description="Retrieve an overview of all the compliance in a given scan.",
parameters=[
OpenApiParameter(
name="filter[scan_id]",
@@ -2402,12 +2407,18 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
description="Related scan ID.",
),
],
- ),
- retrieve=extend_schema(
- tags=["Compliance Overview"],
- summary="Retrieve data from a specific compliance overview",
- description="Fetch detailed information about a specific compliance overview by its ID, including detailed "
- "requirement information and check's status.",
+ responses={
+ 200: OpenApiResponse(
+ description="Compliance overviews obtained successfully",
+ response=ComplianceOverviewSerializer(many=True),
+ ),
+ 202: OpenApiResponse(
+ description="The task is in progress", response=TaskSerializer
+ ),
+ 500: OpenApiResponse(
+ description="Compliance overviews generation task failed"
+ ),
+ },
),
metadata=extend_schema(
tags=["Compliance Overview"],
@@ -2423,19 +2434,84 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
description="Related scan ID.",
),
],
+ responses={
+ 200: OpenApiResponse(
+ description="Compliance overviews metadata obtained successfully",
+ response=ComplianceOverviewMetadataSerializer,
+ ),
+ 202: OpenApiResponse(description="The task is in progress"),
+ 500: OpenApiResponse(
+ description="Compliance overviews generation task failed"
+ ),
+ },
+ ),
+ requirements=extend_schema(
+ tags=["Compliance Overview"],
+ summary="List compliance requirements overview for a scan",
+ description="Retrieve a detailed overview of compliance requirements in a given scan, grouped by compliance "
+ "framework. This endpoint provides requirement-level details and aggregates status across regions.",
+ parameters=[
+ OpenApiParameter(
+ name="filter[scan_id]",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.QUERY,
+ description="Related scan ID.",
+ ),
+ OpenApiParameter(
+ name="filter[compliance_id]",
+ required=True,
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ description="Compliance ID.",
+ ),
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="Compliance requirement details obtained successfully",
+ response=ComplianceOverviewDetailSerializer(many=True),
+ ),
+ 202: OpenApiResponse(description="The task is in progress"),
+ 500: OpenApiResponse(
+ description="Compliance overviews generation task failed"
+ ),
+ },
+ filters=True,
+ ),
+ attributes=extend_schema(
+ tags=["Compliance Overview"],
+ summary="Get compliance requirement attributes",
+ description="Retrieve detailed attribute information for all requirements in a specific compliance framework "
+ "along with the associated check IDs for each requirement.",
+ parameters=[
+ OpenApiParameter(
+ name="filter[compliance_id]",
+ required=True,
+ type=str,
+ location=OpenApiParameter.QUERY,
+ description="Compliance framework ID to get attributes for.",
+ ),
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="Compliance attributes obtained successfully",
+ response=ComplianceOverviewAttributesSerializer(many=True),
+ ),
+ },
),
)
@method_decorator(CACHE_DECORATOR, name="list")
-@method_decorator(CACHE_DECORATOR, name="retrieve")
-class ComplianceOverviewViewSet(BaseRLSViewSet):
+@method_decorator(CACHE_DECORATOR, name="requirements")
+@method_decorator(CACHE_DECORATOR, name="attributes")
+class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
pagination_class = ComplianceOverviewPagination
- queryset = ComplianceOverview.objects.all()
+ queryset = ComplianceRequirementOverview.objects.all()
serializer_class = ComplianceOverviewSerializer
filterset_class = ComplianceOverviewFilter
http_method_names = ["get"]
search_fields = ["compliance_id"]
ordering = ["compliance_id"]
- ordering_fields = ["inserted_at", "compliance_id", "framework", "region"]
+ ordering_fields = ["compliance_id"]
# RBAC required permissions (implicit -> MANAGE_PROVIDERS enable unlimited visibility or check the visibility of
# the provider through the provider group)
required_permissions = []
@@ -2446,51 +2522,44 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
role, Permissions.UNLIMITED_VISIBILITY.value, False
)
- if self.action == "retrieve":
- if unlimited_visibility:
- # User has unlimited visibility, return all compliance
- return ComplianceOverview.objects.filter(
- tenant_id=self.request.tenant_id
- )
-
- providers = get_providers(role)
- return ComplianceOverview.objects.filter(
- tenant_id=self.request.tenant_id, scan__provider__in=providers
- )
-
if unlimited_visibility:
base_queryset = self.filter_queryset(
- ComplianceOverview.objects.filter(tenant_id=self.request.tenant_id)
+ ComplianceRequirementOverview.objects.filter(
+ tenant_id=self.request.tenant_id
+ )
)
else:
providers = Provider.objects.filter(
provider_groups__in=role.provider_groups.all()
).distinct()
base_queryset = self.filter_queryset(
- ComplianceOverview.objects.filter(
+ ComplianceRequirementOverview.objects.filter(
tenant_id=self.request.tenant_id, scan__provider__in=providers
)
)
- max_failed_ids = (
- base_queryset.filter(compliance_id=OuterRef("compliance_id"))
- .order_by("-requirements_failed")
- .values("id")[:1]
- )
-
- return base_queryset.filter(id__in=Subquery(max_failed_ids)).order_by(
- "compliance_id"
- )
+ return base_queryset
def get_serializer_class(self):
- if self.action == "retrieve":
- return ComplianceOverviewFullSerializer
+ if hasattr(self, "response_serializer_class"):
+ return self.response_serializer_class
+ elif self.action == "list":
+ return ComplianceOverviewSerializer
elif self.action == "metadata":
return ComplianceOverviewMetadataSerializer
+ elif self.action == "attributes":
+ return ComplianceOverviewAttributesSerializer
+ elif self.action == "requirements":
+ return ComplianceOverviewDetailSerializer
return super().get_serializer_class()
+ @extend_schema(exclude=True)
+ def retrieve(self, request, *args, **kwargs):
+ raise MethodNotAllowed(method="GET")
+
def list(self, request, *args, **kwargs):
- if not request.query_params.get("filter[scan_id]"):
+ scan_id = request.query_params.get("filter[scan_id]")
+ if not scan_id:
raise ValidationError(
[
{
@@ -2501,7 +2570,82 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
}
]
)
- return super().list(request, *args, **kwargs)
+ try:
+ if task := self.get_task_response_if_running(
+ task_name="scan-compliance-overviews",
+ task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id},
+ raise_on_not_found=False,
+ ):
+ return task
+ except TaskFailedException:
+ return Response(
+ {"detail": "Task failed to generate compliance overview data."},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
+ queryset = self.filter_queryset(self.filter_queryset(self.get_queryset()))
+
+ requirement_status_subquery = queryset.values(
+ "compliance_id", "requirement_id"
+ ).annotate(
+ fail_count=Count("id", filter=Q(requirement_status="FAIL")),
+ pass_count=Count("id", filter=Q(requirement_status="PASS")),
+ total_count=Count("id"),
+ )
+
+ compliance_data = {}
+ framework_info = {}
+
+ for item in queryset.values("compliance_id", "framework", "version").distinct():
+ framework_info[item["compliance_id"]] = {
+ "framework": item["framework"],
+ "version": item["version"],
+ }
+
+ for item in requirement_status_subquery:
+ compliance_id = item["compliance_id"]
+
+ if item["fail_count"] > 0:
+ req_status = "FAIL"
+ elif item["pass_count"] == item["total_count"]:
+ req_status = "PASS"
+ else:
+ req_status = "MANUAL"
+
+ if compliance_id not in compliance_data:
+ compliance_data[compliance_id] = {
+ "total_requirements": 0,
+ "requirements_passed": 0,
+ "requirements_failed": 0,
+ "requirements_manual": 0,
+ }
+
+ compliance_data[compliance_id]["total_requirements"] += 1
+ if req_status == "PASS":
+ compliance_data[compliance_id]["requirements_passed"] += 1
+ elif req_status == "FAIL":
+ compliance_data[compliance_id]["requirements_failed"] += 1
+ else:
+ compliance_data[compliance_id]["requirements_manual"] += 1
+
+ response_data = []
+ for compliance_id, data in compliance_data.items():
+ framework = framework_info.get(compliance_id, {})
+
+ response_data.append(
+ {
+ "id": compliance_id,
+ "compliance_id": compliance_id,
+ "framework": framework.get("framework", ""),
+ "version": framework.get("version", ""),
+ "requirements_passed": data["requirements_passed"],
+ "requirements_failed": data["requirements_failed"],
+ "requirements_manual": data["requirements_manual"],
+ "total_requirements": data["total_requirements"],
+ }
+ )
+
+ serializer = self.get_serializer(response_data, many=True)
+ return Response(serializer.data)
@action(detail=False, methods=["get"], url_name="metadata")
def metadata(self, request):
@@ -2517,11 +2661,21 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
}
]
)
-
- tenant_id = self.request.tenant_id
-
+ try:
+ if task := self.get_task_response_if_running(
+ task_name="scan-compliance-overviews",
+ task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id},
+ raise_on_not_found=False,
+ ):
+ return task
+ except TaskFailedException:
+ return Response(
+ {"detail": "Task failed to generate compliance overview data."},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
regions = list(
- ComplianceOverview.objects.filter(tenant_id=tenant_id, scan_id=scan_id)
+ self.get_queryset()
+ .filter(scan_id=scan_id)
.values_list("region", flat=True)
.order_by("region")
.distinct()
@@ -2532,6 +2686,152 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
serializer.is_valid(raise_exception=True)
return Response(serializer.data, status=status.HTTP_200_OK)
+ @action(detail=False, methods=["get"], url_name="requirements")
+ def requirements(self, request):
+ scan_id = request.query_params.get("filter[scan_id]")
+ compliance_id = request.query_params.get("filter[compliance_id]")
+
+ if not scan_id:
+ raise ValidationError(
+ [
+ {
+ "detail": "This query parameter is required.",
+ "status": 400,
+ "source": {"pointer": "filter[scan_id]"},
+ "code": "required",
+ }
+ ]
+ )
+
+ if not compliance_id:
+ raise ValidationError(
+ [
+ {
+ "detail": "This query parameter is required.",
+ "status": 400,
+ "source": {"pointer": "filter[compliance_id]"},
+ "code": "required",
+ }
+ ]
+ )
+ try:
+ if task := self.get_task_response_if_running(
+ task_name="scan-compliance-overviews",
+ task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id},
+ raise_on_not_found=False,
+ ):
+ return task
+ except TaskFailedException:
+ return Response(
+ {"detail": "Task failed to generate compliance overview data."},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
+ filtered_queryset = self.filter_queryset(self.get_queryset())
+
+ all_requirements = (
+ filtered_queryset.values(
+ "requirement_id", "framework", "version", "description"
+ )
+ .distinct()
+ .annotate(total_instances=Count("id"))
+ )
+
+ passed_instances = (
+ filtered_queryset.filter(requirement_status="PASS")
+ .values("requirement_id")
+ .annotate(pass_count=Count("id"))
+ )
+
+ passed_counts = {
+ item["requirement_id"]: item["pass_count"] for item in passed_instances
+ }
+
+ requirements_summary = []
+ for requirement in all_requirements:
+ 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"
+
+ requirements_summary.append(
+ {
+ "id": requirement_id,
+ "framework": requirement["framework"],
+ "version": requirement["version"],
+ "description": requirement["description"],
+ "status": requirement_status,
+ }
+ )
+
+ serializer = self.get_serializer(requirements_summary, many=True)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ @action(detail=False, methods=["get"], url_name="attributes")
+ def attributes(self, request):
+ compliance_id = request.query_params.get("filter[compliance_id]")
+ if not compliance_id:
+ raise ValidationError(
+ [
+ {
+ "detail": "This query parameter is required.",
+ "status": 400,
+ "source": {"pointer": "filter[compliance_id]"},
+ "code": "required",
+ }
+ ]
+ )
+
+ provider_type = None
+ try:
+ sample_requirement = (
+ self.get_queryset().filter(compliance_id=compliance_id).first()
+ )
+
+ if sample_requirement:
+ provider_type = sample_requirement.scan.provider.provider
+ except Exception:
+ pass
+
+ # If we couldn't determine from database, try each provider type
+ if not provider_type:
+ for pt in Provider.ProviderChoices.values:
+ if compliance_id in PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get(pt, {}):
+ provider_type = pt
+ break
+
+ if not provider_type:
+ raise NotFound(detail=f"Compliance framework '{compliance_id}' not found.")
+
+ compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get(
+ provider_type, {}
+ )
+ compliance_framework = compliance_template.get(compliance_id)
+
+ if not compliance_framework:
+ raise NotFound(detail=f"Compliance framework '{compliance_id}' not found.")
+
+ attribute_data = []
+ for requirement_id, requirement in compliance_framework.get(
+ "requirements", {}
+ ).items():
+ check_ids = list(requirement.get("checks", {}).keys())
+
+ metadata = requirement.get("attributes", [])
+
+ attribute_data.append(
+ {
+ "id": requirement_id,
+ "framework": compliance_framework.get("framework", ""),
+ "version": compliance_framework.get("version", ""),
+ "description": requirement.get("description", ""),
+ "attributes": {"metadata": metadata, "check_ids": check_ids},
+ }
+ )
+
+ serializer = self.get_serializer(attribute_data, many=True)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
@extend_schema(tags=["Overview"])
@extend_schema_view(
@@ -2578,7 +2878,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet):
class OverviewViewSet(BaseRLSViewSet):
queryset = ComplianceOverview.objects.all()
http_method_names = ["get"]
- ordering = ["-id"]
+ ordering = ["-inserted_at"]
# RBAC required permissions (implicit -> MANAGE_PROVIDERS enable unlimited visibility or check the visibility of
# the provider through the provider group)
required_permissions = []
diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py
index 8f3f0bb42e..5de1d9ca71 100644
--- a/api/src/backend/config/django/base.py
+++ b/api/src/backend/config/django/base.py
@@ -26,6 +26,7 @@ INSTALLED_APPS = [
"rest_framework",
"corsheaders",
"drf_spectacular",
+ "drf_spectacular_jsonapi",
"django_guid",
"rest_framework_json_api",
"django_celery_results",
diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py
index 8f58c447de..be215ee59c 100644
--- a/api/src/backend/conftest.py
+++ b/api/src/backend/conftest.py
@@ -15,6 +15,7 @@ from tasks.jobs.backfill import backfill_resource_scan_summaries
from api.db_utils import rls_transaction
from api.models import (
ComplianceOverview,
+ ComplianceRequirementOverview,
Finding,
Integration,
IntegrationProviderRelationship,
@@ -29,6 +30,7 @@ from api.models import (
Scan,
ScanSummary,
StateChoices,
+ StatusChoices,
Task,
User,
UserRoleRelationship,
@@ -777,6 +779,98 @@ def compliance_overviews_fixture(scans_fixture, tenants_fixture):
return compliance_overview1, compliance_overview2
+@pytest.fixture
+def compliance_requirements_overviews_fixture(scans_fixture, tenants_fixture):
+ """Fixture for ComplianceRequirementOverview objects used by the new ComplianceOverviewViewSet."""
+ tenant = tenants_fixture[0]
+ scan1, scan2, scan3 = scans_fixture
+
+ # Create ComplianceRequirementOverview objects for scan1
+ requirement_overview1 = 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",
+ region="eu-west-1",
+ requirement_id="requirement1",
+ requirement_status=StatusChoices.PASS,
+ passed_checks=2,
+ failed_checks=0,
+ total_checks=2,
+ )
+
+ requirement_overview2 = 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",
+ region="eu-west-1",
+ requirement_id="requirement2",
+ requirement_status=StatusChoices.PASS,
+ passed_checks=2,
+ failed_checks=0,
+ total_checks=2,
+ )
+
+ requirement_overview3 = 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",
+ region="eu-west-2",
+ requirement_id="requirement1",
+ requirement_status=StatusChoices.PASS,
+ passed_checks=2,
+ failed_checks=0,
+ total_checks=2,
+ )
+
+ requirement_overview4 = 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",
+ region="eu-west-2",
+ requirement_id="requirement2",
+ requirement_status=StatusChoices.FAIL,
+ passed_checks=1,
+ failed_checks=1,
+ total_checks=2,
+ )
+
+ # Create a different compliance framework for testing
+ requirement_overview5 = ComplianceRequirementOverview.objects.create(
+ tenant=tenant,
+ scan=scan1,
+ compliance_id="cis_1.4_aws",
+ framework="CIS-1.4-AWS",
+ version="1.4",
+ description="CIS AWS Foundations Benchmark v1.4.0",
+ region="eu-west-1",
+ requirement_id="cis_requirement1",
+ requirement_status=StatusChoices.FAIL,
+ passed_checks=0,
+ failed_checks=3,
+ total_checks=3,
+ )
+
+ return (
+ requirement_overview1,
+ requirement_overview2,
+ requirement_overview3,
+ requirement_overview4,
+ requirement_overview5,
+ )
+
+
def get_api_tokens(
api_client, user_email: str, user_password: str, tenant_id: str = None
) -> tuple[str, str]:
diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py
index 1f4f9a8b94..684fbfd759 100644
--- a/api/src/backend/tasks/jobs/scan.py
+++ b/api/src/backend/tasks/jobs/scan.py
@@ -13,9 +13,9 @@ from api.compliance import (
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
generate_scan_compliance,
)
-from api.db_utils import rls_transaction
+from api.db_utils import create_objects_in_batches, rls_transaction
from api.models import (
- ComplianceOverview,
+ ComplianceRequirementOverview,
Finding,
Provider,
Resource,
@@ -119,7 +119,6 @@ def perform_prowler_scan(
ValueError: If the provider cannot be connected.
"""
- check_status_by_region = {}
exception = None
unique_resources = set()
scan_resource_cache: set[tuple[str, str, str, str]] = set()
@@ -293,16 +292,6 @@ def perform_prowler_scan(
)
finding_instance.add_resources([resource_instance])
- # Update compliance data if applicable
- if finding.status.value == "MUTED":
- continue
-
- region_dict = check_status_by_region.setdefault(finding.region, {})
- current_status = region_dict.get(finding.check_id)
- if current_status == "FAIL":
- continue
- region_dict[finding.check_id] = finding.status.value
-
# Update scan resource summaries
scan_resource_cache.add(
(
@@ -335,63 +324,6 @@ def perform_prowler_scan(
if exception is not None:
raise exception
- try:
- regions = prowler_provider.get_regions()
- except AttributeError:
- regions = set()
-
- compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[
- provider_instance.provider
- ]
- compliance_overview_by_region = {
- region: deepcopy(compliance_template) for region in regions
- }
-
- for region, check_status in check_status_by_region.items():
- compliance_data = compliance_overview_by_region.setdefault(
- region, deepcopy(compliance_template)
- )
- for check_name, status in check_status.items():
- generate_scan_compliance(
- compliance_data,
- provider_instance.provider,
- check_name,
- status,
- )
-
- # Prepare compliance overview objects
- compliance_overview_objects = []
- for region, compliance_data in compliance_overview_by_region.items():
- for compliance_id, compliance in compliance_data.items():
- compliance_overview_objects.append(
- ComplianceOverview(
- tenant_id=tenant_id,
- scan=scan_instance,
- region=region,
- compliance_id=compliance_id,
- framework=compliance["framework"],
- version=compliance["version"],
- description=compliance["description"],
- requirements=compliance["requirements"],
- requirements_passed=compliance["requirements_status"]["passed"],
- requirements_failed=compliance["requirements_status"]["failed"],
- requirements_manual=compliance["requirements_status"]["manual"],
- total_requirements=compliance["total_requirements"],
- )
- )
- try:
- with rls_transaction(tenant_id):
- ComplianceOverview.objects.bulk_create(
- compliance_overview_objects, batch_size=500
- )
- except Exception as overview_exception:
- import sentry_sdk
-
- sentry_sdk.capture_exception(overview_exception)
- logger.error(
- f"Error storing compliance overview for scan {scan_id}: {overview_exception}"
- )
-
try:
resource_scan_summaries = [
ResourceScanSummary(
@@ -570,3 +502,114 @@ def aggregate_findings(tenant_id: str, scan_id: str):
for agg in aggregation
}
ScanSummary.objects.bulk_create(scan_aggregations, batch_size=3000)
+
+
+def create_compliance_requirements(tenant_id: str, scan_id: str):
+ """
+ Create detailed compliance requirement overview records for a scan.
+
+ This function processes the compliance data collected during a scan and creates
+ individual records for each compliance requirement in each region. These detailed
+ records provide a granular view of compliance status.
+
+ Args:
+ tenant_id (str): The ID of the tenant for which to create records.
+ scan_id (str): The ID of the scan for which to create records.
+
+ Returns:
+ dict: A dictionary containing the number of requirements created and the regions processed.
+
+ Raises:
+ ValidationError: If tenant_id is not a valid UUID.
+ """
+ try:
+ with rls_transaction(tenant_id):
+ scan_instance = Scan.objects.get(pk=scan_id)
+ provider_instance = scan_instance.provider
+ prowler_provider = initialize_prowler_provider(provider_instance)
+
+ # Get check status data by region from findings
+ check_status_by_region = {}
+ with rls_transaction(tenant_id):
+ findings = Finding.objects.filter(scan_id=scan_id, muted=False)
+ for finding in findings:
+ # Get region from resources
+ for resource in finding.resources.all():
+ region = resource.region
+ region_dict = check_status_by_region.setdefault(region, {})
+ current_status = region_dict.get(finding.check_id)
+ if current_status == "FAIL":
+ continue
+ region_dict[finding.check_id] = finding.status
+
+ try:
+ # Try to get regions from provider
+ regions = prowler_provider.get_regions()
+ except (AttributeError, Exception):
+ # If not available, use regions from findings
+ regions = set(check_status_by_region.keys())
+
+ # Get compliance template for the provider
+ compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[
+ provider_instance.provider
+ ]
+
+ # Create compliance data by region
+ compliance_overview_by_region = {
+ region: deepcopy(compliance_template) for region in regions
+ }
+
+ # Apply check statuses to compliance data
+ for region, check_status in check_status_by_region.items():
+ compliance_data = compliance_overview_by_region.setdefault(
+ region, deepcopy(compliance_template)
+ )
+ for check_name, status in check_status.items():
+ generate_scan_compliance(
+ compliance_data,
+ provider_instance.provider,
+ check_name,
+ status,
+ )
+
+ # Prepare compliance requirement objects
+ compliance_requirement_objects = []
+ for region, compliance_data in compliance_overview_by_region.items():
+ for compliance_id, compliance in compliance_data.items():
+ # Create an overview record for each requirement within each compliance framework
+ for requirement_id, requirement in compliance["requirements"].items():
+ compliance_requirement_objects.append(
+ ComplianceRequirementOverview(
+ tenant_id=tenant_id,
+ scan=scan_instance,
+ region=region,
+ compliance_id=compliance_id,
+ framework=compliance["framework"],
+ version=compliance["version"],
+ requirement_id=requirement_id,
+ description=requirement["description"],
+ passed_checks=requirement["checks_status"]["pass"],
+ failed_checks=requirement["checks_status"]["fail"],
+ total_checks=requirement["checks_status"]["total"],
+ requirement_status=requirement["status"],
+ )
+ )
+
+ # Bulk create requirement records
+ create_objects_in_batches(
+ tenant_id, ComplianceRequirementOverview, compliance_requirement_objects
+ )
+
+ return {
+ "requirements_created": len(compliance_requirement_objects),
+ "regions_processed": list(regions),
+ "compliance_frameworks": (
+ list(compliance_overview_by_region.get(list(regions)[0], {}).keys())
+ if regions
+ else []
+ ),
+ }
+
+ except Exception as e:
+ logger.error(f"Error creating compliance requirements for scan {scan_id}: {e}")
+ raise e
diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py
index 090c0c33d3..4f30b5fc68 100644
--- a/api/src/backend/tasks/tasks.py
+++ b/api/src/backend/tasks/tasks.py
@@ -17,7 +17,11 @@ from tasks.jobs.export import (
_generate_output_directory,
_upload_to_s3,
)
-from tasks.jobs.scan import aggregate_findings, perform_prowler_scan
+from tasks.jobs.scan import (
+ aggregate_findings,
+ create_compliance_requirements,
+ perform_prowler_scan,
+)
from tasks.utils import batched, get_next_execution_datetime
from api.compliance import get_compliance_frameworks
@@ -101,6 +105,7 @@ def perform_scan_task(
chain(
perform_scan_summary_task.si(tenant_id, scan_id),
+ create_compliance_requirements_task.si(tenant_id=tenant_id, scan_id=scan_id),
generate_outputs.si(
scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id
),
@@ -211,6 +216,9 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
chain(
perform_scan_summary_task.si(tenant_id, scan_instance.id),
+ create_compliance_requirements_task.si(
+ tenant_id=tenant_id, scan_id=str(scan_instance.id)
+ ),
generate_outputs.si(
scan_id=str(scan_instance.id), provider_id=provider_id, tenant_id=tenant_id
),
@@ -371,3 +379,19 @@ def backfill_scan_resource_summaries_task(tenant_id: str, scan_id: str):
scan_id (str): The scan identifier.
"""
return backfill_resource_scan_summaries(tenant_id=tenant_id, scan_id=scan_id)
+
+
+@shared_task(base=RLSTask, name="scan-compliance-overviews")
+def create_compliance_requirements_task(tenant_id: str, scan_id: str):
+ """
+ Creates detailed compliance requirement records for a scan.
+
+ This task processes the compliance data collected during a scan and creates
+ individual records for each compliance requirement in each region. These detailed
+ records provide a granular view of compliance status.
+
+ Args:
+ tenant_id (str): The tenant ID for which to create records.
+ scan_id (str): The ID of the scan for which to create records.
+ """
+ return create_compliance_requirements(tenant_id=tenant_id, scan_id=scan_id)
diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py
index a5fde62963..e4ec0d4d41 100644
--- a/api/src/backend/tasks/tests/test_scan.py
+++ b/api/src/backend/tasks/tests/test_scan.py
@@ -7,11 +7,13 @@ import pytest
from tasks.jobs.scan import (
_create_finding_delta,
_store_resources,
+ create_compliance_requirements,
perform_prowler_scan,
)
from tasks.utils import CustomEncoder
from api.models import (
+ ComplianceRequirementOverview,
Finding,
Provider,
Resource,
@@ -235,7 +237,7 @@ class TestPerformScan:
):
tenant_id = uuid.uuid4()
provider_instance = MagicMock()
- provider_instance.id = "provider456"
+ provider_instance.id = "provider123"
finding = MagicMock()
finding.resource_uid = "resource_uid_123"
@@ -250,15 +252,16 @@ class TestPerformScan:
resource_instance.region = finding.region
mock_get_or_create_resource.return_value = (resource_instance, True)
+
tag_instance = MagicMock()
mock_get_or_create_tag.return_value = (tag_instance, True)
resource, resource_uid_tuple = _store_resources(
- finding, tenant_id, provider_instance
+ finding, str(tenant_id), provider_instance
)
mock_get_or_create_resource.assert_called_once_with(
- tenant_id=tenant_id,
+ tenant_id=str(tenant_id),
provider=provider_instance,
uid=finding.resource_uid,
defaults={
@@ -305,11 +308,11 @@ class TestPerformScan:
mock_get_or_create_tag.return_value = (tag_instance, True)
resource, resource_uid_tuple = _store_resources(
- finding, tenant_id, provider_instance
+ finding, str(tenant_id), provider_instance
)
mock_get_or_create_resource.assert_called_once_with(
- tenant_id=tenant_id,
+ tenant_id=str(tenant_id),
provider=provider_instance,
uid=finding.resource_uid,
defaults={
@@ -363,14 +366,14 @@ class TestPerformScan:
]
resource, resource_uid_tuple = _store_resources(
- finding, tenant_id, provider_instance
+ finding, str(tenant_id), provider_instance
)
mock_get_or_create_tag.assert_any_call(
- tenant_id=tenant_id, key="tag1", value="value1"
+ tenant_id=str(tenant_id), key="tag1", value="value1"
)
mock_get_or_create_tag.assert_any_call(
- tenant_id=tenant_id, key="tag2", value="value2"
+ tenant_id=str(tenant_id), key="tag2", value="value2"
)
resource_instance.upsert_or_delete_tags.assert_called_once()
tags_passed = resource_instance.upsert_or_delete_tags.call_args[1]["tags"]
@@ -382,3 +385,808 @@ class TestPerformScan:
# TODO Add tests for aggregations
+
+
+@pytest.mark.django_db
+class TestCreateComplianceRequirements:
+ def test_create_compliance_requirements_success(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ providers_fixture,
+ findings_fixture,
+ resources_fixture,
+ ):
+ with (
+ patch("api.db_utils.rls_transaction"),
+ patch(
+ "tasks.jobs.scan.initialize_prowler_provider"
+ ) as mock_initialize_prowler_provider,
+ patch(
+ "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
+ ) as mock_compliance_template,
+ patch("tasks.jobs.scan.generate_scan_compliance"),
+ patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects,
+ patch("api.models.Finding.objects.filter") as mock_findings_filter,
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = providers_fixture[0]
+
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+
+ scan.provider = provider
+ scan.save()
+
+ tenant_id = str(tenant.id)
+ scan_id = str(scan.id)
+
+ mock_prowler_provider_instance = MagicMock()
+ mock_prowler_provider_instance.get_regions.return_value = [
+ "us-east-1",
+ "us-west-2",
+ ]
+ mock_initialize_prowler_provider.return_value = (
+ mock_prowler_provider_instance
+ )
+
+ mock_compliance_template.__getitem__.return_value = {
+ "cis_1.4_aws": {
+ "framework": "CIS AWS Foundations Benchmark",
+ "version": "1.4.0",
+ "requirements": {
+ "1.1": {
+ "description": "Ensure root access key does not exist",
+ "checks_status": {
+ "pass": 0,
+ "fail": 0,
+ "manual": 0,
+ "total": 1,
+ },
+ "status": "PASS",
+ },
+ "1.2": {
+ "description": "Ensure MFA is enabled for root account",
+ "checks_status": {
+ "pass": 0,
+ "fail": 1,
+ "manual": 0,
+ "total": 1,
+ },
+ "status": "FAIL",
+ },
+ },
+ },
+ "aws_account_security_onboarding_aws": {
+ "framework": "AWS Account Security Onboarding",
+ "version": "1.0",
+ "requirements": {
+ "requirement1": {
+ "description": "Basic security requirement",
+ "checks_status": {
+ "pass": 1,
+ "fail": 0,
+ "manual": 0,
+ "total": 1,
+ },
+ "status": "PASS",
+ },
+ },
+ },
+ }
+
+ mock_findings_filter.return_value = []
+
+ result = create_compliance_requirements(tenant_id, scan_id)
+
+ assert "requirements_created" in result
+ assert "regions_processed" in result
+ assert "compliance_frameworks" in result
+ assert result["regions_processed"] == ["us-east-1", "us-west-2"]
+ assert result["requirements_created"] == 6
+ assert len(result["compliance_frameworks"]) == 2
+
+ mock_create_objects.assert_called_once()
+ call_args = mock_create_objects.call_args[0]
+ assert call_args[0] == tenant_id
+ assert call_args[1] == ComplianceRequirementOverview
+ assert len(call_args[2]) == 6
+
+ compliance_objects = call_args[2]
+ for obj in compliance_objects:
+ assert isinstance(obj, ComplianceRequirementOverview)
+ assert obj.tenant.id == tenant.id
+ assert obj.scan == scan
+ assert obj.region in ["us-east-1", "us-west-2"]
+ assert obj.compliance_id in [
+ "cis_1.4_aws",
+ "aws_account_security_onboarding_aws",
+ ]
+
+ def test_create_compliance_requirements_with_findings(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ providers_fixture,
+ ):
+ with (
+ patch("api.db_utils.rls_transaction"),
+ patch(
+ "tasks.jobs.scan.initialize_prowler_provider"
+ ) as mock_initialize_prowler_provider,
+ patch(
+ "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
+ ) as mock_compliance_template,
+ patch(
+ "tasks.jobs.scan.generate_scan_compliance"
+ ) as mock_generate_compliance,
+ patch("tasks.jobs.scan.create_objects_in_batches"),
+ patch("api.models.Finding.objects.filter") as mock_findings_filter,
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = providers_fixture[0]
+
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+ scan.provider = provider
+ scan.save()
+
+ tenant_id = str(tenant.id)
+ scan_id = str(scan.id)
+
+ mock_finding1 = MagicMock()
+ mock_finding1.check_id = "check1"
+ mock_finding1.status = "PASS"
+ mock_resource1 = MagicMock()
+ mock_resource1.region = "us-east-1"
+ mock_finding1.resources.all.return_value = [mock_resource1]
+
+ mock_finding2 = MagicMock()
+ mock_finding2.check_id = "check2"
+ mock_finding2.status = "FAIL"
+ mock_resource2 = MagicMock()
+ mock_resource2.region = "us-west-2"
+ mock_finding2.resources.all.return_value = [mock_resource2]
+
+ mock_findings_filter.return_value = [mock_finding1, mock_finding2]
+
+ mock_prowler_provider_instance = MagicMock()
+ mock_prowler_provider_instance.get_regions.return_value = [
+ "us-east-1",
+ "us-west-2",
+ ]
+ mock_initialize_prowler_provider.return_value = (
+ mock_prowler_provider_instance
+ )
+
+ mock_compliance_template.__getitem__.return_value = {
+ "test_compliance": {
+ "framework": "Test Framework",
+ "version": "1.0",
+ "requirements": {
+ "req_1": {
+ "description": "Test Requirement 1",
+ "checks": {"check_1": None},
+ "checks_status": {
+ "pass": 2,
+ "fail": 1,
+ "manual": 0,
+ "total": 3,
+ },
+ "status": "FAIL",
+ },
+ "req_2": {
+ "description": "Test Requirement 2",
+ "checks": {"check_2": None},
+ "checks_status": {
+ "pass": 2,
+ "fail": 0,
+ "manual": 0,
+ "total": 2,
+ },
+ "status": "PASS",
+ },
+ },
+ }
+ }
+
+ result = create_compliance_requirements(tenant_id, scan_id)
+
+ mock_findings_filter.assert_called_once_with(scan_id=scan_id, muted=False)
+ assert mock_generate_compliance.call_count == 2
+ assert result["requirements_created"] == 4
+ assert set(result["regions_processed"]) == {"us-east-1", "us-west-2"}
+
+ def test_create_compliance_requirements_no_provider_regions(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ providers_fixture,
+ ):
+ with (
+ patch("api.db_utils.rls_transaction"),
+ patch(
+ "tasks.jobs.scan.initialize_prowler_provider"
+ ) as mock_initialize_prowler_provider,
+ patch(
+ "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
+ ) as mock_compliance_template,
+ patch("tasks.jobs.scan.generate_scan_compliance"),
+ patch("tasks.jobs.scan.create_objects_in_batches"),
+ patch("api.models.Finding.objects.filter") as mock_findings_filter,
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = providers_fixture[0]
+
+ provider.provider = Provider.ProviderChoices.KUBERNETES
+ provider.save()
+ scan.provider = provider
+ scan.save()
+
+ tenant_id = str(tenant.id)
+ scan_id = str(scan.id)
+
+ mock_finding = MagicMock()
+ mock_finding.check_id = "check1"
+ mock_finding.status = "PASS"
+ mock_resource = MagicMock()
+ mock_resource.region = "default"
+ mock_finding.resources.all.return_value = [mock_resource]
+ mock_findings_filter.return_value = [mock_finding]
+
+ mock_prowler_provider_instance = MagicMock()
+ mock_prowler_provider_instance.get_regions.side_effect = AttributeError(
+ "No get_regions method"
+ )
+ mock_initialize_prowler_provider.return_value = (
+ mock_prowler_provider_instance
+ )
+
+ mock_compliance_template.__getitem__.return_value = {
+ "kubernetes_cis": {
+ "framework": "CIS Kubernetes Benchmark",
+ "version": "1.6.0",
+ "requirements": {
+ "1.1": {
+ "description": "Test requirement",
+ "checks_status": {
+ "pass": 0,
+ "fail": 0,
+ "manual": 0,
+ "total": 1,
+ },
+ "status": "PASS",
+ },
+ },
+ },
+ }
+
+ result = create_compliance_requirements(tenant_id, scan_id)
+
+ assert result["regions_processed"] == ["default"]
+
+ def test_create_compliance_requirements_empty_findings(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ providers_fixture,
+ ):
+ with (
+ patch("api.db_utils.rls_transaction"),
+ patch(
+ "tasks.jobs.scan.initialize_prowler_provider"
+ ) as mock_initialize_prowler_provider,
+ patch(
+ "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
+ ) as mock_compliance_template,
+ patch(
+ "tasks.jobs.scan.generate_scan_compliance"
+ ) as mock_generate_compliance,
+ patch("tasks.jobs.scan.create_objects_in_batches"),
+ patch("api.models.Finding.objects.filter") as mock_findings_filter,
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = providers_fixture[0]
+
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+ scan.provider = provider
+ scan.save()
+
+ tenant_id = str(tenant.id)
+ scan_id = str(scan.id)
+
+ mock_findings_filter.return_value = []
+
+ mock_prowler_provider_instance = MagicMock()
+ mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
+ mock_initialize_prowler_provider.return_value = (
+ mock_prowler_provider_instance
+ )
+
+ mock_compliance_template.__getitem__.return_value = {
+ "cis_1.4_aws": {
+ "framework": "CIS AWS Foundations Benchmark",
+ "version": "1.4.0",
+ "requirements": {
+ "1.1": {
+ "description": "Test requirement",
+ "checks_status": {
+ "pass": 0,
+ "fail": 0,
+ "manual": 0,
+ "total": 1,
+ },
+ "status": "PASS",
+ },
+ },
+ },
+ }
+
+ mock_findings_filter.return_value = []
+
+ result = create_compliance_requirements(tenant_id, scan_id)
+
+ assert result["regions_processed"] == ["us-east-1"]
+ assert result["requirements_created"] == 1
+ mock_generate_compliance.assert_not_called()
+
+ def test_create_compliance_requirements_error_handling(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ providers_fixture,
+ ):
+ with (
+ patch("api.db_utils.rls_transaction"),
+ patch(
+ "tasks.jobs.scan.initialize_prowler_provider"
+ ) as mock_initialize_prowler_provider,
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = providers_fixture[0]
+
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+ scan.provider = provider
+ scan.save()
+
+ tenant_id = str(tenant.id)
+ scan_id = str(scan.id)
+
+ mock_initialize_prowler_provider.side_effect = Exception(
+ "Provider initialization failed"
+ )
+
+ with pytest.raises(Exception, match="Provider initialization failed"):
+ create_compliance_requirements(tenant_id, scan_id)
+
+ def test_create_compliance_requirements_muted_findings_excluded(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ providers_fixture,
+ ):
+ with (
+ patch("api.db_utils.rls_transaction"),
+ patch(
+ "tasks.jobs.scan.initialize_prowler_provider"
+ ) as mock_initialize_prowler_provider,
+ patch(
+ "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
+ ) as mock_compliance_template,
+ patch("tasks.jobs.scan.generate_scan_compliance"),
+ patch("tasks.jobs.scan.create_objects_in_batches"),
+ patch("api.models.Finding.objects.filter") as mock_findings_filter,
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = providers_fixture[0]
+
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+ scan.provider = provider
+ scan.save()
+
+ tenant_id = str(tenant.id)
+ scan_id = str(scan.id)
+
+ mock_findings_filter.return_value = []
+
+ mock_prowler_provider_instance = MagicMock()
+ mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
+ mock_initialize_prowler_provider.return_value = (
+ mock_prowler_provider_instance
+ )
+
+ mock_compliance_template.__getitem__.return_value = {}
+
+ mock_findings_filter.return_value = []
+
+ create_compliance_requirements(tenant_id, scan_id)
+
+ mock_findings_filter.assert_called_once_with(scan_id=scan_id, muted=False)
+
+ def test_create_compliance_requirements_check_status_priority(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ providers_fixture,
+ ):
+ with (
+ patch("api.db_utils.rls_transaction"),
+ patch(
+ "tasks.jobs.scan.initialize_prowler_provider"
+ ) as mock_initialize_prowler_provider,
+ patch(
+ "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
+ ) as mock_compliance_template,
+ patch(
+ "tasks.jobs.scan.generate_scan_compliance"
+ ) as mock_generate_compliance,
+ patch("tasks.jobs.scan.create_objects_in_batches"),
+ patch("api.models.Finding.objects.filter") as mock_findings_filter,
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = providers_fixture[0]
+
+ provider.provider = Provider.ProviderChoices.AWS
+ provider.save()
+ scan.provider = provider
+ scan.save()
+
+ tenant_id = str(tenant.id)
+ scan_id = str(scan.id)
+
+ mock_finding1 = MagicMock()
+ mock_finding1.check_id = "check1"
+ mock_finding1.status = "PASS"
+ mock_resource1 = MagicMock()
+ mock_resource1.region = "us-east-1"
+ mock_finding1.resources.all.return_value = [mock_resource1]
+
+ mock_finding2 = MagicMock()
+ mock_finding2.check_id = "check1"
+ mock_finding2.status = "FAIL"
+ mock_resource2 = MagicMock()
+ mock_resource2.region = "us-east-1"
+ mock_finding2.resources.all.return_value = [mock_resource2]
+
+ mock_findings_filter.return_value = [mock_finding1, mock_finding2]
+
+ mock_prowler_provider_instance = MagicMock()
+ mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
+ mock_initialize_prowler_provider.return_value = (
+ mock_prowler_provider_instance
+ )
+
+ mock_compliance_template.__getitem__.return_value = {
+ "cis_1.4_aws": {
+ "framework": "CIS AWS Foundations Benchmark",
+ "version": "1.4.0",
+ "requirements": {
+ "1.1": {
+ "description": "Test requirement",
+ "checks_status": {
+ "pass": 0,
+ "fail": 0,
+ "manual": 0,
+ "total": 1,
+ },
+ "status": "PASS",
+ },
+ },
+ },
+ }
+
+ create_compliance_requirements(tenant_id, scan_id)
+
+ assert mock_generate_compliance.call_count == 1
+
+ def test_compliance_overview_aggregation_requirement_fail_priority(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ providers_fixture,
+ ):
+ with (
+ patch("api.db_utils.rls_transaction"),
+ patch(
+ "tasks.jobs.scan.initialize_prowler_provider"
+ ) as mock_initialize_prowler_provider,
+ patch(
+ "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
+ ) as mock_compliance_template,
+ patch(
+ "tasks.jobs.scan.generate_scan_compliance"
+ ) as mock_generate_compliance,
+ patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects,
+ patch("api.models.Finding.objects.filter") as mock_findings_filter,
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ providers_fixture[0]
+
+ mock_findings_filter.return_value = []
+
+ mock_prowler_provider = MagicMock()
+ mock_prowler_provider.get_regions.return_value = [
+ "us-east-1",
+ "us-west-2",
+ "eu-west-1",
+ ]
+ mock_initialize_prowler_provider.return_value = mock_prowler_provider
+
+ mock_compliance_template.__getitem__.return_value = {
+ "test_compliance": {
+ "framework": "Test Framework",
+ "version": "1.0",
+ "requirements": {
+ "req_1": {
+ "description": "Test Requirement 1",
+ "checks": {"check_1": None},
+ "checks_status": {
+ "pass": 2,
+ "fail": 1,
+ "manual": 0,
+ "total": 3,
+ },
+ "status": "FAIL",
+ }
+ },
+ }
+ }
+
+ mock_generate_compliance.return_value = {
+ "test_compliance": {
+ "framework": "Test Framework",
+ "version": "1.0",
+ "requirements": {
+ "req_1": {
+ "description": "Test Requirement 1",
+ "checks": {
+ "check_1": {
+ "us-east-1": {"status": "PASS"},
+ "us-west-2": {"status": "FAIL"},
+ "eu-west-1": {"status": "PASS"},
+ }
+ },
+ "checks_status": {
+ "pass": 2,
+ "fail": 1,
+ "manual": 0,
+ "total": 3,
+ },
+ "status": "FAIL",
+ }
+ },
+ }
+ }
+
+ created_objects = []
+ mock_create_objects.side_effect = (
+ lambda tenant_id, model, objs, batch_size=500: created_objects.extend(
+ objs
+ )
+ )
+
+ create_compliance_requirements(str(tenant.id), str(scan.id))
+
+ assert len(created_objects) == 3
+ assert all(obj.requirement_status == "FAIL" for obj in created_objects)
+
+ def test_compliance_overview_aggregation_requirement_pass_all_regions(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ providers_fixture,
+ ):
+ with (
+ patch("api.db_utils.rls_transaction"),
+ patch(
+ "tasks.jobs.scan.initialize_prowler_provider"
+ ) as mock_initialize_prowler_provider,
+ patch(
+ "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
+ ) as mock_compliance_template,
+ patch(
+ "tasks.jobs.scan.generate_scan_compliance"
+ ) as mock_generate_compliance,
+ patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects,
+ patch("api.models.Finding.objects.filter") as mock_findings_filter,
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ providers_fixture[0]
+
+ mock_findings_filter.return_value = []
+
+ mock_prowler_provider = MagicMock()
+ mock_prowler_provider.get_regions.return_value = ["us-east-1", "us-west-2"]
+ mock_initialize_prowler_provider.return_value = mock_prowler_provider
+
+ mock_compliance_template.__getitem__.return_value = {
+ "test_compliance": {
+ "framework": "Test Framework",
+ "version": "1.0",
+ "requirements": {
+ "req_1": {
+ "description": "Test Requirement 1",
+ "checks": {"check_1": None},
+ "checks_status": {
+ "pass": 2,
+ "fail": 0,
+ "manual": 0,
+ "total": 2,
+ },
+ "status": "PASS",
+ }
+ },
+ }
+ }
+
+ mock_generate_compliance.return_value = {
+ "test_compliance": {
+ "framework": "Test Framework",
+ "version": "1.0",
+ "requirements": {
+ "req_1": {
+ "description": "Test Requirement 1",
+ "checks": {
+ "check_1": {
+ "us-east-1": {"status": "PASS"},
+ "us-west-2": {"status": "PASS"},
+ }
+ },
+ "checks_status": {
+ "pass": 2,
+ "fail": 0,
+ "manual": 0,
+ "total": 2,
+ },
+ "status": "PASS",
+ }
+ },
+ }
+ }
+
+ created_objects = []
+ mock_create_objects.side_effect = (
+ lambda tenant_id, model, objs, batch_size=500: created_objects.extend(
+ objs
+ )
+ )
+
+ create_compliance_requirements(str(tenant.id), str(scan.id))
+
+ assert len(created_objects) == 2
+ assert all(obj.requirement_status == "PASS" for obj in created_objects)
+
+ def test_compliance_overview_aggregation_multiple_requirements_mixed_status(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ providers_fixture,
+ ):
+ with (
+ patch("api.db_utils.rls_transaction"),
+ patch(
+ "tasks.jobs.scan.initialize_prowler_provider"
+ ) as mock_initialize_prowler_provider,
+ patch(
+ "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
+ ) as mock_compliance_template,
+ patch(
+ "tasks.jobs.scan.generate_scan_compliance"
+ ) as mock_generate_compliance,
+ patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects,
+ patch("api.models.Finding.objects.filter") as mock_findings_filter,
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ providers_fixture[0]
+
+ mock_findings_filter.return_value = []
+
+ mock_prowler_provider = MagicMock()
+ mock_prowler_provider.get_regions.return_value = ["us-east-1", "us-west-2"]
+ mock_initialize_prowler_provider.return_value = mock_prowler_provider
+
+ mock_compliance_template.__getitem__.return_value = {
+ "test_compliance": {
+ "framework": "Test Framework",
+ "version": "1.0",
+ "requirements": {
+ "req_1": {
+ "description": "Test Requirement 1",
+ "checks": {"check_1": None},
+ "checks_status": {
+ "pass": 2,
+ "fail": 0,
+ "manual": 0,
+ "total": 2,
+ },
+ "status": "PASS",
+ },
+ "req_2": {
+ "description": "Test Requirement 2",
+ "checks": {"check_2": None},
+ "checks_status": {
+ "pass": 1,
+ "fail": 1,
+ "manual": 0,
+ "total": 2,
+ },
+ "status": "FAIL",
+ },
+ },
+ }
+ }
+
+ mock_generate_compliance.return_value = {
+ "test_compliance": {
+ "framework": "Test Framework",
+ "version": "1.0",
+ "requirements": {
+ "req_1": {
+ "description": "Test Requirement 1",
+ "checks": {
+ "check_1": {
+ "us-east-1": {"status": "PASS"},
+ "us-west-2": {"status": "PASS"},
+ }
+ },
+ "checks_status": {
+ "pass": 2,
+ "fail": 0,
+ "manual": 0,
+ "total": 2,
+ },
+ "status": "PASS",
+ },
+ "req_2": {
+ "description": "Test Requirement 2",
+ "checks": {
+ "check_2": {
+ "us-east-1": {"status": "PASS"},
+ "us-west-2": {"status": "FAIL"},
+ }
+ },
+ "checks_status": {
+ "pass": 1,
+ "fail": 1,
+ "manual": 0,
+ "total": 2,
+ },
+ "status": "FAIL",
+ },
+ },
+ }
+ }
+
+ created_objects = []
+ mock_create_objects.side_effect = (
+ lambda tenant_id, model, objs, batch_size=500: created_objects.extend(
+ objs
+ )
+ )
+
+ create_compliance_requirements(str(tenant.id), str(scan.id))
+
+ assert len(created_objects) == 4
+ req_1_objects = [
+ obj for obj in created_objects if obj.requirement_id == "req_1"
+ ]
+ req_2_objects = [
+ obj for obj in created_objects if obj.requirement_id == "req_2"
+ ]
+ assert len(req_1_objects) == 2
+ assert len(req_2_objects) == 2
+ assert all(obj.requirement_status == "PASS" for obj in req_1_objects)
+ assert all(obj.requirement_status == "FAIL" for obj in req_2_objects)
diff --git a/docs/img/AAD-permissions.png b/docs/img/AAD-permissions.png
index f530293bfe..f2f37e354d 100644
Binary files a/docs/img/AAD-permissions.png and b/docs/img/AAD-permissions.png differ
diff --git a/docs/tutorials/azure/getting-started-azure.md b/docs/tutorials/azure/getting-started-azure.md
index 5dead442d7..3fabdcc3a5 100644
--- a/docs/tutorials/azure/getting-started-azure.md
+++ b/docs/tutorials/azure/getting-started-azure.md
@@ -111,7 +111,7 @@ Assign the following Microsoft Graph permissions:
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All`
- 
+ 
4. Click `Add permissions`, then grant admin consent
diff --git a/docs/tutorials/azure/img/directory-permission.png b/docs/tutorials/azure/img/directory-permission.png
deleted file mode 100644
index 34dc81abeb..0000000000
Binary files a/docs/tutorials/azure/img/directory-permission.png and /dev/null differ
diff --git a/docs/tutorials/azure/img/domain-permission.png b/docs/tutorials/azure/img/domain-permission.png
new file mode 100644
index 0000000000..467ec8ff36
Binary files /dev/null and b/docs/tutorials/azure/img/domain-permission.png differ
diff --git a/docs/tutorials/microsoft365/getting-started-m365.md b/docs/tutorials/microsoft365/getting-started-m365.md
index 5e0364faaf..2fde79fe84 100644
--- a/docs/tutorials/microsoft365/getting-started-m365.md
+++ b/docs/tutorials/microsoft365/getting-started-m365.md
@@ -95,11 +95,10 @@ With this done you will have all the needed keys, summarized in the following ta
### Grant required API permissions
Assign the following Microsoft Graph permissions:
-
+- `AuditLog.Read.All`: Required for Entra service.
- `Domain.Read.All`: Required for all services.
- `Policy.Read.All`: Required for all services.
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
-- `AuditLog.Read.All`: Required for Entra service.
- `User.Read` (IMPORTANT: this is set as **delegated**): Required for the sign-in.
Follow these steps to assign the permissions:
@@ -113,11 +112,11 @@ Follow these steps to assign the permissions:

3. Search and select every permission below and once all are selected click on `Add permissions`:
-
+ - `AuditLog.Read.All`: Required for Entra service.
- `Domain.Read.All`
- `Policy.Read.All`
- `SharePointTenantSettings.Read.All`
- - `AuditLog.Read.All`: Required for Entra service.
+

diff --git a/docs/tutorials/microsoft365/img/grant-admin-consent-delegated.png b/docs/tutorials/microsoft365/img/grant-admin-consent-delegated.png
index aa2e96ce70..d87903e271 100644
Binary files a/docs/tutorials/microsoft365/img/grant-admin-consent-delegated.png and b/docs/tutorials/microsoft365/img/grant-admin-consent-delegated.png differ
diff --git a/docs/tutorials/microsoft365/img/grant-admin-consent.png b/docs/tutorials/microsoft365/img/grant-admin-consent.png
index 2258d31b8e..2250e41c97 100644
Binary files a/docs/tutorials/microsoft365/img/grant-admin-consent.png and b/docs/tutorials/microsoft365/img/grant-admin-consent.png differ
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 34e9fc3037..d41aa4f6de 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -24,11 +24,17 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Add search bar in Dashboard Overview page. [(#7804)](https://github.com/prowler-cloud/prowler/pull/7804)
### Fixed
+- Update SDK Azure call for ftps_state in the App Service. [(#7923)](https://github.com/prowler-cloud/prowler/pull/7923)
+
+---
+
+### [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)
- Fix `defender_ensure_notify_alerts_severity_is_high`check to accept high or lower severity. [(#7862)](https://github.com/prowler-cloud/prowler/pull/7862)
- Replace `Directory.Read.All` permission with `Domain.Read.All` which is more restrictive. [(#7888)](https://github.com/prowler-cloud/prowler/pull/7888)
+- Split calls to list Azure Functions attributes. [(#7778)](https://github.com/prowler-cloud/prowler/pull/7778)
---
diff --git a/prowler/providers/azure/services/app/app_service.py b/prowler/providers/azure/services/app/app_service.py
index 131b6a52c9..c77022bf5e 100644
--- a/prowler/providers/azure/services/app/app_service.py
+++ b/prowler/providers/azure/services/app/app_service.py
@@ -136,6 +136,11 @@ class App(AzureService):
subscription_name, function.resource_group, function.name
)
+ web_app_config = client.web_apps.get_configuration(
+ resource_group_name=function.resource_group,
+ name=function.name,
+ )
+
functions[subscription_name].update(
{
function.id: FunctionApp(
@@ -162,7 +167,7 @@ class App(AzureService):
"",
),
ftps_state=getattr(
- function_config, "ftps_state", None
+ web_app_config, "ftps_state", None
),
resource_group_name=function.resource_group,
)
diff --git a/tests/providers/azure/services/app/app_service_test.py b/tests/providers/azure/services/app/app_service_test.py
index b5047618d6..cc33c662a1 100644
--- a/tests/providers/azure/services/app/app_service_test.py
+++ b/tests/providers/azure/services/app/app_service_test.py
@@ -200,3 +200,47 @@ class Test_App_Service:
.name
== "name_diagnostic_setting2"
)
+
+ def test_app_service_get_functions(self):
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ patch(
+ "prowler.providers.azure.services.monitor.monitor_service.Monitor",
+ new=MagicMock(),
+ ),
+ ):
+ from prowler.providers.azure.services.app.app_service import FunctionApp
+
+ mock_function = FunctionApp(
+ id="/subscriptions/resource_id",
+ name="functionapp-1",
+ location="West Europe",
+ kind="functionapp",
+ function_keys=None,
+ enviroment_variables=None,
+ identity=ManagedServiceIdentity(type="SystemAssigned"),
+ public_access=True,
+ vnet_subnet_id="",
+ ftps_state="FtpsOnly",
+ )
+
+ app_service = MagicMock()
+ app_service.functions = {
+ "mock-subscription": {"/subscriptions/resource_id": mock_function}
+ }
+
+ assert (
+ app_service.functions["mock-subscription"][
+ "/subscriptions/resource_id"
+ ].ftps_state
+ == "FtpsOnly"
+ )
+ assert (
+ app_service.functions["mock-subscription"][
+ "/subscriptions/resource_id"
+ ].name
+ == "functionapp-1"
+ )
diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs
index 8c32ae0804..01d6afe1ac 100644
--- a/ui/.eslintrc.cjs
+++ b/ui/.eslintrc.cjs
@@ -22,7 +22,8 @@ module.exports = {
},
},
rules: {
- "no-console": 1,
+ // console.error are allowed but no console.log
+ "no-console": ["error", { allow: ["error"] }],
eqeqeq: 2,
quotes: ["error", "double", "avoid-escape"],
"@typescript-eslint/no-explicit-any": "off",
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index bff6a36cbf..e770712ddd 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -10,20 +10,25 @@ All notable changes to the **Prowler UI** are documented in this file.
- Improved `SnippetChip` component and show resource name in new findings table. [(#7813)](https://github.com/prowler-cloud/prowler/pull/7813)
- Possibility to edit the organization name. [(#7829)](https://github.com/prowler-cloud/prowler/pull/7829)
- Add GCP credential method (Account Service Key). [(#7872)](https://github.com/prowler-cloud/prowler/pull/7872)
+- 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)
### 🔄 Changed
-- Improve CustomDropdownFilter component. [(#7868)(https://github.com/prowler-cloud/prowler/pull/7868)]
- Add `Provider UID` filter to scans page. [(#7820)](https://github.com/prowler-cloud/prowler/pull/7820)
+---
+
+## [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)]
-
-
-### 🐞 Fixes
- 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/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts
index 21c65765e0..cdc947fd8c 100644
--- a/ui/actions/compliances/compliances.ts
+++ b/ui/actions/compliances/compliances.ts
@@ -30,7 +30,6 @@ export const getCompliancesOverview = async ({
});
const data = await compliances.json();
const parsedData = parseStringify(data);
-
revalidatePath("/compliance");
return parsedData;
} catch (error) {
@@ -79,3 +78,77 @@ export const getComplianceOverviewMetadataInfo = async ({
return undefined;
}
};
+
+export const getComplianceAttributes = async (complianceId: string) => {
+ const headers = await getAuthHeaders({ contentType: false });
+
+ try {
+ const url = new URL(`${apiBaseUrl}/compliance-overviews/attributes`);
+ url.searchParams.append("filter[compliance_id]", complianceId);
+
+ const response = await fetch(url.toString(), {
+ headers,
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch compliance attributes: ${response.statusText}`,
+ );
+ }
+
+ const data = await response.json();
+
+ const parsedData = parseStringify(data);
+ return parsedData;
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("Error fetching compliance attributes:", error);
+ return undefined;
+ }
+ // */
+};
+
+export const getComplianceRequirements = async ({
+ complianceId,
+ scanId,
+ region,
+}: {
+ complianceId: string;
+ scanId: string;
+ region?: string | string[];
+}) => {
+ const headers = await getAuthHeaders({ contentType: false });
+
+ try {
+ const url = new URL(`${apiBaseUrl}/compliance-overviews/requirements`);
+ url.searchParams.append("filter[compliance_id]", complianceId);
+ url.searchParams.append("filter[scan_id]", scanId);
+
+ if (region) {
+ const regionValue = Array.isArray(region) ? region.join(",") : region;
+ url.searchParams.append("filter[region__in]", regionValue);
+ //remove page param
+ }
+ url.searchParams.delete("page");
+
+ const response = await fetch(url.toString(), {
+ headers,
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch compliance requirements: ${response.statusText}`,
+ );
+ }
+
+ const data = await response.json();
+ const parsedData = parseStringify(data);
+
+ return parsedData;
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("Error fetching compliance requirements:", error);
+ return undefined;
+ }
+ // */
+};
diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx
new file mode 100644
index 0000000000..189a33531a
--- /dev/null
+++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx
@@ -0,0 +1,295 @@
+import { Spacer } from "@nextui-org/react";
+import Image from "next/image";
+import { Suspense } from "react";
+
+import {
+ getComplianceAttributes,
+ getComplianceOverviewMetadataInfo,
+ getComplianceRequirements,
+} from "@/actions/compliances";
+import { getProvider } from "@/actions/providers";
+import { getScans } from "@/actions/scans";
+import {
+ BarChart,
+ BarChartSkeleton,
+ ClientAccordionWrapper,
+ ComplianceHeader,
+ HeatmapChart,
+ HeatmapChartSkeleton,
+ PieChart,
+ PieChartSkeleton,
+ SkeletonAccordion,
+} from "@/components/compliance";
+import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
+import { ContentLayout } from "@/components/ui";
+import {
+ calculateCategoryHeatmapData,
+ calculateRegionHeatmapData,
+ getComplianceMapper,
+} from "@/lib/compliance/commons";
+import { ScanProps } from "@/types";
+import { Framework, RequirementsTotals } from "@/types/compliance";
+
+interface ComplianceDetailSearchParams {
+ complianceId: string;
+ version?: string;
+ scanId?: string;
+ "filter[region__in]"?: string;
+ "filter[cis_profile_level]"?: string;
+}
+
+const ComplianceIconSmall = ({
+ logoPath,
+ title,
+}: {
+ logoPath: string;
+ title: string;
+}) => {
+ return (
+
+
+
+ );
+};
+
+const ChartsWrapper = ({
+ children,
+}: {
+ children: React.ReactNode;
+ logoPath?: string;
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default async function ComplianceDetail({
+ params,
+ searchParams,
+}: {
+ params: { compliancetitle: string };
+ searchParams: ComplianceDetailSearchParams;
+}) {
+ const { compliancetitle } = params;
+ const { complianceId, version, scanId } = searchParams;
+ const regionFilter = searchParams["filter[region__in]"];
+ const cisProfileFilter = searchParams["filter[cis_profile_level]"];
+ const logoPath = getComplianceIcon(compliancetitle);
+
+ // Create a key that includes region filter for Suspense
+ const searchParamsKey = JSON.stringify(searchParams || {});
+
+ const formattedTitle = compliancetitle.split("-").join(" ");
+ const pageTitle = version
+ ? `Compliance Details: ${formattedTitle} - ${version}`
+ : `Compliance Details: ${formattedTitle}`;
+
+ // Fetch scans data
+ const scansData = await getScans({
+ filters: {
+ "filter[state]": "completed",
+ },
+ });
+
+ // Expand scans with provider information
+ const expandedScansData = scansData?.data?.length
+ ? await Promise.all(
+ scansData.data.map(async (scan: ScanProps) => {
+ const providerId = scan.relationships?.provider?.data?.id;
+
+ if (!providerId) {
+ return { ...scan, providerInfo: null };
+ }
+
+ const formData = new FormData();
+ formData.append("id", providerId);
+
+ const providerData = await getProvider(formData);
+
+ return {
+ ...scan,
+ providerInfo: providerData?.data
+ ? {
+ provider: providerData.data.attributes.provider,
+ uid: providerData.data.attributes.uid,
+ alias: providerData.data.attributes.alias,
+ }
+ : null,
+ };
+ }),
+ )
+ : [];
+
+ const selectedScanId = scanId || expandedScansData[0]?.id || null;
+
+ // Fetch metadata info for regions
+ const metadataInfoData = await getComplianceOverviewMetadataInfo({
+ filters: {
+ "filter[scan_id]": selectedScanId,
+ },
+ });
+
+ const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
+
+ return (
+
+ ) : (
+ "fluent-mdl2:compliance-audit"
+ )
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+ );
+}
+
+const SSRComplianceContent = async ({
+ complianceId,
+ scanId,
+ region,
+ filter,
+ logoPath,
+ uniqueRegions,
+ isRegionFiltered,
+}: {
+ complianceId: string;
+ scanId: string;
+ region?: string;
+ filter?: string;
+ logoPath?: string;
+ uniqueRegions: string[];
+ isRegionFiltered: boolean;
+}) => {
+ if (!scanId) {
+ return (
+
+ );
+ }
+
+ // Get compliance data and attributes once
+ const [attributesData, requirementsData] = await Promise.all([
+ getComplianceAttributes(complianceId),
+ getComplianceRequirements({
+ complianceId,
+ scanId,
+ region,
+ }),
+ ]);
+
+ // Determine framework from the first attribute item
+ const framework = attributesData?.data?.[0]?.attributes?.framework;
+ const mapper = getComplianceMapper(framework);
+ const data = mapper.mapComplianceData(
+ attributesData,
+ requirementsData,
+ filter,
+ );
+
+ // Calculate region heatmap data using already obtained data
+ const regionHeatmapData = await calculateRegionHeatmapData(
+ complianceId,
+ scanId,
+ uniqueRegions,
+ attributesData,
+ mapper,
+ );
+ const categoryHeatmapData = calculateCategoryHeatmapData(data);
+
+ const totalRequirements: RequirementsTotals = data.reduce(
+ (acc: RequirementsTotals, framework: Framework) => ({
+ pass: acc.pass + framework.pass,
+ fail: acc.fail + framework.fail,
+ manual: acc.manual + framework.manual,
+ }),
+ { pass: 0, fail: 0, manual: 0 },
+ );
+
+ const accordionItems = mapper.toAccordionItems(data, scanId);
+ const topFailedSections = mapper.getTopFailedSections(data);
+
+ // Todo: rethink as every compliance has a different number of items
+ // const defaultKeys = accordionItems.slice(0, 2).map((item) => item.key);
+ const defaultKeys = [""];
+
+ return (
+
+ );
+};
diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx
index 56b6dc9d62..061af051d7 100644
--- a/ui/app/(prowler)/compliance/page.tsx
+++ b/ui/app/(prowler)/compliance/page.tsx
@@ -1,6 +1,4 @@
export const dynamic = "force-dynamic";
-
-import { Spacer } from "@nextui-org/react";
import { Suspense } from "react";
import { getCompliancesOverview } from "@/actions/compliances";
@@ -12,11 +10,10 @@ import {
ComplianceSkeletonGrid,
NoScansAvailable,
} from "@/components/compliance";
-import { DataCompliance } from "@/components/compliance/data-compliance";
-import { FilterControls } from "@/components/filters";
+import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header";
import { ContentLayout } from "@/components/ui";
-import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
-import { ComplianceOverviewData, ScanProps, SearchParamsProps } from "@/types";
+import { ScanProps, SearchParamsProps } from "@/types";
+import { ComplianceOverviewData } from "@/types/compliance";
export default async function Compliance({
searchParams,
@@ -84,21 +81,10 @@ export default async function Compliance({
{selectedScanId ? (
<>
-
-
-
-
-
-
}>
@@ -133,7 +119,11 @@ const SSRComplianceGrid = async ({
});
// Check if the response contains no data
- if (!compliancesData || compliancesData?.data?.length === 0) {
+ if (
+ !compliancesData ||
+ !compliancesData.data ||
+ compliancesData.data.length === 0
+ ) {
return (
@@ -155,25 +145,22 @@ const SSRComplianceGrid = async ({
return (
{compliancesData.data.map((compliance: ComplianceOverviewData) => {
- const { attributes } = compliance;
- const {
- framework,
- version,
- requirements_status: { passed, total },
- compliance_id,
- } = attributes;
+ const { attributes, id } = compliance;
+ const { framework, version, requirements_passed, total_requirements } =
+ attributes;
return (
);
})}
diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx
index e37902ac31..82bf6d6d18 100644
--- a/ui/app/(prowler)/findings/page.tsx
+++ b/ui/app/(prowler)/findings/page.tsx
@@ -27,7 +27,8 @@ import {
createProviderDetailsMapping,
extractProviderUIDs,
} from "@/lib/provider-helpers";
-import { FindingProps, ScanProps, SearchParamsProps } from "@/types/components";
+import { ScanProps } from "@/types";
+import { FindingProps, SearchParamsProps } from "@/types/components";
export default async function Findings({
searchParams,
@@ -124,6 +125,7 @@ export default async function Findings({
defaultOpen={true}
/>
+
}>
diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx
index 29d8a01e45..ff26a9db15 100644
--- a/ui/app/(prowler)/profile/page.tsx
+++ b/ui/app/(prowler)/profile/page.tsx
@@ -9,7 +9,7 @@ import { MembershipsCard } from "@/components/users/profile/memberships-card";
import { RolesCard } from "@/components/users/profile/roles-card";
import { SkeletonUserInfo } from "@/components/users/profile/skeleton-user-info";
import { isUserOwnerAndHasManageAccount } from "@/lib/permissions";
-import { RoleDetail, TenantDetailData } from "@/types/users/users";
+import { RoleDetail, TenantDetailData } from "@/types/users";
export default async function Profile() {
return (
diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx
new file mode 100644
index 0000000000..d22c4b2fe4
--- /dev/null
+++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+import { useEffect, useRef, useState } from "react";
+
+import { getFindings } from "@/actions/findings/findings";
+import {
+ ColumnFindings,
+ SkeletonTableFindings,
+} from "@/components/findings/table";
+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 { FindingProps, FindingsResponse } from "@/types/components";
+
+interface ClientAccordionContentProps {
+ requirement: Requirement;
+ scanId: string;
+ framework: string;
+ disableFindings?: boolean;
+}
+
+export const ClientAccordionContent = ({
+ requirement,
+ framework,
+ scanId,
+ disableFindings = false,
+}: ClientAccordionContentProps) => {
+ const [findings, setFindings] = useState
(null);
+ const [expandedFindings, setExpandedFindings] = useState([]);
+ const searchParams = useSearchParams();
+ const pageNumber = searchParams.get("page") || "1";
+ const complianceId = searchParams.get("complianceId") as ComplianceId;
+ const defaultSort = "severity,status,-inserted_at";
+ const sort = searchParams.get("sort") || defaultSort;
+ const loadedPageRef = useRef(null);
+ const loadedSortRef = useRef(null);
+ const isExpandedRef = useRef(false);
+ const region = searchParams.get("filter[region__in]") || "";
+
+ useEffect(() => {
+ async function loadFindings() {
+ if (
+ !disableFindings &&
+ requirement.check_ids?.length > 0 &&
+ requirement.status !== "No findings" &&
+ (loadedPageRef.current !== pageNumber ||
+ loadedSortRef.current !== sort ||
+ !isExpandedRef.current)
+ ) {
+ loadedPageRef.current = pageNumber;
+ loadedSortRef.current = sort;
+ isExpandedRef.current = true;
+
+ try {
+ const checkIds = requirement.check_ids;
+ const encodedSort = sort.replace(/^\+/, "");
+ const findingsData = await getFindings({
+ filters: {
+ "filter[check_id__in]": checkIds.join(","),
+ "filter[scan]": scanId,
+ ...(region && { "filter[region__in]": region }),
+ },
+ page: parseInt(pageNumber, 10),
+ sort: encodedSort,
+ });
+
+ setFindings(findingsData);
+
+ if (findingsData?.data) {
+ // Create dictionaries for resources, scans, and providers
+ const resourceDict = createDict("resources", findingsData);
+ const scanDict = createDict("scans", findingsData);
+ const providerDict = createDict("providers", findingsData);
+
+ // Expand each finding with its corresponding resource, scan, and provider
+ const expandedData = findingsData.data.map(
+ (finding: FindingProps) => {
+ const scan = scanDict[finding.relationships?.scan?.data?.id];
+ const resource =
+ resourceDict[finding.relationships?.resources?.data?.[0]?.id];
+ const provider =
+ providerDict[scan?.relationships?.provider?.data?.id];
+
+ return {
+ ...finding,
+ relationships: { scan, resource, provider },
+ };
+ },
+ );
+ setExpandedFindings(expandedData);
+ }
+ } catch (error) {
+ console.error("Error loading findings:", error);
+ }
+ }
+ }
+
+ loadFindings();
+ }, [requirement, scanId, pageNumber, sort, region, disableFindings]);
+
+ const renderDetails = () => {
+ if (!complianceId) {
+ return null;
+ }
+
+ const mapper = getComplianceMapper(framework);
+ const detailsComponent = mapper.getDetailsComponent(requirement);
+
+ return {detailsComponent}
;
+ };
+
+ if (disableFindings) {
+ return (
+
+ {renderDetails()}
+
+ This requirement has no checks; therefore, there are no findings.
+
+
+ );
+ }
+
+ const checks = requirement.check_ids || [];
+ const checksList = (
+
+ {checks.join(", ")}
+
+ );
+
+ const accordionChecksItems = [
+ {
+ key: "checks",
+ title: (
+
+ {checks.length}
+ {checks.length > 1 ? Checks : Check}
+
+ ),
+ content: checksList,
+ },
+ ];
+
+ const renderFindingsTable = () => {
+ if (findings === null && requirement.status !== "MANUAL") {
+ return ;
+ }
+
+ if (findings?.data?.length && findings.data.length > 0) {
+ return (
+
+ index !== 4 && index !== 7,
+ )}
+ data={expandedFindings || []}
+ metadata={findings?.meta}
+ disableScroll={true}
+ />
+
+ );
+ }
+
+ return There are no findings for this regions
;
+ };
+
+ return (
+
+ {renderDetails()}
+
+ {checks.length > 0 && (
+
+ )}
+
+ {renderFindingsTable()}
+
+ );
+};
diff --git a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx
new file mode 100644
index 0000000000..a47952612b
--- /dev/null
+++ b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useState } from "react";
+
+import { Accordion, AccordionItemProps } from "@/components/ui";
+import { CustomButton } from "@/components/ui/custom";
+
+export const ClientAccordionWrapper = ({
+ items,
+ defaultExpandedKeys,
+}: {
+ items: AccordionItemProps[];
+ defaultExpandedKeys: string[];
+}) => {
+ const [selectedKeys, setSelectedKeys] =
+ useState(defaultExpandedKeys);
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // Function to get all keys except the last level (requirements)
+ const getAllKeysExceptLastLevel = (items: AccordionItemProps[]): string[] => {
+ const keys: string[] = [];
+
+ const traverse = (items: AccordionItemProps[], level: number = 0) => {
+ items.forEach((item) => {
+ // Add current item key if it's not the last level
+ if (item.items && item.items.length > 0) {
+ keys.push(item.key);
+ // Check if the children have their own children (not the last level)
+ const hasGrandChildren = item.items.some(
+ (child) => child.items && child.items.length > 0,
+ );
+ if (hasGrandChildren) {
+ traverse(item.items, level + 1);
+ }
+ }
+ });
+ };
+
+ traverse(items);
+ return keys;
+ };
+
+ const handleToggleExpand = () => {
+ if (isExpanded) {
+ setSelectedKeys(defaultExpandedKeys);
+ } else {
+ const allKeys = getAllKeysExceptLastLevel(items);
+ setSelectedKeys(allKeys);
+ }
+ setIsExpanded(!isExpanded);
+ };
+
+ const handleSelectionChange = (keys: string[]) => {
+ setSelectedKeys(keys);
+ };
+
+ return (
+
+
+
+ {isExpanded ? "Collapse all" : "Expand all"}
+
+
+
+
+ );
+};
diff --git a/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx
new file mode 100644
index 0000000000..8f14cddf36
--- /dev/null
+++ b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx
@@ -0,0 +1,21 @@
+import { FindingStatus, StatusFindingBadge } from "@/components/ui/table";
+
+interface ComplianceAccordionRequirementTitleProps {
+ type: string;
+ name: string;
+ status: FindingStatus;
+}
+
+export const ComplianceAccordionRequirementTitle = ({
+ name,
+ status,
+}: ComplianceAccordionRequirementTitleProps) => {
+ return (
+
+ );
+};
diff --git a/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx b/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx
new file mode 100644
index 0000000000..0f3f681b59
--- /dev/null
+++ b/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx
@@ -0,0 +1,137 @@
+import { Tooltip } from "@nextui-org/react";
+
+interface ComplianceAccordionTitleProps {
+ label: string;
+ pass: number;
+ fail: number;
+ manual?: number;
+ isParentLevel?: boolean;
+}
+
+export const ComplianceAccordionTitle = ({
+ label,
+ pass,
+ fail,
+ manual = 0,
+ isParentLevel = false,
+}: ComplianceAccordionTitleProps) => {
+ const total = pass + fail + manual;
+ const passPercentage = (pass / total) * 100;
+ const failPercentage = (fail / total) * 100;
+ const manualPercentage = (manual / total) * 100;
+
+ return (
+
+
+
+ {label.charAt(0).toUpperCase() + label.slice(1)}
+
+
+
+
+ {total > 0 && isParentLevel && (
+
+ Requirements:
+
+ )}
+
+
+
+ {total > 0 ? (
+
+ {pass > 0 && (
+
+ Pass
+
+ {pass} ({passPercentage.toFixed(1)}%)
+
+
+ }
+ size="sm"
+ placement="top"
+ delay={0}
+ closeDelay={0}
+ >
+
0 ? "2px" : "0",
+ }}
+ />
+
+ )}
+ {fail > 0 && (
+
+ Fail
+
+ {fail} ({failPercentage.toFixed(1)}%)
+
+
+ }
+ size="sm"
+ placement="top"
+ delay={0}
+ closeDelay={0}
+ >
+
0 ? "2px" : "0",
+ }}
+ />
+
+ )}
+ {manual > 0 && (
+
+ Manual
+
+ {manual} ({manualPercentage.toFixed(1)}%)
+
+
+ }
+ size="sm"
+ placement="top"
+ delay={0}
+ closeDelay={0}
+ >
+
+
+ )}
+
+ ) : (
+
+ )}
+
+
+
+ Total requirements
+ {total}
+
+ }
+ size="sm"
+ placement="top"
+ >
+
+ {total > 0 ? total : "—"}
+
+
+
+
+ );
+};
diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx
index ff55421681..10a473236f 100644
--- a/ui/components/compliance/compliance-card.tsx
+++ b/ui/components/compliance/compliance-card.tsx
@@ -2,7 +2,7 @@
import { Card, CardBody, Progress } from "@nextui-org/react";
import Image from "next/image";
-import { useSearchParams } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import React, { useState } from "react";
import { DownloadIconButton, toast } from "@/components/ui";
@@ -19,6 +19,7 @@ interface ComplianceCardProps {
prevTotalRequirements: number;
scanId: string;
complianceId: string;
+ id: string;
}
export const ComplianceCard: React.FC
= ({
@@ -28,8 +29,10 @@ export const ComplianceCard: React.FC = ({
totalRequirements,
scanId,
complianceId,
+ id,
}) => {
const searchParams = useSearchParams();
+ const router = useRouter();
const hasRegionFilter = searchParams.has("filter[region__in]");
const [isDownloading, setIsDownloading] = useState(false);
@@ -68,6 +71,22 @@ export const ComplianceCard: React.FC = ({
return "success";
};
+ const navigateToDetail = () => {
+ // We will unlock this while developing the rest of complainces.
+ if (!id.includes("ens") && !id.includes("iso") && !id.includes("cis_")) {
+ return;
+ }
+
+ const formattedTitleForUrl = encodeURIComponent(title);
+ const path = `/compliance/${formattedTitleForUrl}`;
+ const params = new URLSearchParams();
+
+ params.set("complianceId", id);
+ params.set("version", version);
+ params.set("scanId", scanId);
+
+ router.push(`${path}?${params.toString()}`);
+ };
const handleDownload = async () => {
setIsDownloading(true);
try {
@@ -78,7 +97,13 @@ export const ComplianceCard: React.FC = ({
};
return (
-
+
+ Failed Sections (Top 5)
+
+);
+
+export const BarChart = ({ sections }: FailedSectionsListProps) => {
+ const { theme } = useTheme();
+
+ const getTypeColor = (type: string) => {
+ switch (type.toLowerCase()) {
+ case "requisito":
+ return "#ff5356";
+ case "recomendacion":
+ return "#FDC53A"; // Increased contrast from #FDDD8A
+ case "refuerzo":
+ return "#7FB5FF"; // Increased contrast from #B5D7FF
+ default:
+ return "#ff5356";
+ }
+ };
+
+ const chartData = [...sections]
+ .sort((a, b) => b.total - a.total)
+ .slice(0, 5)
+ .map((section) => ({
+ name: section.name.charAt(0).toUpperCase() + section.name.slice(1),
+ ...section.types,
+ }));
+
+ const allTypes = Array.from(
+ new Set(sections.flatMap((section) => Object.keys(section.types || {}))),
+ );
+
+ // Add empty bars to complete up to 5 bars for better distribution
+ while (chartData.length < 5) {
+ const emptyBar: any = { name: "" };
+ allTypes.forEach((type) => {
+ emptyBar[type] = 0;
+ });
+ chartData.push(emptyBar);
+ }
+
+ // Calculate the maximum value to ensure proper scaling
+ const maxValue = Math.max(
+ ...chartData.map((item) =>
+ allTypes.reduce((sum, type) => sum + ((item as any)[type] || 0), 0),
+ ),
+ );
+
+ // Set minimum domain to ensure bars are always visible
+ const domainMax = Math.max(maxValue, 1);
+
+ // Check if there are no failed sections
+ if (!sections || sections.length === 0) {
+ return (
+
+ {title}
+
+
There are no failed sections
+
+
+ );
+ }
+
+ return (
+
+
{title}
+
+
+
+
+
+
+ {
+ if (!props.active || !props.payload || !props.payload.length) {
+ return null;
+ }
+
+ const data = props.payload[0].payload;
+ if (!data.name || data.name === "") {
+ return null;
+ }
+
+ const hasValues = allTypes.some((type) => data[type] > 0);
+ if (!hasValues) {
+ return null;
+ }
+
+ return (
+
+ {props.payload.map((entry: any, index: number) => (
+
+ {translateType(entry.dataKey)}: {entry.value}
+
+ ))}
+
+ );
+ }}
+ cursor={false}
+ />
+ {allTypes.map((type, i) => (
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/ui/components/compliance/compliance-charts/heatmap-chart.tsx b/ui/components/compliance/compliance-charts/heatmap-chart.tsx
new file mode 100644
index 0000000000..d4cb3b3a0a
--- /dev/null
+++ b/ui/components/compliance/compliance-charts/heatmap-chart.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import { useTheme } from "next-themes";
+import { useState } from "react";
+
+import { CategoryData, RegionData } 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 => {
+ if (percentage === 0) return "#10b981"; // Green for 0% failures
+ if (percentage <= 25) return "#eab308"; // Yellow
+ if (percentage <= 50) return "#f97316"; // Orange
+ if (percentage <= 100) return "#ef4444"; // Red
+ return "#ef4444";
+};
+
+const capitalizeFirstLetter = (text: string): string => {
+ const lowerText = text.toLowerCase();
+ const firstLetterIndex = lowerText.search(/[a-zA-Z]/);
+ if (firstLetterIndex === -1) return text; // No letters found
+
+ return (
+ lowerText.slice(0, firstLetterIndex) +
+ lowerText.charAt(firstLetterIndex).toUpperCase() +
+ lowerText.slice(firstLetterIndex + 1)
+ );
+};
+
+export const HeatmapChart = ({
+ regions,
+ categories = [],
+ isRegionFiltered = false,
+}: HeatmapChartProps) => {
+ const { theme } = useTheme();
+ const [hoveredItem, setHoveredItem] = useState<
+ RegionData | 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
+ .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";
+
+ return (
+
+
+ {isRegionFiltered
+ ? "Categories Failure Rate"
+ : "Failure Rate by Region"}
+
+
+
+ );
+ }
+
+ const handleMouseEnter = (
+ item: RegionData | CategoryData,
+ event: React.MouseEvent,
+ ) => {
+ setHoveredItem(item);
+ setMousePosition({ x: event.clientX, y: event.clientY });
+ };
+
+ const handleMouseMove = (event: React.MouseEvent) => {
+ setMousePosition({ x: event.clientX, y: event.clientY });
+ };
+
+ const handleMouseLeave = () => {
+ setHoveredItem(null);
+ };
+
+ return (
+
+
+
+ {isRegionFiltered
+ ? "Categories Failure Rate"
+ : "Failure Rate by Region"}
+
+
+
+
+ {/* 3x3 Grid */}
+
+ {heatmapData.map((item) => (
+
handleMouseEnter(item, e)}
+ onMouseMove={handleMouseMove}
+ onMouseLeave={handleMouseLeave}
+ >
+
+
+ {isRegionFiltered
+ ? capitalizeFirstLetter(item.name)
+ : item.name}
+
+
+ {item.failurePercentage}%
+
+
+
+ ))}
+
+
+ {/* Custom Tooltip */}
+ {hoveredItem && (
+
+
+ {isRegionFiltered
+ ? capitalizeFirstLetter(hoveredItem.name)
+ : hoveredItem.name}
+
+
Failure Rate: {hoveredItem.failurePercentage}%
+
+ Failed: {hoveredItem.failedRequirements}/
+ {hoveredItem.totalRequirements}
+
+
+ )}
+
+
+ );
+};
diff --git a/ui/components/compliance/compliance-charts/pie-chart.tsx b/ui/components/compliance/compliance-charts/pie-chart.tsx
new file mode 100644
index 0000000000..3e5cc01fb8
--- /dev/null
+++ b/ui/components/compliance/compliance-charts/pie-chart.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import { useTheme } from "next-themes";
+import {
+ Cell,
+ Label,
+ Pie,
+ PieChart as RechartsPieChart,
+ Tooltip,
+} from "recharts";
+
+import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
+
+interface PieChartProps {
+ pass: number;
+ fail: number;
+ manual: number;
+}
+
+const chartConfig = {
+ number: {
+ label: "Requirements",
+ },
+ pass: {
+ label: "Pass",
+ color: "hsl(var(--chart-success))",
+ },
+ fail: {
+ label: "Fail",
+ color: "hsl(var(--chart-fail))",
+ },
+ manual: {
+ label: "Manual",
+ color: "hsl(var(--chart-warning))",
+ },
+} satisfies ChartConfig;
+
+export const PieChart = ({ pass, fail, manual }: PieChartProps) => {
+ const { theme } = useTheme();
+
+ const chartData = [
+ {
+ name: "Pass",
+ value: pass,
+ fill: "#3CEC6D",
+ },
+ {
+ name: "Fail",
+ value: fail,
+ fill: "#FB718F",
+ },
+ {
+ name: "Manual",
+ value: manual,
+ fill: "#868994",
+ },
+ ];
+
+ const totalRequirements = pass + fail + manual;
+
+ const emptyChartData = [
+ {
+ name: "Empty",
+ value: 1,
+ fill: "#64748b",
+ },
+ ];
+
+ interface CustomTooltipProps {
+ active: boolean;
+ payload: {
+ payload: {
+ name: string;
+ value: number;
+ fill: string;
+ };
+ }[];
+ }
+
+ const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
+ if (active && payload && payload.length) {
+ const data = payload[0];
+ return (
+
+
+
+
+ {data.payload.name}: {data.payload.value}
+
+
+
+ );
+ }
+ return null;
+ };
+
+ return (
+
+
+ Requirements Status
+
+
+
+
+ }
+ />
+ 0 ? chartData : emptyChartData}
+ dataKey="value"
+ nameKey="name"
+ innerRadius={70}
+ outerRadius={100}
+ paddingAngle={2}
+ cornerRadius={4}
+ >
+ {(totalRequirements > 0 ? chartData : emptyChartData).map(
+ (entry, index) => (
+ |
+ ),
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/ui/components/compliance/compliance-custom-details/cis-details.tsx b/ui/components/compliance/compliance-custom-details/cis-details.tsx
new file mode 100644
index 0000000000..e4d99db68f
--- /dev/null
+++ b/ui/components/compliance/compliance-custom-details/cis-details.tsx
@@ -0,0 +1,150 @@
+import ReactMarkdown from "react-markdown";
+
+import { Requirement } from "@/types/compliance";
+
+interface CISDetailsProps {
+ requirement: Requirement;
+}
+
+export const CISCustomDetails = ({ requirement }: CISDetailsProps) => {
+ const processReferences = (
+ references: string | number | string[] | undefined,
+ ): string[] => {
+ if (typeof references !== "string") return [];
+
+ // Use regex to extract all URLs that start with https://
+ const urlRegex = /https:\/\/[^:]+/g;
+ const urls = references.match(urlRegex);
+
+ return urls || [];
+ };
+
+ return (
+
+ {requirement.profile && (
+
+
+ Profile Level
+
+
{requirement.profile}
+
+ )}
+
+ {requirement.subsection && (
+
+
+ SubSection
+
+
{requirement.subsection}
+
+ )}
+
+ {requirement.assessment_status && (
+
+
+ Assessment Status
+
+
{requirement.assessment_status}
+
+ )}
+
+ {requirement.description && (
+
+
+ Description
+
+
{requirement.description}
+
+ )}
+
+ {requirement.rationale_statement && (
+
+
+ Rationale Statement
+
+
{requirement.rationale_statement}
+
+ )}
+
+ {requirement.impact_statement && (
+
+
+ Impact Statement
+
+
{requirement.impact_statement}
+
+ )}
+
+ {requirement.remediation_procedure &&
+ typeof requirement.remediation_procedure === "string" && (
+
+
+ Remediation Procedure
+
+ {/* Prettier -> "plugins": ["prettier-plugin-tailwindcss"] is not ready yet to "prose": */}
+ {/* eslint-disable-next-line */}
+
+ {requirement.remediation_procedure}
+
+
+ )}
+
+ {requirement.audit_procedure &&
+ typeof requirement.audit_procedure === "string" && (
+
+
+ Audit Procedure
+
+ {/* eslint-disable-next-line */}
+
+ {requirement.audit_procedure}
+
+
+ )}
+
+ {requirement.additional_information && (
+
+
+ Additional Information
+
+
+ {requirement.additional_information}
+
+
+ )}
+
+ {requirement.default_value && (
+
+
+ Default Value
+
+
{requirement.default_value}
+
+ )}
+
+ {requirement.references && (
+
+
+ References
+
+
+ {processReferences(requirement.references).map(
+ (url: string, index: number) => (
+
+ ),
+ )}
+
+
+ )}
+
+ );
+};
diff --git a/ui/components/compliance/compliance-custom-details/ens-details.tsx b/ui/components/compliance/compliance-custom-details/ens-details.tsx
new file mode 100644
index 0000000000..2c133f7e11
--- /dev/null
+++ b/ui/components/compliance/compliance-custom-details/ens-details.tsx
@@ -0,0 +1,47 @@
+import { translateType } from "@/lib/compliance/ens";
+import { Requirement } from "@/types/compliance";
+
+export const ENSCustomDetails = ({
+ requirement,
+}: {
+ requirement: Requirement;
+}) => {
+ return (
+
+
+ {requirement.description}
+
+
+
+ Type:
+
+ {translateType(requirement.type as string)}
+
+
+
+ Level:
+ {requirement.nivel}
+
+ {requirement.dimensiones &&
+ Array.isArray(requirement.dimensiones) &&
+ requirement.dimensiones.length > 0 && (
+
+
Dimensions:
+
+ {requirement.dimensiones.map(
+ (dimension: string, index: number) => (
+
+ {dimension}
+
+ ),
+ )}
+
+
+ )}
+
+
+ );
+};
diff --git a/ui/components/compliance/compliance-custom-details/iso-details.tsx b/ui/components/compliance/compliance-custom-details/iso-details.tsx
new file mode 100644
index 0000000000..1bcf687b08
--- /dev/null
+++ b/ui/components/compliance/compliance-custom-details/iso-details.tsx
@@ -0,0 +1,23 @@
+import { Requirement } from "@/types/compliance";
+
+export const ISOCustomDetails = ({
+ requirement,
+}: {
+ requirement: Requirement;
+}) => {
+ return (
+
+
+ {requirement.description}
+
+
+ {requirement.objetive_name && (
+
+ Objective:
+ {requirement.objetive_name}
+
+ )}
+
+
+ );
+};
diff --git a/ui/components/compliance/compliance-header/compliance-header.tsx b/ui/components/compliance/compliance-header/compliance-header.tsx
new file mode 100644
index 0000000000..e6bfe0dd2d
--- /dev/null
+++ b/ui/components/compliance/compliance-header/compliance-header.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { Spacer } from "@nextui-org/react";
+
+import { FilterControls } from "@/components/filters";
+import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
+
+import { DataCompliance } from "./data-compliance";
+import { SelectScanComplianceDataProps } from "./select-scan-compliance-data";
+
+interface ComplianceHeaderProps {
+ scans: SelectScanComplianceDataProps["scans"];
+ uniqueRegions: string[];
+ showSearch?: boolean;
+ showRegionFilter?: boolean;
+ framework?: string; // Framework name to show specific filters
+}
+
+export const ComplianceHeader = ({
+ scans,
+ uniqueRegions,
+ showSearch = true,
+ showRegionFilter = true,
+ framework,
+}: ComplianceHeaderProps) => {
+ const frameworkFilters = [];
+
+ // Add CIS Profile Level filter if framework is CIS
+ if (framework === "CIS") {
+ frameworkFilters.push({
+ key: "cis_profile_level",
+ labelCheckboxGroup: "Level",
+ values: ["Level 1", "Level 2"],
+ index: 0, // Show first
+ showSelectAll: false, // No "Select All" option since Level 2 includes Level 1
+ defaultValues: ["Level 2"], // Default to Level 2 selected (which includes Level 1)
+ });
+ }
+
+ // Prepare region filters
+ const regionFilters = showRegionFilter
+ ? [
+ {
+ key: "region__in",
+ labelCheckboxGroup: "Regions",
+ values: uniqueRegions,
+ index: 1, // Show after framework filters
+ defaultToSelectAll: true, // Default to all regions selected
+ },
+ ]
+ : [];
+
+ const allFilters = [...frameworkFilters, ...regionFilters];
+
+ return (
+ <>
+ {showSearch && }
+
+
+ {allFilters.length > 0 && (
+ <>
+
+
+ >
+ )}
+
+ >
+ );
+};
diff --git a/ui/components/compliance/compliance-scan-info.tsx b/ui/components/compliance/compliance-header/compliance-scan-info.tsx
similarity index 99%
rename from ui/components/compliance/compliance-scan-info.tsx
rename to ui/components/compliance/compliance-header/compliance-scan-info.tsx
index c4bff1d7ad..a6690da618 100644
--- a/ui/components/compliance/compliance-scan-info.tsx
+++ b/ui/components/compliance/compliance-header/compliance-scan-info.tsx
@@ -3,6 +3,7 @@ import React from "react";
import { DateWithTime, EntityInfoShort } from "@/components/ui/entities";
import { ProviderType } from "@/types";
+
interface ComplianceScanInfoProps {
scan: {
providerInfo: {
diff --git a/ui/components/compliance/data-compliance/data-compliance.tsx b/ui/components/compliance/compliance-header/data-compliance.tsx
similarity index 90%
rename from ui/components/compliance/data-compliance/data-compliance.tsx
rename to ui/components/compliance/compliance-header/data-compliance.tsx
index 9b57033bc1..a24a1db6c1 100644
--- a/ui/components/compliance/data-compliance/data-compliance.tsx
+++ b/ui/components/compliance/compliance-header/data-compliance.tsx
@@ -3,8 +3,10 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
-import { SelectScanComplianceData } from "@/components/compliance/data-compliance";
-import { SelectScanComplianceDataProps } from "@/types";
+import {
+ SelectScanComplianceData,
+ SelectScanComplianceDataProps,
+} from "@/components/compliance/compliance-header/index";
interface DataComplianceProps {
scans: SelectScanComplianceDataProps["scans"];
}
diff --git a/ui/components/compliance/data-compliance/index.ts b/ui/components/compliance/compliance-header/index.ts
similarity index 100%
rename from ui/components/compliance/data-compliance/index.ts
rename to ui/components/compliance/compliance-header/index.ts
diff --git a/ui/components/compliance/data-compliance/select-scan-compliance-data.tsx b/ui/components/compliance/compliance-header/select-scan-compliance-data.tsx
similarity index 72%
rename from ui/components/compliance/data-compliance/select-scan-compliance-data.tsx
rename to ui/components/compliance/compliance-header/select-scan-compliance-data.tsx
index 77e4b12198..f28b79fe1a 100644
--- a/ui/components/compliance/data-compliance/select-scan-compliance-data.tsx
+++ b/ui/components/compliance/compliance-header/select-scan-compliance-data.tsx
@@ -1,8 +1,20 @@
import { Select, SelectItem } from "@nextui-org/react";
-import { SelectScanComplianceDataProps } from "@/types";
+import { ProviderType, ScanProps } from "@/types";
-import { ComplianceScanInfo } from "../compliance-scan-info";
+import { ComplianceScanInfo } from "./compliance-scan-info";
+
+export interface SelectScanComplianceDataProps {
+ scans: (ScanProps & {
+ providerInfo: {
+ provider: ProviderType;
+ uid: string;
+ alias: string;
+ };
+ })[];
+ selectedScanId: string;
+ onSelectionChange: (selectedKey: string) => void;
+}
export const SelectScanComplianceData = ({
scans,
diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts
index d0ba1eb027..5beaaf4c5b 100644
--- a/ui/components/compliance/index.ts
+++ b/ui/components/compliance/index.ts
@@ -1,4 +1,21 @@
+export * from "./compliance-accordion/client-accordion-content";
+export * from "./compliance-accordion/client-accordion-wrapper";
+export * from "./compliance-accordion/compliance-accordion-requeriment-title";
+export * from "./compliance-accordion/compliance-accordion-title";
export * from "./compliance-card";
-export * from "./compliance-scan-info";
-export * from "./compliance-skeleton-grid";
+export * from "./compliance-charts/bar-chart";
+export * from "./compliance-charts/heatmap-chart";
+export * from "./compliance-charts/pie-chart";
+export * from "./compliance-custom-details/cis-details";
+export * from "./compliance-custom-details/ens-details";
+export * from "./compliance-custom-details/iso-details";
+export * from "./compliance-header/compliance-header";
+export * from "./compliance-header/compliance-scan-info";
+export * from "./compliance-header/data-compliance";
+export * from "./compliance-header/select-scan-compliance-data";
export * from "./no-scans-available";
+export * from "./skeletons/bar-chart-skeleton";
+export * from "./skeletons/compliance-accordion-skeleton";
+export * from "./skeletons/compliance-grid-skeleton";
+export * from "./skeletons/heatmap-chart-skeleton";
+export * from "./skeletons/pie-chart-skeleton";
diff --git a/ui/components/compliance/skeletons/bar-chart-skeleton.tsx b/ui/components/compliance/skeletons/bar-chart-skeleton.tsx
new file mode 100644
index 0000000000..05f26ae938
--- /dev/null
+++ b/ui/components/compliance/skeletons/bar-chart-skeleton.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { Skeleton } from "@nextui-org/react";
+
+export const BarChartSkeleton = () => {
+ return (
+
+ {/* Title skeleton */}
+
+
+
+
+ {/* Chart area skeleton */}
+
+ {/* Bar chart skeleton - 5 horizontal bars */}
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ {/* Bar skeleton with varying widths */}
+
+
+
+
+ ))}
+
+ {/* Legend skeleton */}
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/ui/components/compliance/skeletons/compliance-accordion-skeleton.tsx b/ui/components/compliance/skeletons/compliance-accordion-skeleton.tsx
new file mode 100644
index 0000000000..f1077b53ee
--- /dev/null
+++ b/ui/components/compliance/skeletons/compliance-accordion-skeleton.tsx
@@ -0,0 +1,30 @@
+import { Skeleton } from "@nextui-org/react";
+import React from "react";
+
+interface SkeletonAccordionProps {
+ itemCount?: number;
+ className?: string;
+ isCompact?: boolean;
+}
+
+export const SkeletonAccordion = ({
+ itemCount = 3,
+ className = "",
+ isCompact = false,
+}: SkeletonAccordionProps) => {
+ const itemHeight = isCompact ? "h-10" : "h-14";
+
+ return (
+
+ {[...Array(itemCount)].map((_, index) => (
+
+
+
+ ))}
+
+ );
+};
+
+SkeletonAccordion.displayName = "SkeletonAccordion";
diff --git a/ui/components/compliance/compliance-skeleton-grid.tsx b/ui/components/compliance/skeletons/compliance-grid-skeleton.tsx
similarity index 100%
rename from ui/components/compliance/compliance-skeleton-grid.tsx
rename to ui/components/compliance/skeletons/compliance-grid-skeleton.tsx
diff --git a/ui/components/compliance/skeletons/heatmap-chart-skeleton.tsx b/ui/components/compliance/skeletons/heatmap-chart-skeleton.tsx
new file mode 100644
index 0000000000..ae247900f2
--- /dev/null
+++ b/ui/components/compliance/skeletons/heatmap-chart-skeleton.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { Skeleton } from "@nextui-org/react";
+
+export const HeatmapChartSkeleton = () => {
+ return (
+
+ {/* Title skeleton */}
+
+
+
+
+ {/* Heatmap area skeleton - 3x3 grid like the real component */}
+
+
+ {Array.from({ length: 9 }).map((_, index) => (
+
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/ui/components/compliance/skeletons/pie-chart-skeleton.tsx b/ui/components/compliance/skeletons/pie-chart-skeleton.tsx
new file mode 100644
index 0000000000..f21c653b24
--- /dev/null
+++ b/ui/components/compliance/skeletons/pie-chart-skeleton.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { Skeleton } from "@nextui-org/react";
+
+export const PieChartSkeleton = () => {
+ return (
+
+ {/* Title skeleton */}
+
+
+
+
+ {/* Pie chart skeleton */}
+
+ {/* Outer circle */}
+
+
+
+
+ {/* Inner circle (donut hole) */}
+
+
+ {/* Center text skeleton */}
+
+
+
+ {/* Bottom stats skeleton */}
+
+
+ );
+};
diff --git a/ui/components/findings/table/skeleton-table-findings.tsx b/ui/components/findings/table/skeleton-table-findings.tsx
index 3af6403e7c..865fd14911 100644
--- a/ui/components/findings/table/skeleton-table-findings.tsx
+++ b/ui/components/findings/table/skeleton-table-findings.tsx
@@ -1,65 +1,11 @@
-import { Card, Skeleton } from "@nextui-org/react";
import React from "react";
+import { SkeletonTable } from "../../ui/skeleton/skeleton";
+
export const SkeletonTableFindings = () => {
return (
-
- {/* Table headers */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Table body */}
-
- {[...Array(3)].map((_, index) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
+
+
+
);
};
diff --git a/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx b/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx
index 3f30d2ee70..ae4d704731 100644
--- a/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx
+++ b/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx
@@ -116,9 +116,9 @@ export const FindingsBySeverityChart = ({
>
diff --git a/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx b/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx
index 331748adc0..7e6040ed60 100644
--- a/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx
+++ b/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx
@@ -146,9 +146,9 @@ export const FindingsByStatusChart: React.FC = ({
-
+
-
+
{
return (
-
- {/* Table headers */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Table body */}
-
- {[...Array(3)].map((_, index) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
+
+
+
);
};
diff --git a/ui/components/ui/accordion/Accordion.tsx b/ui/components/ui/accordion/Accordion.tsx
index aa029a121c..1bbcbc9ed8 100644
--- a/ui/components/ui/accordion/Accordion.tsx
+++ b/ui/components/ui/accordion/Accordion.tsx
@@ -6,7 +6,7 @@ import {
Selection,
} from "@nextui-org/react";
import { ChevronDown } from "lucide-react";
-import React, { ReactNode, useCallback, useState } from "react";
+import React, { ReactNode, useCallback, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
@@ -24,17 +24,24 @@ export interface AccordionProps {
variant?: "light" | "shadow" | "bordered" | "splitted";
className?: string;
defaultExpandedKeys?: string[];
+ selectedKeys?: string[];
selectionMode?: "single" | "multiple";
isCompact?: boolean;
showDivider?: boolean;
+ onItemExpand?: (key: string) => void;
+ onSelectionChange?: (keys: string[]) => void;
}
const AccordionContent = ({
content,
items,
+ selectedKeys,
+ onSelectionChange,
}: {
content: ReactNode;
items?: AccordionItemProps[];
+ selectedKeys?: string[];
+ onSelectionChange?: (keys: string[]) => void;
}) => {
return (
@@ -46,6 +53,8 @@ const AccordionContent = ({
variant="light"
isCompact
selectionMode="multiple"
+ selectedKeys={selectedKeys}
+ onSelectionChange={onSelectionChange}
/>
)}
@@ -58,21 +67,58 @@ export const Accordion = ({
variant = "light",
className,
defaultExpandedKeys = [],
+ selectedKeys,
selectionMode = "single",
isCompact = false,
showDivider = true,
+ onItemExpand,
+ onSelectionChange,
}: AccordionProps) => {
- const [expandedKeys, setExpandedKeys] = useState
(
+ // Determine if component is in controlled or uncontrolled mode
+ const isControlled = selectedKeys !== undefined;
+
+ const [internalExpandedKeys, setInternalExpandedKeys] = useState(
new Set(defaultExpandedKeys),
);
- const handleSelectionChange = useCallback((keys: Selection) => {
- setExpandedKeys(keys);
- }, []);
+ // Use selectedKeys if controlled, otherwise use internal state
+ const expandedKeys = useMemo(
+ () => (isControlled ? new Set(selectedKeys) : internalExpandedKeys),
+ [isControlled, selectedKeys, internalExpandedKeys],
+ );
+
+ const handleSelectionChange = useCallback(
+ (keys: Selection) => {
+ const keysArray = Array.from(keys as Set);
+
+ // If controlled mode, call parent callback
+ if (isControlled && onSelectionChange) {
+ onSelectionChange(keysArray);
+ } else {
+ // If uncontrolled, update internal state
+ setInternalExpandedKeys(keys);
+ }
+
+ // Handle onItemExpand for backward compatibility
+ if (onItemExpand && keys !== expandedKeys) {
+ const currentKeys = Array.from(expandedKeys as Set);
+ const newKeys = keysArray;
+
+ const newlyExpandedKeys = newKeys.filter(
+ (key) => !currentKeys.includes(key),
+ );
+
+ newlyExpandedKeys.forEach((key) => {
+ onItemExpand(key);
+ });
+ }
+ },
+ [expandedKeys, onItemExpand, isControlled, onSelectionChange],
+ );
return (
}
classNames={{
- base: index === 0 || index === 1 ? "my-2" : "my-1",
- title: "text-sm font-medium",
+ base: index === 0 || index === 1 ? "my-1" : "my-1",
+ title: "text-sm font-medium max-w-full overflow-hidden truncate",
subtitle: "text-xs text-gray-500",
trigger:
- "p-2 rounded-lg data-[hover=true]:bg-gray-50 dark:data-[hover=true]:bg-gray-800/50",
- content: "p-2",
+ "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",
+ content: "px-0 py-1",
}}
>
-
+
))}
diff --git a/ui/components/ui/chart/horizontal-split-chart.tsx b/ui/components/ui/chart/horizontal-split-chart.tsx
index b3b4c783a3..88c79e2996 100644
--- a/ui/components/ui/chart/horizontal-split-chart.tsx
+++ b/ui/components/ui/chart/horizontal-split-chart.tsx
@@ -70,6 +70,16 @@ interface HorizontalSplitBarProps {
* @default "text-gray-700"
*/
labelColor?: string;
+ /**
+ * Growth ratio multiplier (pixels per value unit)
+ * @default 1
+ */
+ ratio?: number;
+ /**
+ * Show zero values in labels
+ * @default true
+ */
+ showZero?: boolean;
}
/**
@@ -99,6 +109,8 @@ export const HorizontalSplitBar = ({
tooltipContentA,
tooltipContentB,
labelColor = "text-gray-700",
+ ratio = 1,
+ showZero = true,
}: HorizontalSplitBarProps) => {
// Reference to the container to measure its width
const containerRef = React.useRef(null);
@@ -150,8 +162,9 @@ export const HorizontalSplitBar = ({
const halfWidth = availableWidth / 2;
const separatorWidth = 1;
- let rawWidthA = valA;
- let rawWidthB = valB;
+ // Apply ratio multiplier to raw widths
+ let rawWidthA = valA * ratio;
+ let rawWidthB = valB * ratio;
// Determine if we need to scale to fit in available space
const maxSideWidth = halfWidth - separatorWidth / 2;
@@ -183,7 +196,7 @@ export const HorizontalSplitBar = ({
className={cn("text-xs font-medium", labelColor)}
aria-label={`${formattedValueA} ${tooltipContentA ? tooltipContentA : ""}`}
>
- {valA > 0 ? formattedValueA : "0"}
+ {valA > 0 ? formattedValueA : showZero ? "0" : ""}
{/* Left bar */}
{valA > 0 && (
@@ -230,7 +243,7 @@ export const HorizontalSplitBar = ({
className={cn("text-xs font-medium", labelColor)}
aria-label={`${formattedValueB} ${tooltipContentB ? tooltipContentB : ""}`}
>
- {valB > 0 ? formattedValueB : "0"}
+ {valB > 0 ? formattedValueB : showZero ? "0" : ""}
diff --git a/ui/components/ui/content-layout/content-layout.tsx b/ui/components/ui/content-layout/content-layout.tsx
index f1fdb13a57..518fb58efa 100644
--- a/ui/components/ui/content-layout/content-layout.tsx
+++ b/ui/components/ui/content-layout/content-layout.tsx
@@ -1,12 +1,13 @@
-import { Suspense, use } from "react";
+import { ReactNode, Suspense, use } from "react";
import { getUserInfo } from "@/actions/users/users";
import { Navbar } from "../nav-bar/navbar";
import { SkeletonContentLayout } from "./skeleton-content-layout";
+
interface ContentLayoutProps {
title: string;
- icon: string;
+ icon: string | ReactNode;
children: React.ReactNode;
}
diff --git a/ui/components/ui/custom/custom-dropdown-filter.tsx b/ui/components/ui/custom/custom-dropdown-filter.tsx
index 254efa5024..b17828083a 100644
--- a/ui/components/ui/custom/custom-dropdown-filter.tsx
+++ b/ui/components/ui/custom/custom-dropdown-filter.tsx
@@ -12,7 +12,13 @@ import {
} from "@nextui-org/react";
import { ChevronDown, X } from "lucide-react";
import { useSearchParams } from "next/navigation";
-import React, { useCallback, useEffect, useMemo, useState } from "react";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { CustomDropdownFilterProps } from "@/types";
@@ -25,6 +31,7 @@ export const CustomDropdownFilter = ({
const searchParams = useSearchParams();
const [groupSelected, setGroupSelected] = useState(new Set
());
const [isOpen, setIsOpen] = useState(false);
+ const hasUserInteracted = useRef(false);
const filterValues = useMemo(() => filter?.values || [], [filter?.values]);
const selectedValues = Array.from(groupSelected).filter(
@@ -42,24 +49,66 @@ export const CustomDropdownFilter = ({
useEffect(() => {
if (activeFilterValue.length > 0) {
const newSelection = new Set(activeFilterValue);
- if (newSelection.size === filterValues.length) {
+ if (
+ newSelection.size === filterValues.length &&
+ filter?.showSelectAll !== false
+ ) {
newSelection.add("all");
}
setGroupSelected(newSelection);
- } else {
- setGroupSelected(new Set());
+ } 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());
+ }
}
- }, [activeFilterValue, filterValues.length]);
+ }, [
+ activeFilterValue,
+ filterValues,
+ filter?.defaultToSelectAll,
+ filter?.defaultValues,
+ filter?.showSelectAll,
+ ]);
const updateSelection = useCallback(
(newValues: string[]) => {
+ // Mark that user has interacted with the filter
+ hasUserInteracted.current = true;
+
const actualValues = newValues.filter((key) => key !== "all");
const newSelection = new Set(actualValues);
- // Auto-add "all" if all items are selected
+ // Auto-add "all" if all items are selected and showSelectAll is not false
if (
actualValues.length === filterValues.length &&
- filterValues.length > 0
+ filterValues.length > 0 &&
+ filter?.showSelectAll !== false
) {
newSelection.add("all");
}
@@ -69,7 +118,7 @@ export const CustomDropdownFilter = ({
// Notify parent with actual values (excluding "all")
onFilterChange?.(filter.key, actualValues);
},
- [filterValues.length, onFilterChange, filter.key],
+ [filterValues.length, onFilterChange, filter.key, filter?.showSelectAll],
);
const onSelectionChange = useCallback(
@@ -194,16 +243,20 @@ export const CustomDropdownFilter = ({
onValueChange={onSelectionChange}
className="font-bold"
>
-
- Select All
-
-
+ {filter?.showSelectAll !== false && (
+ <>
+
+ Select All
+
+
+ >
+ )}
-
+ {typeof icon === "string" ? (
+
+ ) : (
+
+ {icon}
+
+ )}
{title}
diff --git a/ui/components/ui/skeleton/skeleton.tsx b/ui/components/ui/skeleton/skeleton.tsx
new file mode 100644
index 0000000000..4591264f7d
--- /dev/null
+++ b/ui/components/ui/skeleton/skeleton.tsx
@@ -0,0 +1,123 @@
+import { cn } from "@/lib/utils";
+
+interface SkeletonProps {
+ className?: string;
+ variant?: "default" | "card" | "table" | "text" | "circle" | "rectangular";
+ width?: string | number;
+ height?: string | number;
+ animate?: boolean;
+}
+
+export function Skeleton({
+ className,
+ variant = "default",
+ width,
+ height,
+ animate = true,
+}: SkeletonProps) {
+ const variantClasses = {
+ default: "w-full h-4 rounded-lg",
+ card: "w-full h-40 rounded-xl",
+ table: "w-full h-60 rounded-lg",
+ text: "w-24 h-4 rounded-full",
+ circle: "rounded-full w-8 h-8",
+ rectangular: "rounded-md",
+ };
+
+ return (
+
+ );
+}
+
+export function SkeletonTable({
+ rows = 5,
+ columns = 4,
+ className,
+ roundedCells = true,
+}: {
+ rows?: number;
+ columns?: number;
+ className?: string;
+ roundedCells?: boolean;
+}) {
+ return (
+
+ {/* Header */}
+
+ {Array.from({ length: columns }).map((_, index) => (
+
+ ))}
+
+
+ {/* Rows */}
+ {Array.from({ length: rows }).map((_, rowIndex) => (
+
+ {Array.from({ length: columns }).map((_, colIndex) => (
+
+ ))}
+
+ ))}
+
+ );
+}
+
+export function SkeletonCard({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function SkeletonText({
+ lines = 3,
+ className,
+ lastLineWidth = "w-1/2",
+}: {
+ lines?: number;
+ className?: string;
+ lastLineWidth?: string;
+}) {
+ return (
+
+ {Array.from({ length: lines - 1 }).map((_, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/ui/components/ui/table/data-table-filter-custom.tsx b/ui/components/ui/table/data-table-filter-custom.tsx
index 3a394c30ef..de2015619f 100644
--- a/ui/components/ui/table/data-table-filter-custom.tsx
+++ b/ui/components/ui/table/data-table-filter-custom.tsx
@@ -55,7 +55,7 @@ export const DataTableFilterCustom = ({
size="md"
startContent={
}
onPress={() => setShowFilters(!showFilters)}
- className="w-fit"
+ className="w-full max-w-fit"
>
{showFilters ? "Hide Filters" : "Show Filters"}
diff --git a/ui/components/ui/table/data-table-pagination.tsx b/ui/components/ui/table/data-table-pagination.tsx
index a7773925e9..cb99c48c3d 100644
--- a/ui/components/ui/table/data-table-pagination.tsx
+++ b/ui/components/ui/table/data-table-pagination.tsx
@@ -23,9 +23,19 @@ import {
interface DataTablePaginationProps {
metadata?: MetaDataProps;
+ disableScroll?: boolean;
}
-export function DataTablePagination({ metadata }: DataTablePaginationProps) {
+const baseLinkClass =
+ "relative block rounded border-0 bg-transparent px-3 py-1.5 text-gray-800 outline-none transition-all duration-300 hover:bg-gray-200 hover:text-gray-800 focus:shadow-none dark:text-prowler-theme-green";
+
+const disabledLinkClass =
+ "text-gray-300 dark:text-gray-600 hover:bg-transparent hover:text-gray-300 dark:hover:text-gray-600 cursor-default pointer-events-none";
+
+export function DataTablePagination({
+ metadata,
+ disableScroll = false,
+}: DataTablePaginationProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
@@ -41,90 +51,148 @@ export function DataTablePagination({ metadata }: DataTablePaginationProps) {
const createPageUrl = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
- if (pageNumber === "...") return `${pathname}?${params.toString()}`;
+ // Preserve all important parameters
+ const scanId = searchParams.get("scanId");
+ const id = searchParams.get("id");
+ const version = searchParams.get("version");
if (+pageNumber > totalPages) {
return `${pathname}?${params.toString()}`;
}
params.set("page", pageNumber.toString());
+
+ // Ensure that scanId, id and version are preserved
+ if (scanId) params.set("scanId", scanId);
+ if (id) params.set("id", id);
+ if (version) params.set("version", version);
+
return `${pathname}?${params.toString()}`;
};
+ const isFirstPage = currentPage === 1;
+ const isLastPage = currentPage === totalPages;
+
return (
-
- {totalEntries} entries in Total.
+
+ {totalEntries} entries in total
-
- {/* Rows per page selector */}
-
-
Rows per page
-
+ )}
);
}
diff --git a/ui/components/ui/table/data-table.tsx b/ui/components/ui/table/data-table.tsx
index b759e4316d..49ad556897 100644
--- a/ui/components/ui/table/data-table.tsx
+++ b/ui/components/ui/table/data-table.tsx
@@ -29,12 +29,14 @@ interface DataTableProviderProps
{
data: TData[];
metadata?: MetaDataProps;
customFilters?: FilterOption[];
+ disableScroll?: boolean;
}
export function DataTable({
columns,
data,
metadata,
+ disableScroll = false,
}: DataTableProviderProps) {
const [sorting, setSorting] = useState([]);
const [columnFilters, setColumnFilters] = useState([]);
@@ -109,7 +111,10 @@ export function DataTable({
{metadata && (
-
+
)}
>
diff --git a/ui/components/ui/table/status-finding-badge.tsx b/ui/components/ui/table/status-finding-badge.tsx
index b9532d3b5d..8177d116d7 100644
--- a/ui/components/ui/table/status-finding-badge.tsx
+++ b/ui/components/ui/table/status-finding-badge.tsx
@@ -16,10 +16,12 @@ const statusColorMap: Record<
export const StatusFindingBadge = ({
status,
size = "sm",
+ value,
...props
}: {
status: FindingStatus;
size?: "sm" | "md" | "lg";
+ value?: string | number;
}) => {
const color = statusColorMap[status];
@@ -33,6 +35,7 @@ export const StatusFindingBadge = ({
>
{status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()}
+ {value !== undefined && `: ${value}`}
);
diff --git a/ui/components/users/profile/membership-item.tsx b/ui/components/users/profile/membership-item.tsx
index 5db1e5cc6b..a22f33dda0 100644
--- a/ui/components/users/profile/membership-item.tsx
+++ b/ui/components/users/profile/membership-item.tsx
@@ -5,7 +5,7 @@ import { useState } from "react";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
import { DateWithTime, InfoField } from "@/components/ui/entities";
-import { MembershipDetailData } from "@/types/users/users";
+import { MembershipDetailData } from "@/types/users";
import { EditTenantForm } from "../forms";
diff --git a/ui/components/users/profile/memberships-card.tsx b/ui/components/users/profile/memberships-card.tsx
index 43138c74c5..0aba1b4834 100644
--- a/ui/components/users/profile/memberships-card.tsx
+++ b/ui/components/users/profile/memberships-card.tsx
@@ -1,6 +1,6 @@
import { Card, CardBody, CardHeader } from "@nextui-org/react";
-import { MembershipDetailData, TenantDetailData } from "@/types/users/users";
+import { MembershipDetailData, TenantDetailData } from "@/types/users";
import { MembershipItem } from "./membership-item";
diff --git a/ui/components/users/profile/role-item.tsx b/ui/components/users/profile/role-item.tsx
index 460d9d5398..4d1ad7861e 100644
--- a/ui/components/users/profile/role-item.tsx
+++ b/ui/components/users/profile/role-item.tsx
@@ -6,7 +6,7 @@ import { useState } from "react";
import { CustomButton } from "@/components/ui/custom/custom-button";
import { getRolePermissions } from "@/lib/permissions";
-import { RoleData, RoleDetail } from "@/types/users/users";
+import { RoleData, RoleDetail } from "@/types/users";
interface PermissionItemProps {
enabled: boolean;
diff --git a/ui/components/users/profile/roles-card.tsx b/ui/components/users/profile/roles-card.tsx
index 5135c41528..126984be08 100644
--- a/ui/components/users/profile/roles-card.tsx
+++ b/ui/components/users/profile/roles-card.tsx
@@ -1,6 +1,6 @@
import { Card, CardBody, CardHeader } from "@nextui-org/react";
-import { RoleData, RoleDetail } from "@/types/users/users";
+import { RoleData, RoleDetail } from "@/types/users";
import { RoleItem } from "./role-item";
diff --git a/ui/components/users/profile/user-basic-info-card.tsx b/ui/components/users/profile/user-basic-info-card.tsx
index 37805b2c55..7085953062 100644
--- a/ui/components/users/profile/user-basic-info-card.tsx
+++ b/ui/components/users/profile/user-basic-info-card.tsx
@@ -3,7 +3,7 @@
import { Card, CardBody, Divider } from "@nextui-org/react";
import { DateWithTime, InfoField, SnippetChip } from "@/components/ui/entities";
-import { UserDataWithRoles } from "@/types/users/users";
+import { UserDataWithRoles } from "@/types/users";
import { ProwlerShort } from "../../icons";
diff --git a/ui/lib/compliance/cis.tsx b/ui/lib/compliance/cis.tsx
new file mode 100644
index 0000000000..df59c6dd95
--- /dev/null
+++ b/ui/lib/compliance/cis.tsx
@@ -0,0 +1,204 @@
+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,
+ CISAttributesMetadata,
+ Framework,
+ Requirement,
+ RequirementItemData,
+ RequirementsData,
+ RequirementStatus,
+} from "@/types/compliance";
+
+export const mapComplianceData = (
+ attributesData: AttributesData,
+ requirementsData: RequirementsData,
+ filter?: string, // "Level 1" or "Level 2" or undefined (show all)
+): 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 CISAttributesMetadata[];
+ const attrs = metadataArray?.[0];
+ if (!attrs) continue;
+
+ // Apply profile filter
+ if (filter === "Level 1" && attrs.Profile !== "Level 1") {
+ continue; // Skip Level 2 requirements when Level 1 is selected
+ }
+
+ // Get corresponding requirement data
+ const requirementData = requirementsMap.get(id);
+ if (!requirementData) continue;
+
+ const frameworkName = attributeItem.attributes.framework;
+ const sectionName = attrs.Section;
+ const description = attributeItem.attributes.description;
+ const status = requirementData.attributes.status || "";
+ const checks = attributeItem.attributes.attributes.check_ids || [];
+ const requirementName = id;
+
+ // 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);
+ }
+
+ const normalizedSectionName = sectionName.replace(/^(\d+)\s/, "$1. ");
+ let category = framework.categories.find(
+ (c) => c.name === normalizedSectionName,
+ );
+
+ if (!category) {
+ category = {
+ name: normalizedSectionName,
+ pass: 0,
+ fail: 0,
+ manual: 0,
+ controls: [],
+ };
+ framework.categories.push(category);
+ }
+
+ // Create a control for this requirement (each requirement is its own control)
+ const controlLabel = `${id} - ${description}`;
+ const control = {
+ label: controlLabel,
+ pass: 0,
+ fail: 0,
+ manual: 0,
+ requirements: [] as Requirement[],
+ };
+
+ // Create requirement
+ const finalStatus: RequirementStatus = status as RequirementStatus;
+ const requirement: Requirement = {
+ name: requirementName,
+ description: attrs.Description,
+ status: finalStatus,
+ check_ids: checks,
+ pass: finalStatus === "PASS" ? 1 : 0,
+ fail: finalStatus === "FAIL" ? 1 : 0,
+ manual: finalStatus === "MANUAL" ? 1 : 0,
+ profile: attrs.Profile,
+ subsection: attrs.SubSection || "",
+ assessment_status: attrs.AssessmentStatus,
+ rationale_statement: attrs.RationaleStatement,
+ impact_statement: attrs.ImpactStatement,
+ remediation_procedure: attrs.RemediationProcedure,
+ audit_procedure: attrs.AuditProcedure,
+ additional_information: attrs.AdditionalInformation,
+ default_value: attrs.DefaultValue || "",
+ references: attrs.References,
+ };
+
+ control.requirements.push(requirement);
+
+ // Update control counters
+ if (requirement.status === "MANUAL") {
+ control.manual++;
+ } else if (requirement.status === "PASS") {
+ control.pass++;
+ } else if (requirement.status === "FAIL") {
+ control.fail++;
+ }
+
+ category.controls.push(control);
+ }
+
+ // Calculate counters for categories and frameworks
+ 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) => {
+ 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) => {
+ const requirement = control.requirements[0]; // Each control has one requirement
+ const itemKey = `${framework.name}-${category.name}-control-${i}`;
+
+ return {
+ key: itemKey,
+ title: (
+
+ ),
+ content: (
+
+ ),
+ items: [],
+ };
+ }),
+ };
+ }),
+ );
+};
diff --git a/ui/lib/compliance/commons.ts b/ui/lib/compliance/commons.ts
new file mode 100644
index 0000000000..6d0c3c7779
--- /dev/null
+++ b/ui/lib/compliance/commons.ts
@@ -0,0 +1,257 @@
+import React from "react";
+
+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";
+import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
+import {
+ AttributesData,
+ CategoryData,
+ FailedSection,
+ Framework,
+ RegionData,
+ Requirement,
+ RequirementsData,
+} from "@/types/compliance";
+
+import {
+ mapComplianceData as mapCISComplianceData,
+ toAccordionItems as toCISAccordionItems,
+} from "./cis";
+import {
+ mapComplianceData as mapENSComplianceData,
+ toAccordionItems as toENSAccordionItems,
+} from "./ens";
+import {
+ mapComplianceData as mapISOComplianceData,
+ toAccordionItems as toISOAccordionItems,
+} from "./iso";
+
+export interface ComplianceMapper {
+ mapComplianceData: (
+ attributesData: AttributesData,
+ requirementsData: RequirementsData,
+ filter?: string,
+ ) => Framework[];
+ toAccordionItems: (
+ data: Framework[],
+ scanId: string | undefined,
+ ) => AccordionItemProps[];
+ getTopFailedSections: (mappedData: Framework[]) => FailedSection[];
+ getDetailsComponent: (requirement: Requirement) => React.ReactNode;
+}
+
+// Common function for getting top failed sections
+export const getTopFailedSections = (
+ mappedData: Framework[],
+): FailedSection[] => {
+ const failedSectionMap = new Map();
+
+ mappedData.forEach((framework) => {
+ framework.categories.forEach((category) => {
+ category.controls.forEach((control) => {
+ control.requirements.forEach((requirement) => {
+ if (requirement.status === "FAIL") {
+ const sectionName = category.name;
+
+ if (!failedSectionMap.has(sectionName)) {
+ failedSectionMap.set(sectionName, { total: 0, types: {} });
+ }
+
+ const sectionData = failedSectionMap.get(sectionName);
+ sectionData.total += 1;
+
+ const type = requirement.type || "Fails";
+
+ sectionData.types[type as string] =
+ (sectionData.types[type as string] || 0) + 1;
+ }
+ });
+ });
+ });
+ });
+
+ // Convert in descending order and slice top 5
+ return Array.from(failedSectionMap.entries())
+ .map(([name, data]) => ({ name, ...data }))
+ .sort((a, b) => b.total - a.total)
+ .slice(0, 5); // Top 5
+};
+
+// Registry of compliance mappers
+const complianceMappers: Record = {
+ ENS: {
+ mapComplianceData: mapENSComplianceData,
+ toAccordionItems: toENSAccordionItems,
+ getTopFailedSections,
+ getDetailsComponent: (requirement: Requirement) =>
+ React.createElement(ENSCustomDetails, { requirement }),
+ },
+ ISO27001: {
+ mapComplianceData: mapISOComplianceData,
+ toAccordionItems: toISOAccordionItems,
+ getTopFailedSections,
+ getDetailsComponent: (requirement: Requirement) =>
+ React.createElement(ISOCustomDetails, { requirement }),
+ },
+ CIS: {
+ mapComplianceData: mapCISComplianceData,
+ toAccordionItems: toCISAccordionItems,
+ getTopFailedSections,
+ getDetailsComponent: (requirement: Requirement) =>
+ React.createElement(CISCustomDetails, { requirement }),
+ },
+};
+
+// Default mapper (fallback to ENS for backward compatibility)
+const defaultMapper: ComplianceMapper = complianceMappers.ENS;
+
+/**
+ * Get the appropriate compliance mapper based on the framework name
+ * @param framework - The framework name (e.g., "ENS", "ISO27001", "CIS")
+ * @returns ComplianceMapper object with specific functions for the framework
+ */
+export const getComplianceMapper = (framework?: string): ComplianceMapper => {
+ if (!framework) {
+ return defaultMapper;
+ }
+
+ 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[] => {
+ if (!complianceData?.length) {
+ return [];
+ }
+
+ try {
+ const categoryMap = new Map<
+ string,
+ { pass: number; fail: number; manual: number }
+ >();
+
+ // Aggregate data by category
+ complianceData.forEach((framework) => {
+ framework.categories.forEach((category) => {
+ const existing = categoryMap.get(category.name) || {
+ pass: 0,
+ fail: 0,
+ manual: 0,
+ };
+ categoryMap.set(category.name, {
+ pass: existing.pass + category.pass,
+ fail: existing.fail + category.fail,
+ manual: existing.manual + category.manual,
+ });
+ });
+ });
+
+ const categoryData: CategoryData[] = Array.from(categoryMap.entries()).map(
+ ([name, stats]) => {
+ const totalRequirements = stats.pass + stats.fail + stats.manual;
+ const failurePercentage =
+ totalRequirements > 0
+ ? Math.round((stats.fail / totalRequirements) * 100)
+ : 0;
+
+ return {
+ name,
+ failurePercentage,
+ totalRequirements,
+ failedRequirements: stats.fail,
+ };
+ },
+ );
+
+ const filteredData = categoryData
+ .filter((category) => category.totalRequirements > 0)
+ .sort((a, b) => b.failurePercentage - a.failurePercentage)
+ .slice(0, 9); // Show top 9 categories
+
+ return filteredData;
+ } catch (error) {
+ console.error("Error calculating category heatmap data:", error);
+ return [];
+ }
+};
diff --git a/ui/lib/compliance/ens.tsx b/ui/lib/compliance/ens.tsx
new file mode 100644
index 0000000000..a3103035a3
--- /dev/null
+++ b/ui/lib/compliance/ens.tsx
@@ -0,0 +1,249 @@
+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,
+ ENSAttributesMetadata,
+ Framework,
+ Requirement,
+ RequirementItemData,
+ RequirementsData,
+ RequirementStatus,
+} from "@/types/compliance";
+
+export const translateType = (type: string) => {
+ if (!type) {
+ return "";
+ }
+
+ switch (type.toLowerCase()) {
+ case "requisito":
+ return "Requirement";
+ case "recomendacion":
+ return "Recommendation";
+ case "refuerzo":
+ return "Reinforcement";
+ case "medida":
+ return "Measure";
+ default:
+ return type;
+ }
+};
+
+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 attrs = attributeItem.attributes?.attributes
+ ?.metadata?.[0] as ENSAttributesMetadata;
+
+ if (!attrs) continue;
+
+ // Get corresponding requirement data
+ const requirementData = requirementsMap.get(id);
+ if (!requirementData) continue;
+
+ const frameworkName = attrs.Marco;
+ const categoryName = attrs.Categoria;
+ const groupControl = attrs.IdGrupoControl;
+ const type = attrs.Tipo;
+ const description = attributeItem.attributes.description;
+ const status = requirementData.attributes.status || "";
+ const controlDescription = attrs.DescripcionControl || "";
+ const checks = attributeItem.attributes.attributes.check_ids || [];
+ const isManual = attrs.ModoEjecucion === "manual";
+ const requirementName = id;
+ const groupControlLabel = `${groupControl} - ${description}`;
+
+ // 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
+ let category = framework.categories.find((c) => c.name === categoryName);
+ if (!category) {
+ category = {
+ name: categoryName,
+ pass: 0,
+ fail: 0,
+ manual: 0,
+ controls: [],
+ };
+ framework.categories.push(category);
+ }
+
+ // Find or create control
+ let control = category.controls.find((c) => c.label === groupControlLabel);
+ if (!control) {
+ control = {
+ label: groupControlLabel,
+ pass: 0,
+ fail: 0,
+ manual: 0,
+ requirements: [],
+ };
+ category.controls.push(control);
+ }
+
+ // Create requirement
+ const finalStatus: RequirementStatus = isManual
+ ? "MANUAL"
+ : (status as RequirementStatus);
+ const requirement: Requirement = {
+ name: requirementName,
+ description: controlDescription,
+ status: finalStatus,
+ type,
+ check_ids: checks,
+ pass: finalStatus === "PASS" ? 1 : 0,
+ fail: finalStatus === "FAIL" ? 1 : 0,
+ manual: finalStatus === "MANUAL" ? 1 : 0,
+ nivel: attrs.Nivel || "",
+ dimensiones: attrs.Dimensiones || [],
+ };
+
+ 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.map((framework) => {
+ return {
+ key: framework.name,
+ title: (
+
+ ),
+ content: "",
+ items: 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: (
+
+ ),
+ };
+ }),
+ isDisabled:
+ control.pass === 0 &&
+ control.fail === 0 &&
+ control.manual === 0,
+ };
+ }),
+ };
+ }),
+ };
+ });
+};
diff --git a/ui/lib/compliance/iso.tsx b/ui/lib/compliance/iso.tsx
new file mode 100644
index 0000000000..8fd2e7ae7e
--- /dev/null
+++ b/ui/lib/compliance/iso.tsx
@@ -0,0 +1,212 @@
+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,
+ Framework,
+ ISO27001AttributesMetadata,
+ 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 ISO27001AttributesMetadata[];
+ 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 categoryName = attrs.Category;
+ const controlLabel = `${attrs.Objetive_ID} - ${attrs.Objetive_Name}`;
+ const description = attributeItem.attributes.description;
+ const status = requirementData.attributes.status || "";
+ const checks = attributeItem.attributes.attributes.check_ids || [];
+ const requirementName = id;
+ const objetiveName = attrs.Objetive_Name;
+ const checkSummary = attrs.Check_Summary;
+
+ // 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
+ let category = framework.categories.find((c) => c.name === categoryName);
+ if (!category) {
+ category = {
+ name: categoryName,
+ pass: 0,
+ fail: 0,
+ manual: 0,
+ controls: [],
+ };
+ framework.categories.push(category);
+ }
+
+ // Find or create control
+ let control = category.controls.find((c) => c.label === controlLabel);
+ if (!control) {
+ control = {
+ label: controlLabel,
+ 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,
+ objetive_name: objetiveName,
+ check_summary: checkSummary,
+ };
+
+ 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/permissions.ts b/ui/lib/permissions.ts
index 7315258a9d..7d1d4448e8 100644
--- a/ui/lib/permissions.ts
+++ b/ui/lib/permissions.ts
@@ -1,4 +1,4 @@
-import { RolePermissionAttributes } from "@/types/users/users";
+import { RolePermissionAttributes } from "@/types/users";
export const isUserOwnerAndHasManageAccount = (
roles: any[],
diff --git a/ui/package-lock.json b/ui/package-lock.json
index b00fb5b597..14523f6c8f 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -22,6 +22,7 @@
"@radix-ui/react-toast": "^1.2.4",
"@react-aria/ssr": "3.9.4",
"@react-aria/visually-hidden": "3.8.12",
+ "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-table": "^8.19.3",
"add": "^2.0.6",
"alert": "^6.0.2",
@@ -43,6 +44,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.2",
+ "react-markdown": "^10.1.0",
"recharts": "^2.15.2",
"server-only": "^0.0.1",
"shadcn-ui": "^0.2.3",
@@ -7418,6 +7420,34 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@tailwindcss/typography": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
+ "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash.castarray": "^4.4.0",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.merge": "^4.6.2",
+ "postcss-selector-parser": "6.0.10"
+ },
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
+ }
+ },
+ "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
+ "version": "6.0.10",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/@tanstack/react-table": {
"version": "8.19.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.19.3.tgz",
@@ -7539,6 +7569,39 @@
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
+ "node_modules/@types/debug": {
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
+ "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
+ "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -7560,6 +7623,21 @@
"@types/lodash": "*"
}
},
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "20.5.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz",
@@ -7569,14 +7647,12 @@
"node_modules/@types/prop-types": {
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
- "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
- "devOptional": true
+ "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q=="
},
"node_modules/@types/react": {
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
- "devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -7591,6 +7667,12 @@
"@types/react": "*"
}
},
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@@ -7785,8 +7867,7 @@
"node_modules/@ungap/structured-clone": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
- "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
- "dev": true
+ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
},
"node_modules/acorn": {
"version": "8.12.1",
@@ -8178,6 +8259,16 @@
"deep-equal": "^2.0.5"
}
},
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -8368,6 +8459,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -8384,6 +8485,46 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -8982,6 +9123,16 @@
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true
},
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -9269,6 +9420,19 @@
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
+ "node_modules/decode-named-character-reference": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz",
+ "integrity": "sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/deep-equal": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
@@ -9360,6 +9524,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
@@ -9374,6 +9547,19 @@
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
},
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -10352,6 +10538,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -10390,6 +10586,12 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -10941,6 +11143,56 @@
"node": ">= 0.4"
}
},
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/https-proxy-agent": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-6.2.1.tgz",
@@ -11055,6 +11307,12 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
+ "node_modules/inline-style-parser": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz",
+ "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==",
+ "license": "MIT"
+ },
"node_modules/internal-slot": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz",
@@ -11088,6 +11346,30 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-arguments": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
@@ -11235,6 +11517,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -11289,6 +11581,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-interactive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
@@ -11356,6 +11658,18 @@
"node": ">=8"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-regex": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
@@ -11887,6 +12201,12 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
+ "node_modules/lodash.castarray": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
+ "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
+ "license": "MIT"
+ },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
@@ -11902,6 +12222,12 @@
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="
},
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
"node_modules/lodash.kebabcase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz",
@@ -11915,8 +12241,7 @@
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
- "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
- "dev": true
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"node_modules/lodash.omit": {
"version": "4.5.0",
@@ -12078,6 +12403,16 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -12105,6 +12440,159 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
+ "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
+ "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -12118,6 +12606,448 @@
"node": ">= 8"
}
},
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -12741,6 +13671,31 @@
"node": ">=6"
}
},
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+ "license": "MIT"
+ },
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@@ -13174,6 +14129,16 @@
"react-is": "^16.13.1"
}
},
+ "node_modules/property-information": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
+ "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -13317,6 +14282,33 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
+ "node_modules/react-markdown": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
"node_modules/react-remove-scroll": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz",
@@ -13572,6 +14564,39 @@
"url": "https://github.com/sponsors/mysticatea"
}
},
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@@ -14142,6 +15167,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/stdin-discarder": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz",
@@ -14338,6 +15373,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -14392,6 +15441,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/style-to-js": {
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz",
+ "integrity": "sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.8"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz",
+ "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.4"
+ }
+ },
"node_modules/styled-jsx": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
@@ -14603,6 +15670,26 @@
"node": ">=8.0"
}
},
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
@@ -14771,6 +15858,93 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
+ "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz",
+ "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz",
+ "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -14946,6 +16120,34 @@
"uuid": "dist/esm/bin/uuid"
}
},
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz",
+ "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
@@ -15235,6 +16437,16 @@
"optional": true
}
}
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
}
}
}
diff --git a/ui/package.json b/ui/package.json
index 5d16567d19..10432cb767 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -14,6 +14,7 @@
"@radix-ui/react-toast": "^1.2.4",
"@react-aria/ssr": "3.9.4",
"@react-aria/visually-hidden": "3.8.12",
+ "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-table": "^8.19.3",
"add": "^2.0.6",
"alert": "^6.0.2",
@@ -35,6 +36,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.2",
+ "react-markdown": "^10.1.0",
"recharts": "^2.15.2",
"server-only": "^0.0.1",
"shadcn-ui": "^0.2.3",
diff --git a/ui/public/ens.png b/ui/public/ens.png
new file mode 100644
index 0000000000..c3e6433f31
Binary files /dev/null and b/ui/public/ens.png differ
diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js
index 664e360167..503418fa2c 100644
--- a/ui/tailwind.config.js
+++ b/ui/tailwind.config.js
@@ -171,9 +171,9 @@ module.exports = {
"100%": { left: "100%", width: "100%" },
},
dropArrow: {
- '0%': { transform: 'translateY(-8px)', opacity: '0' },
- '50%': { opacity: '1' },
- '100%': { transform: 'translateY(0)', opacity: '1' },
+ "0%": { transform: "translateY(-8px)", opacity: "0" },
+ "50%": { opacity: "1" },
+ "100%": { transform: "translateY(0)", opacity: "1" },
},
},
animation: {
@@ -188,6 +188,7 @@ module.exports = {
},
plugins: [
require("tailwindcss-animate"),
+ require("@tailwindcss/typography"),
nextui({
themes: {
dark: {
diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts
new file mode 100644
index 0000000000..4fa4cf7e1a
--- /dev/null
+++ b/ui/types/compliance.ts
@@ -0,0 +1,160 @@
+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[];
+}
+
+export interface ComplianceOverviewData {
+ type: "compliance-requirements-status";
+ id: string;
+ attributes: {
+ framework: string;
+ version: string;
+ requirements_passed: number;
+ requirements_failed: number;
+ requirements_manual: number;
+ total_requirements: number;
+ };
+}
+
+export interface Requirement {
+ name: string;
+ description: string;
+ status: RequirementStatus;
+ pass: number;
+ fail: number;
+ manual: number;
+ check_ids: string[];
+ // This is to allow any key to be added to the requirement object
+ // because each compliance has different keys
+ [key: string]: string | string[] | number | undefined;
+}
+
+export interface Control {
+ label: string;
+ pass: number;
+ fail: number;
+ manual: number;
+ requirements: Requirement[];
+}
+
+export interface Category {
+ name: string;
+ pass: number;
+ fail: number;
+ manual: number;
+ controls: Control[];
+}
+
+export interface Framework {
+ name: string;
+ pass: number;
+ fail: number;
+ manual: number;
+ categories: Category[];
+}
+
+export interface FailedSection {
+ name: string;
+ total: number;
+ types?: { [key: string]: number };
+}
+
+export interface RequirementsTotals {
+ pass: number;
+ fail: number;
+ manual: number;
+}
+
+// API Responses types:
+export interface ENSAttributesMetadata {
+ IdGrupoControl: string;
+ Marco: string;
+ Categoria: string;
+ DescripcionControl: string;
+ Tipo: string;
+ Nivel: string;
+ Dimensiones: string[];
+ ModoEjecucion: string;
+ Dependencias: any[];
+}
+
+export interface ISO27001AttributesMetadata {
+ Category: string;
+ Objetive_ID: string;
+ Objetive_Name: string;
+ Check_Summary: string;
+}
+
+export interface CISAttributesMetadata {
+ Section: string;
+ SubSection: string | null;
+ Profile: string; // "Level 1" or "Level 2"
+ AssessmentStatus: string; // "Manual" or "Automated"
+ Description: string;
+ RationaleStatement: string;
+ ImpactStatement: string;
+ RemediationProcedure: string;
+ AuditProcedure: string;
+ AdditionalInformation: string;
+ DefaultValue: string | null;
+ References: string;
+}
+
+export interface AttributesItemData {
+ type: "compliance-requirements-attributes";
+ id: string;
+ attributes: {
+ framework: string;
+ version: string;
+ description: string;
+ attributes: {
+ metadata: ENSAttributesMetadata[] | ISO27001AttributesMetadata[];
+ check_ids: string[];
+ };
+ };
+}
+
+export interface RequirementItemData {
+ type: "compliance-requirements-details";
+ id: string;
+ attributes: {
+ framework: string;
+ version: string;
+ description: string;
+ status: RequirementStatus;
+ };
+}
+
+export interface AttributesData {
+ data: AttributesItemData[];
+}
+
+export interface RequirementsData {
+ data: RequirementItemData[];
+}
+
+export interface RegionData {
+ name: string;
+ failurePercentage: number;
+ totalRequirements: number;
+ failedRequirements: number;
+}
+
+export interface CategoryData {
+ name: string;
+ failurePercentage: number;
+ totalRequirements: number;
+ failedRequirements: number;
+}
diff --git a/ui/types/components.ts b/ui/types/components.ts
index f5928386d0..c8a8054f80 100644
--- a/ui/types/components.ts
+++ b/ui/types/components.ts
@@ -1,8 +1,6 @@
import { LucideIcon } from "lucide-react";
import { SVGProps } from "react";
-import { ProviderType } from "./providers";
-
export type IconSvgProps = SVGProps & {
size?: number;
};
@@ -44,18 +42,6 @@ export interface CollapseMenuButtonProps {
isOpen: boolean | undefined;
}
-export interface SelectScanComplianceDataProps {
- scans: (ScanProps & {
- providerInfo: {
- provider: ProviderType;
- uid: string;
- alias: string;
- };
- })[];
- selectedScanId: string;
- onSelectionChange: (selectedKey: string) => void;
-}
-
export type NextUIVariants =
| "solid"
| "faded"
@@ -269,53 +255,6 @@ export interface ApiError {
};
code: string;
}
-export interface CompliancesOverview {
- links: {
- first: string;
- last: string;
- next: string | null;
- prev: string | null;
- };
- data: ComplianceOverviewData[];
- meta: {
- pagination: {
- page: number;
- pages: number;
- count: number;
- };
- version: string;
- };
-}
-
-export interface ComplianceOverviewData {
- type: "compliance-overviews";
- id: string;
- attributes: {
- inserted_at: string;
- compliance_id: string;
- framework: string;
- version: string;
- requirements_status: {
- passed: number;
- failed: number;
- manual: number;
- total: number;
- };
- region: string;
- provider_type: string;
- };
- relationships: {
- scan: {
- data: {
- type: "scans";
- id: string;
- };
- };
- };
- links: {
- self: string;
- };
-}
export interface InvitationProps {
type: "invitations";
@@ -497,52 +436,9 @@ export interface UserProps {
}[];
}
-export interface ScanProps {
- type: "scans";
- id: string;
- attributes: {
- name: string;
- trigger: "scheduled" | "manual";
- state:
- | "available"
- | "scheduled"
- | "executing"
- | "completed"
- | "failed"
- | "cancelled";
- unique_resource_count: number;
- progress: number;
- scanner_args: {
- only_logs?: boolean;
- excluded_checks?: string[];
- aws_retries_max_attempts?: number;
- } | null;
- duration: number;
- started_at: string;
- inserted_at: string;
- completed_at: string;
- scheduled_at: string;
- next_scan_at: string;
- };
- relationships: {
- provider: {
- data: {
- id: string;
- type: "providers";
- };
- };
- task: {
- data: {
- id: string;
- type: "tasks";
- };
- };
- };
- providerInfo?: {
- provider: ProviderType;
- uid: string;
- alias: string;
- };
+export interface FindingsResponse {
+ data: FindingProps[];
+ meta: MetaDataProps;
}
export interface FindingProps {
diff --git a/ui/types/filters.ts b/ui/types/filters.ts
index e6f364794e..3d0684e1d0 100644
--- a/ui/types/filters.ts
+++ b/ui/types/filters.ts
@@ -6,6 +6,9 @@ export interface FilterOption {
values: string[];
valueLabelMapping?: Array<{ [uid: string]: ProviderAccountProps }>;
index?: number;
+ showSelectAll?: boolean;
+ defaultToSelectAll?: boolean;
+ defaultValues?: string[];
}
export interface CustomDropdownFilterProps {
diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts
index ef627bcea3..a2fbf1e3a4 100644
--- a/ui/types/formSchemas.ts
+++ b/ui/types/formSchemas.ts
@@ -1,5 +1,7 @@
import { z } from "zod";
+import { ProviderType } from "./providers";
+
export const addRoleFormSchema = z.object({
name: z.string().min(1, "Name is required"),
manage_users: z.boolean().default(false),
@@ -176,7 +178,9 @@ export const addCredentialsRoleFormSchema = (providerType: string) =>
providerType: z.string(),
});
-export const addCredentialsServiceAccountFormSchema = (providerType: string) =>
+export const addCredentialsServiceAccountFormSchema = (
+ providerType: ProviderType,
+) =>
providerType === "gcp"
? z.object({
providerId: z.string(),
diff --git a/ui/types/index.ts b/ui/types/index.ts
index e35a2815da..1483946f3e 100644
--- a/ui/types/index.ts
+++ b/ui/types/index.ts
@@ -3,3 +3,4 @@ export * from "./components";
export * from "./filters";
export * from "./formSchemas";
export * from "./providers";
+export * from "./scans";
diff --git a/ui/types/scans.ts b/ui/types/scans.ts
new file mode 100644
index 0000000000..ac8bfe64e6
--- /dev/null
+++ b/ui/types/scans.ts
@@ -0,0 +1,49 @@
+import { ProviderType } from "./providers";
+
+export interface ScanProps {
+ type: "scans";
+ id: string;
+ attributes: {
+ name: string;
+ trigger: "scheduled" | "manual";
+ state:
+ | "available"
+ | "scheduled"
+ | "executing"
+ | "completed"
+ | "failed"
+ | "cancelled";
+ unique_resource_count: number;
+ progress: number;
+ scanner_args: {
+ only_logs?: boolean;
+ excluded_checks?: string[];
+ aws_retries_max_attempts?: number;
+ } | null;
+ duration: number;
+ started_at: string;
+ inserted_at: string;
+ completed_at: string;
+ scheduled_at: string;
+ next_scan_at: string;
+ };
+ relationships: {
+ provider: {
+ data: {
+ id: string;
+ type: "providers";
+ };
+ };
+ task: {
+ data: {
+ id: string;
+ type: "tasks";
+ };
+ };
+ };
+ providerInfo?: {
+ provider: ProviderType;
+ uid: string;
+ alias: string;
+ };
+}
diff --git a/ui/types/users/users.ts b/ui/types/users.ts
similarity index 100%
rename from ui/types/users/users.ts
rename to ui/types/users.ts
diff --git a/ui/types/users/index.ts b/ui/types/users/index.ts
deleted file mode 100644
index ddf77b4624..0000000000
--- a/ui/types/users/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./users";