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` - ![Permission Screenshots](./img/directory-permission.png) + ![Permission Screenshots](./img/domain-permission.png) 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: ![Add API Permission](./img/add-app-api-permission.png) 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. + ![Permission Screenshots](./img/directory-permission.png) 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 ( +
+ {`${title} +
+ ); +}; + +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 ( +
+
+ {name} +
+ +
+ ); +}; 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) => ( + + ))} + translateType(value)} + wrapperStyle={{ + fontSize: "10px", + display: "flex", + justifyContent: "center", + width: "100%", + paddingTop: "16px", + marginBottom: "16px", + }} + iconType="circle" + layout="horizontal" + verticalAlign="bottom" + /> +
+
+
+
+ ); +}; 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"} +

+
+

{noDataMessage}

+
+
+ ); + } + + 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) => ( + + ), + )} + + + + +
+
+
Pass
+
{pass}
+
+
+
Fail
+
{fail}
+
+
+
Manual
+
{manual}
+
+
+
+ ); +}; 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

- { + setSelectedPageSize(value); - const params = new URLSearchParams(searchParams); - params.set("pageSize", value); - params.set("page", "1"); + const params = new URLSearchParams(searchParams); - // This pushes the URL without reloading the page - router.push(`${pathname}?${params.toString()}`); - }} - > - - - - - {itemsPerPageOptions.map((pageSize) => ( - - {pageSize} - - ))} - - + // Preserve all important parameters + const scanId = searchParams.get("scanId"); + const id = searchParams.get("id"); + const version = searchParams.get("version"); + + params.set("pageSize", value); + params.set("page", "1"); + + // 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); + + // This pushes the URL without reloading the page + if (disableScroll) { + const url = `${pathname}?${params.toString()}`; + router.push(url, { scroll: false }); + } else { + router.push(`${pathname}?${params.toString()}`); + } + }} + > + + + + + {itemsPerPageOptions.map((pageSize) => ( + + {pageSize} + + ))} + + +
+
+ Page {currentPage} of {totalPages} +
+
+ isFirstPage && e.preventDefault()} + > +
-
- Page {currentPage} of {totalPages} -
-
- -
-
+ )}
); } 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";