diff --git a/.env b/.env index 1fa497f3e6..1f53b81153 100644 --- a/.env +++ b/.env @@ -24,6 +24,10 @@ POSTGRES_USER=prowler POSTGRES_PASSWORD=postgres POSTGRES_DB=prowler_db +# Celery-Prowler task settings +TASK_RETRY_DELAY_SECONDS=0.1 +TASK_RETRY_ATTEMPTS=5 + # Valkey settings # If running Valkey and celery on host, use localhost, else use 'valkey' VALKEY_HOST=valkey diff --git a/.github/workflows/pull-request-merged.yml b/.github/workflows/pull-request-merged.yml index 090950f79b..7bc05dc3c4 100644 --- a/.github/workflows/pull-request-merged.yml +++ b/.github/workflows/pull-request-merged.yml @@ -18,7 +18,7 @@ jobs: - name: Set short git commit SHA id: vars run: | - shortSha=$(git rev-parse --short ${{ github.sha }}) + shortSha=$(git rev-parse --short ${{ github.event.pull_request.merge_commit_sha }}) echo "SHORT_SHA=${shortSha}" >> $GITHUB_ENV - name: Trigger pull request @@ -28,7 +28,7 @@ jobs: repository: ${{ secrets.CLOUD_DISPATCH }} event-type: prowler-pull-request-merged client-payload: '{ - "PROWLER_COMMIT_SHA": "${{ github.sha }}", + "PROWLER_COMMIT_SHA": "${{ github.event.pull_request.merge_commit_sha }}", "PROWLER_COMMIT_SHORT_SHA": "${{ env.SHORT_SHA }}", "PROWLER_PR_TITLE": "${{ github.event.pull_request.title }}", "PROWLER_PR_LABELS": ${{ toJson(github.event.pull_request.labels.*.name) }}, diff --git a/README.md b/README.md index 7c7c12d3fc..8702e8f675 100644 --- a/README.md +++ b/README.md @@ -86,12 +86,12 @@ prowler dashboard | Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | |---|---|---|---|---| -| AWS | 564 | 82 | 34 | 10 | +| AWS | 567 | 82 | 36 | 10 | | GCP | 79 | 13 | 9 | 3 | -| Azure | 140 | 18 | 8 | 3 | +| Azure | 142 | 18 | 10 | 3 | | Kubernetes | 83 | 7 | 5 | 7 | -| GitHub | 3 | 2 | 1 | 0 | -| M365 | 44 | 2 | 2 | 0 | +| GitHub | 16 | 2 | 1 | 0 | +| M365 | 69 | 7 | 2 | 2 | | NHN (Unofficial) | 6 | 2 | 1 | 0 | > [!Note] diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 2f0ba3cce0..4d0fbce45b 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to the **Prowler API** are documented in this file. +## [v1.9.0] (Prowler UNRELEASED) + +### 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.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 diff --git a/api/pyproject.toml b/api/pyproject.toml index 19190f8893..621e1fb477 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -35,7 +35,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.8.1" +version = "1.9.0" [project.scripts] celery = "src.backend.config.settings.celery" diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py index 3e965482df..605afe332d 100644 --- a/api/src/backend/api/base_views.py +++ b/api/src/backend/api/base_views.py @@ -109,16 +109,6 @@ 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) - - 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,8 +116,8 @@ 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 + user_id = str(request.user.id) + with rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR): return super().initial(request, *args, **kwargs) 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/0026_provider_secret_gcp_service_account.py b/api/src/backend/api/migrations/0026_provider_secret_gcp_service_account.py new file mode 100644 index 0000000000..3857dbbd75 --- /dev/null +++ b/api/src/backend/api/migrations/0026_provider_secret_gcp_service_account.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0025_findings_uid_index_parent"), + ] + + operations = [ + migrations.RunSQL( + "ALTER TYPE provider_secret_type ADD VALUE IF NOT EXISTS 'service_account';", + reverse_sql=migrations.RunSQL.noop, + ), + ] 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 a427bcf9c9..b4a5c3a894 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -1,7 +1,9 @@ import json import re +import time from uuid import UUID, uuid4 +from config.env import env from cryptography.fernet import Fernet from django.conf import settings from django.contrib.auth.models import AbstractBaseUser @@ -242,7 +244,7 @@ class Provider(RowLevelSecurityProtectedModel): @staticmethod def validate_kubernetes_uid(value): if not re.match( - r"^[a-z0-9][A-Za-z0-9_.:\/-]{1,250}$", + r"^[a-zA-Z0-9][a-zA-Z0-9._@:\/-]{1,250}$", value, ): raise ModelValidationError( @@ -352,6 +354,42 @@ class ProviderGroupMembership(RowLevelSecurityProtectedModel): resource_name = "provider_groups-provider" +class TaskManager(models.Manager): + def get_with_retry( + self, + id: str, + max_retries: int = None, + delay_seconds: float = None, + ): + """ + Retry fetching a Task by ID in case it hasn't been created yet. + + Args: + id (str): The Celery task ID (expected to match Task model PK). + max_retries (int, optional): Number of retry attempts. Defaults to env TASK_RETRY_ATTEMPTS or 5. + delay_seconds (float, optional): Delay between retries in seconds. Defaults to env TASK_RETRY_DELAY_SECONDS or 0.1. + + Returns: + Task: The retrieved Task instance. + + Raises: + Task.DoesNotExist: If the task is not found after all retries. + """ + max_retries = max_retries or env.int("TASK_RETRY_ATTEMPTS", default=5) + delay_seconds = delay_seconds or env.float( + "TASK_RETRY_DELAY_SECONDS", default=0.1 + ) + + for _attempt in range(max_retries): + try: + return self.get(id=id) + except self.model.DoesNotExist: + time.sleep(delay_seconds) + raise self.model.DoesNotExist( + f"Task with ID {id} not found after {max_retries} retries." + ) + + class Task(RowLevelSecurityProtectedModel): id = models.UUIDField(primary_key=True, default=uuid4, editable=False) inserted_at = models.DateTimeField(auto_now_add=True, editable=False) @@ -364,6 +402,8 @@ class Task(RowLevelSecurityProtectedModel): blank=True, ) + objects = TaskManager() + class Meta(RowLevelSecurityProtectedModel.Meta): db_table = "tasks" @@ -762,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: @@ -850,6 +894,7 @@ class ProviderSecret(RowLevelSecurityProtectedModel): class TypeChoices(models.TextChoices): STATIC = "static", _("Key-value pairs") ROLE = "role", _("Role assumption") + SERVICE_ACCOUNT = "service_account", _("GCP Service Account Key") id = models.UUIDField(primary_key=True, default=uuid4, editable=False) inserted_at = models.DateTimeField(auto_now_add=True, editable=False) @@ -1142,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 3f90c28cb1..30c64e31a4 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.8.1 + version: 1.9.0 description: |- Prowler API specification. @@ -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 @@ -5376,7 +5464,8 @@ paths: '403': description: There is a problem with credentials '404': - description: The scan has no reports + description: The scan has no reports, or the report generation task has + not started yet /api/v1/schedules/daily: post: operationId: schedules_daily_create @@ -6838,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 @@ -6920,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: @@ -7071,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: @@ -8385,7 +8372,7 @@ components: type: object properties: data: - $ref: '#/components/schemas/Membership' + $ref: '#/components/schemas/ComplianceOverviewMetadata' required: - data OverviewFinding: @@ -8600,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: @@ -9136,10 +9127,12 @@ components: enum: - static - role + - service_account type: string description: |- * `static` - Key-value pairs * `role` - Role assumption + * `service_account` - GCP Service Account Key readOnly: true secret: oneOf: @@ -9240,15 +9233,15 @@ components: user: type: email description: User microsoft email address. - encrypted_password: + password: type: string - description: User encrypted password. + description: User password. required: - client_id - client_secret - tenant_id - user - - encrypted_password + - password - type: object title: GCP Static Credentials properties: @@ -9268,6 +9261,14 @@ components: - client_id - client_secret - refresh_token + - type: object + title: GCP Service Account Key + properties: + service_account_key: + type: object + description: The service account key for GCP. + required: + - service_account_key - type: object title: Kubernetes Static Credentials properties: @@ -10316,10 +10317,12 @@ components: enum: - static - role + - service_account type: string description: |- * `static` - Key-value pairs * `role` - Role assumption + * `service_account` - GCP Service Account Key required: - secret_type relationships: @@ -10383,10 +10386,12 @@ components: enum: - static - role + - service_account type: string description: |- * `static` - Key-value pairs * `role` - Role assumption + * `service_account` - GCP Service Account Key secret: oneOf: - type: object @@ -10485,15 +10490,15 @@ components: user: type: email description: User microsoft email address. - encrypted_password: + password: type: string - description: User encrypted password. + description: User password. required: - client_id - client_secret - tenant_id - user - - encrypted_password + - password - type: object title: GCP Static Credentials properties: @@ -10513,6 +10518,14 @@ components: - client_id - client_secret - refresh_token + - type: object + title: GCP Service Account Key + properties: + service_account_key: + type: object + description: The service account key for GCP. + required: + - service_account_key - type: object title: Kubernetes Static Credentials properties: @@ -10591,10 +10604,12 @@ components: enum: - static - role + - service_account type: string description: |- * `static` - Key-value pairs * `role` - Role assumption + * `service_account` - GCP Service Account Key secret: oneOf: - type: object @@ -10694,15 +10709,15 @@ components: user: type: email description: User microsoft email address. - encrypted_password: + password: type: string - description: User encrypted password. + description: User password. required: - client_id - client_secret - tenant_id - user - - encrypted_password + - password - type: object title: GCP Static Credentials properties: @@ -10722,6 +10737,14 @@ components: - client_id - client_secret - refresh_token + - type: object + title: GCP Service Account Key + properties: + service_account_key: + type: object + description: The service account key for GCP. + required: + - service_account_key - type: object title: Kubernetes Static Credentials properties: @@ -10816,10 +10839,12 @@ components: enum: - static - role + - service_account type: string description: |- * `static` - Key-value pairs * `role` - Role assumption + * `service_account` - GCP Service Account Key readOnly: true secret: oneOf: @@ -10919,15 +10944,15 @@ components: user: type: email description: User microsoft email address. - encrypted_password: + password: type: string - description: User encrypted password. + description: User password. required: - client_id - client_secret - tenant_id - user - - encrypted_password + - password - type: object title: GCP Static Credentials properties: @@ -10947,6 +10972,14 @@ components: - client_id - client_secret - refresh_token + - type: object + title: GCP Service Account Key + properties: + service_account_key: + type: object + description: The service account key for GCP. + required: + - service_account_key - type: object title: Kubernetes Static Credentials properties: @@ -11861,6 +11894,7 @@ components: type: object required: - type + - id additionalProperties: false properties: type: @@ -11869,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: @@ -12283,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_models.py b/api/src/backend/api/tests/test_models.py index c2beeb3583..de2ec7c59e 100644 --- a/api/src/backend/api/tests/test_models.py +++ b/api/src/backend/api/tests/test_models.py @@ -1,6 +1,9 @@ +import uuid +from unittest import mock + import pytest -from api.models import Resource, ResourceTag +from api.models import Resource, ResourceTag, Task @pytest.mark.django_db @@ -120,3 +123,35 @@ class TestResourceModel: # compliance={}, # ) # assert Finding.objects.filter(uid=long_uid).exists() + + +@pytest.mark.django_db +class TestTaskManager: + def test_get_with_retry_success(self): + task_id = uuid.uuid4() + call_counter = {"count": 0} + + def side_effect(*args, **kwargs): + if call_counter["count"] < 2: + call_counter["count"] += 1 + raise Task.DoesNotExist() + return Task(id=task_id) + + with mock.patch.object(Task.objects, "get", side_effect=side_effect): + task = Task.objects.get_with_retry( + task_id, max_retries=5, delay_seconds=0.01 + ) + + assert task.id == task_id + assert call_counter["count"] == 2 + + def test_get_with_retry_fail(self): + non_existent_id = uuid.uuid4() + + with mock.patch.object(Task.objects, "get", side_effect=Task.DoesNotExist): + with pytest.raises(Task.DoesNotExist) as excinfo: + Task.objects.get_with_retry( + non_existent_id, max_retries=3, delay_seconds=0.01 + ) + + assert str(non_existent_id) in str(excinfo.value) diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 80ab44b946..512ba79a93 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -13,11 +13,12 @@ from botocore.exceptions import ClientError, NoCredentialsError from conftest import API_JSON_CONTENT_TYPE, TEST_PASSWORD, TEST_USER 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, @@ -34,6 +35,7 @@ from api.models import ( UserRoleRelationship, ) from api.rls import Tenant +from api.v1.views import ComplianceOverviewViewSet TODAY = str(datetime.today().date()) @@ -913,6 +915,16 @@ class TestProviderViewSet: "uid": "gke_aaaa-dev_europe-test1_dev-aaaa-test-cluster-long-name-123456789", "alias": "GKE", }, + { + "provider": "kubernetes", + "uid": "gke_project/cluster-name", + "alias": "GKE", + }, + { + "provider": "kubernetes", + "uid": "admin@k8s-demo", + "alias": "test", + }, { "provider": "azure", "uid": "8851db6b-42e5-4533-aa9e-30a32d67e875", @@ -920,7 +932,7 @@ class TestProviderViewSet: }, { "provider": "m365", - "uid": "TestingPro.onMirosoft.com", + "uid": "TestingPro.onmicrosoft.com", "alias": "test", }, { @@ -1678,6 +1690,26 @@ class TestProviderSecretViewSet: "refresh_token": "refresh-token", }, ), + # GCP with Service Account Key secret + ( + Provider.ProviderChoices.GCP.value, + ProviderSecret.TypeChoices.SERVICE_ACCOUNT, + { + "service_account_key": { + "type": "service_account", + "project_id": "project-id", + "private_key_id": "private-key-id", + "private_key": "private-key", + "client_email": "client-email", + "client_id": "client-id", + "auth_uri": "auth-uri", + "token_uri": "token-uri", + "auth_provider_x509_cert_url": "auth-provider-x509-cert-url", + "client_x509_cert_url": "client-x509-cert-url", + "universe_domain": "universe-domain", + }, + }, + ), # Kubernetes with STATIC secret ( Provider.ProviderChoices.KUBERNETES.value, @@ -2303,7 +2335,10 @@ class TestScanViewSet: url = reverse("scan-report", kwargs={"pk": scan.id}) response = authenticated_client.get(url) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json()["errors"]["detail"] == "The scan has no reports." + assert ( + response.json()["errors"]["detail"] + == "The scan has no reports, or the report generation task has not started yet." + ) def test_report_s3_no_credentials( self, authenticated_client, scans_fixture, monkeypatch @@ -2371,7 +2406,7 @@ class TestScanViewSet: ): """ When output_location is a local path and glob.glob returns an empty list, - the view should return HTTP 404 with detail "The scan has no reports." + the view should return HTTP 404 with detail "The scan has no reports, or the report generation task has not started yet." """ scan = scans_fixture[0] scan.output_location = "/tmp/nonexistent_report_pattern.zip" @@ -2383,7 +2418,10 @@ class TestScanViewSet: response = authenticated_client.get(url) assert response.status_code == 404 - assert response.json()["errors"]["detail"] == "The scan has no reports." + assert ( + response.json()["errors"]["detail"] + == "The scan has no reports, or the report generation task has not started yet." + ) def test_report_local_file(self, authenticated_client, scans_fixture, monkeypatch): scan = scans_fixture[0] @@ -2458,7 +2496,10 @@ class TestScanViewSet: url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": framework}) resp = authenticated_client.get(url) assert resp.status_code == status.HTTP_404_NOT_FOUND - assert resp.json()["errors"]["detail"] == "The scan has no reports." + assert ( + resp.json()["errors"]["detail"] + == "The scan has no reports, or the report generation task has not started yet." + ) def test_compliance_s3_no_credentials( self, authenticated_client, scans_fixture, monkeypatch @@ -2600,6 +2641,36 @@ class TestScanViewSet: assert response.status_code == status.HTTP_404_NOT_FOUND + @patch("api.v1.views.TaskSerializer") + def test__get_task_status_finds_task_using_kwargs( + self, mock_task_serializer, authenticated_client, scans_fixture + ): + scan = scans_fixture[0] + scan.state = StateChoices.COMPLETED + scan.output_location = "dummy" + scan.save() + + task_result = TaskResult.objects.create( + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ) + + task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=task_result, + ) + + mock_task_serializer.return_value.data = { + "id": str(task.id), + "state": StateChoices.EXECUTING, + } + + url = reverse("scan-report", kwargs={"pk": scan.id}) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.data["id"] == str(task.id) + @patch("api.v1.views.get_s3_client") @patch("api.v1.views.sentry_sdk.capture_exception") def test_compliance_list_objects_client_error( @@ -2650,7 +2721,10 @@ class TestScanViewSet: response = authenticated_client.get(url) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json()["errors"]["detail"] == "The scan has no reports." + assert ( + response.json()["errors"]["detail"] + == "The scan has no reports, or the report generation task has not started yet." + ) @patch("api.v1.views.get_s3_client") def test_report_s3_client_error_other( @@ -4688,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/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index 231e5dc1bb..0a1e999fe2 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -119,9 +119,9 @@ from rest_framework_json_api import serializers "type": "email", "description": "User microsoft email address.", }, - "encrypted_password": { + "password": { "type": "string", - "description": "User encrypted password.", + "description": "User password.", }, }, "required": [ @@ -129,7 +129,7 @@ from rest_framework_json_api import serializers "client_secret", "tenant_id", "user", - "encrypted_password", + "password", ], }, { @@ -154,6 +154,17 @@ from rest_framework_json_api import serializers }, "required": ["client_id", "client_secret", "refresh_token"], }, + { + "type": "object", + "title": "GCP Service Account Key", + "properties": { + "service_account_key": { + "type": "object", + "description": "The service account key for GCP.", + } + }, + "required": ["service_account_key"], + }, { "type": "object", "title": "Kubernetes Static Credentials", diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index c0440f3ef0..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, @@ -1159,6 +1159,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer): ) elif secret_type == ProviderSecret.TypeChoices.ROLE: serializer = AWSRoleAssumptionProviderSecret(data=secret) + elif secret_type == ProviderSecret.TypeChoices.SERVICE_ACCOUNT: + serializer = GCPServiceAccountProviderSecret(data=secret) else: raise serializers.ValidationError( {"secret_type": f"Secret type not supported: {secret_type}"} @@ -1197,7 +1199,7 @@ class M365ProviderSecret(serializers.Serializer): client_secret = serializers.CharField() tenant_id = serializers.CharField() user = serializers.EmailField() - encrypted_password = serializers.CharField() + password = serializers.CharField() class Meta: resource_name = "provider-secrets" @@ -1212,6 +1214,13 @@ class GCPProviderSecret(serializers.Serializer): resource_name = "provider-secrets" +class GCPServiceAccountProviderSecret(serializers.Serializer): + service_account_key = serializers.JSONField() + + class Meta: + resource_name = "provider-secrets" + + class KubernetesProviderSecret(serializers.Serializer): kubeconfig_content = serializers.CharField() @@ -1670,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 b3e39fef50..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, @@ -260,7 +266,7 @@ class SchemaView(SpectacularAPIView): def get(self, request, *args, **kwargs): spectacular_settings.TITLE = "Prowler API" - spectacular_settings.VERSION = "1.8.1" + spectacular_settings.VERSION = "1.9.0" spectacular_settings.DESCRIPTION = ( "Prowler API specification.\n\nThis file is auto-generated." ) @@ -1086,7 +1092,7 @@ class ProviderViewSet(BaseRLSViewSet): task = check_provider_connection_task.delay( provider_id=pk, tenant_id=self.request.tenant_id ) - prowler_task = Task.objects.get(id=task.id) + prowler_task = Task.objects.get_with_retry(id=task.id) serializer = TaskSerializer(prowler_task) return Response( data=serializer.data, @@ -1109,7 +1115,7 @@ class ProviderViewSet(BaseRLSViewSet): task = delete_provider_task.delay( provider_id=pk, tenant_id=self.request.tenant_id ) - prowler_task = Task.objects.get(id=task.id) + prowler_task = Task.objects.get_with_retry(id=task.id) serializer = TaskSerializer(prowler_task) return Response( data=serializer.data, @@ -1160,7 +1166,9 @@ class ProviderViewSet(BaseRLSViewSet): 200: OpenApiResponse(description="Report obtained successfully"), 202: OpenApiResponse(description="The task is in progress"), 403: OpenApiResponse(description="There is a problem with credentials"), - 404: OpenApiResponse(description="The scan has no reports"), + 404: OpenApiResponse( + description="The scan has no reports, or the report generation task has not started yet" + ), }, ), compliance=extend_schema( @@ -1281,7 +1289,7 @@ class ScanViewSet(BaseRLSViewSet): try: task = Task.objects.get( task_runner_task__task_name="scan-report", - task_runner_task__task_args__contains=str(scan_instance.id), + task_runner_task__task_kwargs__contains=str(scan_instance.id), ) except Task.DoesNotExist: return None @@ -1363,7 +1371,9 @@ class ScanViewSet(BaseRLSViewSet): code = e.response.get("Error", {}).get("Code") if code == "NoSuchKey": return Response( - {"detail": "The scan has no reports."}, + { + "detail": "The scan has no reports, or the report generation task has not started yet." + }, status=status.HTTP_404_NOT_FOUND, ) return Response( @@ -1376,7 +1386,9 @@ class ScanViewSet(BaseRLSViewSet): files = glob.glob(path_pattern) if not files: return Response( - {"detail": "The scan has no reports."}, + { + "detail": "The scan has no reports, or the report generation task has not started yet." + }, status=status.HTTP_404_NOT_FOUND, ) filepath = files[0] @@ -1402,7 +1414,10 @@ class ScanViewSet(BaseRLSViewSet): if not scan.output_location: return Response( - {"detail": "The scan has no reports."}, status=status.HTTP_404_NOT_FOUND + { + "detail": "The scan has no reports, or the report generation task has not started yet." + }, + status=status.HTTP_404_NOT_FOUND, ) if scan.output_location.startswith("s3://"): @@ -1440,7 +1455,10 @@ class ScanViewSet(BaseRLSViewSet): if not scan.output_location: return Response( - {"detail": "The scan has no reports."}, status=status.HTTP_404_NOT_FOUND + { + "detail": "The scan has no reports, or the report generation task has not started yet." + }, + status=status.HTTP_404_NOT_FOUND, ) if scan.output_location.startswith("s3://"): @@ -1477,10 +1495,10 @@ class ScanViewSet(BaseRLSViewSet): }, ) + prowler_task = Task.objects.get_with_retry(id=task.id) scan.task_id = task.id scan.save(update_fields=["task_id"]) - prowler_task = Task.objects.get(id=task.id) self.response_serializer_class = TaskSerializer output_serializer = self.get_serializer(prowler_task) @@ -2379,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]", @@ -2390,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"], @@ -2411,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 = [] @@ -2434,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( [ { @@ -2489,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): @@ -2505,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() @@ -2520,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( @@ -2566,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 = [] @@ -2811,7 +3123,7 @@ class ScheduleViewSet(BaseRLSViewSet): with transaction.atomic(): task = schedule_provider_scan(provider_instance) - prowler_task = Task.objects.get(id=task.id) + prowler_task = Task.objects.get_with_retry(id=task.id) self.response_serializer_class = TaskSerializer output_serializer = self.get_serializer(prowler_task) diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 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 81fbfbb0e9..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,11 +119,11 @@ 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() start_time = time.time() + exc = None with rls_transaction(tenant_id): provider_instance = Provider.objects.get(pk=provider_id) @@ -139,7 +139,7 @@ def perform_prowler_scan( provider_instance.connected = True except Exception as e: provider_instance.connected = False - raise ValueError( + exc = ValueError( f"Provider {provider_instance.provider} is not connected: {e}" ) finally: @@ -148,6 +148,11 @@ def perform_prowler_scan( ) provider_instance.save() + # If the provider is not connected, raise an exception outside the transaction. + # If raised within the transaction, the transaction will be rolled back and the provider will not be marked as not connected. + if exc: + raise exc + prowler_scan = ProwlerScan(provider=prowler_provider, checks=checks_to_execute) resource_cache = {} @@ -287,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( ( @@ -329,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( @@ -564,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 8880b8bd03..e4ec0d4d41 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -1,16 +1,19 @@ import json import uuid +from datetime import datetime from unittest.mock import MagicMock, patch 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, @@ -206,6 +209,10 @@ class TestPerformScan: scan.refresh_from_db() assert scan.state == StateChoices.FAILED + provider.refresh_from_db() + assert provider.connected is False + assert isinstance(provider.connection_last_checked_at, datetime) + @pytest.mark.parametrize( "last_status, new_status, expected_delta", [ @@ -230,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" @@ -245,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={ @@ -300,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={ @@ -358,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"] @@ -377,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/dashboard/compliance/nis2_aws.py b/dashboard/compliance/nis2_aws.py new file mode 100644 index 0000000000..8baac9a9a5 --- /dev/null +++ b/dashboard/compliance/nis2_aws.py @@ -0,0 +1,43 @@ +import warnings + +from dashboard.common_methods import get_section_containers_3_levels + +warnings.filterwarnings("ignore") + + +def get_table(data): + data["REQUIREMENTS_DESCRIPTION"] = ( + data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"] + ) + + data["REQUIREMENTS_DESCRIPTION"] = data["REQUIREMENTS_DESCRIPTION"].apply( + lambda x: x[:150] + "..." if len(str(x)) > 150 else x + ) + + data["REQUIREMENTS_ATTRIBUTES_SECTION"] = data[ + "REQUIREMENTS_ATTRIBUTES_SECTION" + ].apply(lambda x: x[:80] + "..." if len(str(x)) > 80 else x) + + data["REQUIREMENTS_ATTRIBUTES_SUBSECTION"] = data[ + "REQUIREMENTS_ATTRIBUTES_SUBSECTION" + ].apply(lambda x: x[:150] + "..." if len(str(x)) > 150 else x) + + aux = data[ + [ + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ] + + return get_section_containers_3_levels( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "REQUIREMENTS_DESCRIPTION", + ) diff --git a/dashboard/compliance/nis2_azure.py b/dashboard/compliance/nis2_azure.py new file mode 100644 index 0000000000..8baac9a9a5 --- /dev/null +++ b/dashboard/compliance/nis2_azure.py @@ -0,0 +1,43 @@ +import warnings + +from dashboard.common_methods import get_section_containers_3_levels + +warnings.filterwarnings("ignore") + + +def get_table(data): + data["REQUIREMENTS_DESCRIPTION"] = ( + data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"] + ) + + data["REQUIREMENTS_DESCRIPTION"] = data["REQUIREMENTS_DESCRIPTION"].apply( + lambda x: x[:150] + "..." if len(str(x)) > 150 else x + ) + + data["REQUIREMENTS_ATTRIBUTES_SECTION"] = data[ + "REQUIREMENTS_ATTRIBUTES_SECTION" + ].apply(lambda x: x[:80] + "..." if len(str(x)) > 80 else x) + + data["REQUIREMENTS_ATTRIBUTES_SUBSECTION"] = data[ + "REQUIREMENTS_ATTRIBUTES_SUBSECTION" + ].apply(lambda x: x[:150] + "..." if len(str(x)) > 150 else x) + + aux = data[ + [ + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ] + + return get_section_containers_3_levels( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "REQUIREMENTS_DESCRIPTION", + ) diff --git a/dashboard/lib/layouts.py b/dashboard/lib/layouts.py index f1b4408c38..144036f183 100644 --- a/dashboard/lib/layouts.py +++ b/dashboard/lib/layouts.py @@ -90,12 +90,28 @@ def create_layout_overview( ), html.Div( [ - ( - html.Label( - "Table Rows:", - className="text-prowler-stone-900 font-bold text-sm", - style={"margin-right": "10px"}, - ) + html.Label( + "Search:", + className="text-prowler-stone-900 font-bold text-sm", + style={"margin-right": "10px"}, + ), + dcc.Input( + id="search-input", + type="text", + placeholder="Search by check title, service, region...", + debounce=True, + style={ + "padding": "4px 8px", + "border": "1px solid #ccc", + "borderRadius": "4px", + "marginRight": "20px", + "width": "250px", + }, + ), + html.Label( + "Table Rows:", + className="text-prowler-stone-900 font-bold text-sm", + style={"margin-right": "10px"}, ), table_row_dropdown, download_button_csv, diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py index 2688281af2..0901604ad9 100644 --- a/dashboard/pages/overview.py +++ b/dashboard/pages/overview.py @@ -518,6 +518,7 @@ else: Input("service-filter", "value"), Input("table-rows", "value"), Input("status-filter", "value"), + Input("search-input", "value"), Input("aws_card", "n_clicks"), Input("azure_card", "n_clicks"), Input("gcp_card", "n_clicks"), @@ -540,6 +541,7 @@ def filter_data( service_values, table_row_values, status_values, + search_value, aws_clicks, azure_clicks, gcp_clicks, @@ -1144,6 +1146,15 @@ def filter_data( } index_count = 0 + if search_value: + search_value = search_value.lower() + filtered_data = filtered_data[ + filtered_data["CHECK_TITLE"].str.lower().str.contains(search_value) + | filtered_data["SERVICE_NAME"].str.lower().str.contains(search_value) + | filtered_data["REGION"].str.lower().str.contains(search_value) + | filtered_data["STATUS"].str.lower().str.contains(search_value) + ] + full_filtered_data = filtered_data.copy() filtered_data = filtered_data.head(table_row_values) # Sort the filtered_data diff --git a/docs/getting-started/requirements.md b/docs/getting-started/requirements.md index 29b9069013..e5d6087c1f 100644 --- a/docs/getting-started/requirements.md +++ b/docs/getting-started/requirements.md @@ -70,7 +70,7 @@ The other three cases does not need additional configuration, `--az-cli-auth` an Prowler for Azure needs two types of permission scopes to be set: - **Microsoft Entra ID permissions**: used to retrieve metadata from the identity assumed by Prowler and specific Entra checks (not mandatory to have access to execute the tool). The permissions required by the tool are the following: - - `Directory.Read.All` + - `Domain.Read.All` - `Policy.Read.All` - `UserAuthenticationMethod.Read.All` (used only for the Entra checks related with multifactor authentication) - **Subscription scope permissions**: required to launch the checks against your resources, mandatory to launch the tool. It is required to add the following RBAC builtin roles per subscription to the entity that is going to be assumed by the tool: @@ -81,6 +81,9 @@ Prowler for Azure needs two types of permission scopes to be set: To assign the permissions, follow the instructions in the [Microsoft Entra ID permissions](../tutorials/azure/create-prowler-service-principal.md#assigning-the-proper-permissions) section and the [Azure subscriptions permissions](../tutorials/azure/subscriptions.md#assign-the-appropriate-permissions-to-the-identity-that-is-going-to-be-assumed-by-prowler) section, respectively. +???+ warning + Some permissions in `ProwlerRole` are considered **write** permissions, so if you have a `ReadOnly` lock attached to some resources you may get an error and will not get a finding for that check. + #### Checks that require ProwlerRole The following checks require the `ProwlerRole` permissions to be executed, if you want to run them, make sure you have assigned the role to the identity that is going to be assumed by Prowler: @@ -153,77 +156,31 @@ With this credentials you will only be able to run the checks that work through Authentication flag: `--env-auth` -This authentication method follows the same approach as the service principal method but introduces two additional environment variables for user credentials: `M365_USER` and `M365_ENCRYPTED_PASSWORD`. +This authentication method follows the same approach as the service principal method but introduces two additional environment variables for user credentials: `M365_USER` and `M365_PASSWORD`. ```console export AZURE_CLIENT_ID="XXXXXXXXX" export AZURE_CLIENT_SECRET="XXXXXXXXX" export AZURE_TENANT_ID="XXXXXXXXX" export M365_USER="your_email@example.com" -export M365_ENCRYPTED_PASSWORD="6500780061006d0070006c006500700061007300730077006f0072006400" # replace this to yours +export M365_PASSWORD="examplepassword" ``` These two new environment variables are **required** to execute the PowerShell modules needed to retrieve information from M365 services. Prowler uses Service Principal authentication to access Microsoft Graph and user credentials to authenticate to Microsoft PowerShell modules. -- `M365_USER` should be your Microsoft account email using the default domain. This means it must look like `example@YourCompany.onmicrosoft.com`. +- `M365_USER` should be your Microsoft account email using the **assigned domain in the tenant**. This means it must look like `example@YourCompany.onmicrosoft.com` or `example@YourCompany.com`, but it must be the exact domain assigned to that user in the tenant. - To ensure that you are using the default domain you can see how to verify it [here](../tutorials/microsoft365/getting-started-m365.md#step-1-obtain-your-domain). + ???+ warning + Using a tenant domain other than the one assigned — even if it belongs to the same tenant — will cause Prowler to fail, as Microsoft authentication will not succeed. - If you don't have a user created with that domain, Prowler will not work as it will not be able to ensure both app an user belong to the same tenant. To proceed, you can either create a new user with that domain or modify the domain of an existing user. + Ensure you are using the right domain for the user you are trying to authenticate with. ![User Domains](../tutorials/microsoft365/img/user-domains.png) -- `M365_ENCRYPTED_PASSWORD` must be an encrypted SecureString. To convert your password into a valid encrypted string, you need to use PowerShell. - - ???+ warning - Passwords encrypted using ConvertTo-SecureString can only be decrypted on the same OS/user context. If you generate an encrypted password on macOS or Linux (both UNIX), it should fail on Windows and vice versa. As Prowler Cloud runs on UNIX if you generate your password using Windows it won't work so you'll need to generate a new password using any UNIX distro (example above) - - If you are working from Windows and you will use your encrypted password in a different system (like for example executing Prowler in macOS or adding your password to Prowler Cloud), you will need to generate a "UNIX compatible" version of your encrypted password. This can be done using WSL which is so easy to install on Windows. - - === "UNIX" - - Open a PowerShell cmd with a [supported version](requirements.md#supported-powershell-versions) and then run the following command: - - ```console - $securePassword = ConvertTo-SecureString "examplepassword" -AsPlainText -Force - $encryptedPassword = $securePassword | ConvertFrom-SecureString - Write-Output $encryptedPassword - 6500780061006d0070006c006500700061007300730077006f0072006400 - ``` - - If everything is done correctly, you will see the encrypted string that you need to set as the `M365_ENCRYPTED_PASSWORD` environment variable. - - === "Windows" - - - How to install WSL and PowerShell on it to generate that password (you can use a different distro but this one will work for sure): - - ```console - wsl --install -d Ubuntu-22.04 - ``` - - Then, open the Ubuntu terminal and run the following commands: - - ```console - sudo apt update && sudo apt install -y wget apt-transport-https software-properties-common - wget -q "https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb" - sudo dpkg -i packages-microsoft-prod.deb - sudo apt update - sudo apt install -y powershell - pwsh - ``` - - With this done you will see now that a prompt running PowerShell with the latest version is open so here you will be able to generate your encrypted password: - - ```console - $securePassword = ConvertTo-SecureString "examplepassword" -AsPlainText -Force - $encryptedPassword = $securePassword | ConvertFrom-SecureString - Write-Output $encryptedPassword - 6500780061006d0070006c006500700061007300730077006f0072006400 - ``` - - If everything is done correctly, you will see the encrypted string that you need to set as the `M365_ENCRYPTED_PASSWORD` environment variable. +- `M365_PASSWORD` must be the user password. + ???+ note + Before we asked for a encrypted password, but now we ask for the user password directly. Prowler will now handle the password encryption for you. ### Interactive Browser authentication @@ -242,10 +199,9 @@ Since this is a delegated permission authentication method, necessary permission Prowler for M365 requires two types of permission scopes to be set (if you want to run the full provider including PowerShell checks). Both must be configured using Microsoft Entra ID: - **Service Principal Application Permissions**: These are set at the **application** level and are used to retrieve data from the identity being assessed: - - `Directory.Read.All`: Required for all services. + - `Domain.Read.All`: Required for all services. - `Policy.Read.All`: Required for all services. - `User.Read` (IMPORTANT: this must be set as **delegated**): Required for the sign-in. - - `Sites.Read.All`: Required for SharePoint service. - `SharePointTenantSettings.Read.All`: Required for SharePoint service. - `AuditLog.Read.All`: Required for Entra service. 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/img/m365-credentials.png b/docs/img/m365-credentials.png index 9c06343ce2..87191751a8 100644 Binary files a/docs/img/m365-credentials.png and b/docs/img/m365-credentials.png differ diff --git a/docs/tutorials/azure/create-prowler-service-principal.md b/docs/tutorials/azure/create-prowler-service-principal.md index 02ab7ea629..25c170df97 100644 --- a/docs/tutorials/azure/create-prowler-service-principal.md +++ b/docs/tutorials/azure/create-prowler-service-principal.md @@ -40,7 +40,7 @@ az ad sp create-for-rbac --name "ProwlerApp" To allow Prowler to retrieve metadata from the identity assumed and run specific Entra checks, it is needed to assign the following permissions: -- `Directory.Read.All` +- `Domain.Read.All` - `Policy.Read.All` - `UserAuthenticationMethod.Read.All` (used only for the Entra checks related with multifactor authentication) @@ -58,7 +58,7 @@ To assign the permissions you can make it from the Azure Portal or using the Azu 5. Then click on "+ Add a permission" and select "Microsoft Graph" 6. Once in the "Microsoft Graph" view, select "Application permissions" 7. Finally, search for "Directory", "Policy" and "UserAuthenticationMethod" select the following permissions: - - `Directory.Read.All` + - `Domain.Read.All` - `Policy.Read.All` - `UserAuthenticationMethod.Read.All` 8. Click on "Add permissions" to apply the new permissions. diff --git a/docs/tutorials/azure/getting-started-azure.md b/docs/tutorials/azure/getting-started-azure.md index b9331703e8..3fabdcc3a5 100644 --- a/docs/tutorials/azure/getting-started-azure.md +++ b/docs/tutorials/azure/getting-started-azure.md @@ -90,7 +90,7 @@ A Service Principal is required to grant Prowler the necessary privileges. Assign the following Microsoft Graph permissions: - - Directory.Read.All + - Domain.Read.All - Policy.Read.All @@ -107,11 +107,11 @@ Assign the following Microsoft Graph permissions: 3. Search and select: - - `Directory.Read.All` + - `Domain.Read.All` - `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/configuration_file.md b/docs/tutorials/configuration_file.md index 78b9865183..dc1e66c96c 100644 --- a/docs/tutorials/configuration_file.md +++ b/docs/tutorials/configuration_file.md @@ -109,6 +109,15 @@ The following list includes all the Microsoft 365 checks with configurable varia | `exchange_organization_mailtips_enabled` | `recommended_mailtips_large_audience_threshold` | Integer | +## GitHub + +### Configurable Checks +The following list includes all the GitHub checks with configurable variables that can be changed in the configuration yaml file: + +| Check Name | Value | Type | +|--------------------------------------------|---------------------------------------------|---------| +| `repository_inactive_not_archived` | `inactive_not_archived_days_threshold` | Integer | + ## Config YAML File Structure ???+ note @@ -525,5 +534,10 @@ m365: # m365.exchange_organization_mailtips_enabled recommended_mailtips_large_audience_threshold: 25 # maximum number of recipients +# GitHub Configuration +github: + # github.repository_inactive_not_archived + inactive_not_archived_days_threshold: 180 + ``` diff --git a/docs/tutorials/microsoft365/getting-started-m365.md b/docs/tutorials/microsoft365/getting-started-m365.md index 067b31a1dd..2fde79fe84 100644 --- a/docs/tutorials/microsoft365/getting-started-m365.md +++ b/docs/tutorials/microsoft365/getting-started-m365.md @@ -4,9 +4,9 @@ Set up your M365 account to enable security scanning using Prowler Cloud/App. ## Requirements -To configure your M365 account, you’ll need: +To configure your M365 account, you'll need: -1. Obtain your `Default Domain` from the Entra ID portal. +1. Obtain a domain from the Entra ID portal. 2. Access Prowler Cloud/App and add a new cloud provider `Microsoft 365`. @@ -18,8 +18,6 @@ To configure your M365 account, you’ll need: 3.3 Assign the required roles to your user. - 3.4 Retrieve your encrypted password. - 4. Add the credentials to Prowler Cloud/App. ## Step 1: Obtain your Domain @@ -32,9 +30,7 @@ Go to the Entra ID portal, then you can search for `Domain` or go to Identity > ![Custom Domain Names](./img/custom-domain-names.png) -Once you are there just look for the `Default Domain` this should be something similar to `YourCompany.onmicrosoft.com`. To ensure that you are picking the correct domain just click on it and verify that the type is `Initial` and you can't delete it. - -![Search Default Domain](./img/search-default-domain.png) +Once you are there just select the domain you want to use. --- @@ -78,11 +74,11 @@ A Service Principal is required to grant Prowler the necessary privileges. ![New Registration](./img/new-registration.png) -4. Go to `Certificates & secrets` > `+ New client secret` +4. Go to `Certificates & secrets` > `Client secrets` > `+ New client secret` ![Certificate & Secrets nav](./img/certificates-and-secrets.png) -5. Fill in the required fields and click `Add`, then copy the generated value (that value will be `AZURE_CLIENT_SECRET`) +5. Fill in the required fields and click `Add`, then copy the generated `value` (that value will be `AZURE_CLIENT_SECRET`) ![New Client Secret](./img/new-client-secret.png) @@ -99,12 +95,11 @@ 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: - -- `Directory.Read.All`: Required for all services. +- `AuditLog.Read.All`: Required for Entra service. +- `Domain.Read.All`: Required for all services. - `Policy.Read.All`: Required for all services. -- `User.Read` (IMPORTANT: this is set as **delegated**): Required for the sign-in. -- `Sites.Read.All`: Required for SharePoint service. - `SharePointTenantSettings.Read.All`: Required for SharePoint service. +- `User.Read` (IMPORTANT: this is set as **delegated**): Required for the sign-in. Follow these steps to assign the permissions: @@ -117,12 +112,12 @@ 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`: - - - `Directory.Read.All` + - `AuditLog.Read.All`: Required for Entra service. + - `Domain.Read.All` - `Policy.Read.All` - - `Sites.Read.All` - `SharePointTenantSettings.Read.All` + ![Permission Screenshots](./img/directory-permission.png) 4. Click `Add permissions`, then grant admin consent @@ -174,25 +169,20 @@ Follow these steps to assign the role: --- -### Get your encrypted password - -For this step you will need to use PowerShell, here you will have to create your Encrypted Password based on the password of the User that you are going to use. For more information about how to generate this Password go [here](../../getting-started/requirements.md#service-principal-and-user-credentials-authentication-recommended) and follow the steps needed to obtain `M365_ENCRYPTED_PASSWORD`. - ---- - ## Step 4: Add credentials to Prowler Cloud/App 1. Go to your App Registration overview and copy the `Client ID` and `Tenant ID` ![App Overview](./img/app-overview.png) + 2. Go to Prowler Cloud/App and paste: - `Client ID` - `Tenant ID` - `AZURE_CLIENT_SECRET` from earlier - - `M365_USER` your user using the default domain, more info [here](../../getting-started/requirements.md#service-principal-and-user-credentials-authentication-recommended) - - `M365_ENCRYPTED_PASSWORD` generated before + - `M365_USER` the user using the correct assigned domain, more info [here](../../getting-started/requirements.md#service-principal-and-user-credentials-authentication-recommended) + - `M365_PASSWORD` the password of the user ![Prowler Cloud M365 Credentials](./img/m365-credentials.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/docs/tutorials/microsoft365/img/m365-credentials.png b/docs/tutorials/microsoft365/img/m365-credentials.png index 9c06343ce2..87191751a8 100644 Binary files a/docs/tutorials/microsoft365/img/m365-credentials.png and b/docs/tutorials/microsoft365/img/m365-credentials.png differ diff --git a/poetry.lock b/poetry.lock index 01c3b39bd3..bea5bae5ff 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2184,6 +2184,8 @@ python-versions = "*" groups = ["dev"] files = [ {file = "jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c"}, + {file = "jsonpath_ng-1.7.0-py2-none-any.whl", hash = "sha256:898c93fc173f0c336784a3fa63d7434297544b7198124a68f9a3ef9597b0ae6e"}, + {file = "jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6"}, ] [package.dependencies] @@ -3969,32 +3971,6 @@ cffi = ">=1.4.1" docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] -[[package]] -name = "pynacl" -version = "1.5.0" -description = "Python binding to the Networking and Cryptography (NaCl) library" -optional = false -python-versions = ">=3.6" -files = [ - {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"}, - {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"}, - {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"}, - {file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"}, - {file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"}, - {file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"}, -] - -[package.dependencies] -cffi = ">=1.4.1" - -[package.extras] -docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] -tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] - [[package]] name = "pyparsing" version = "3.2.3" diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 3b3f77db43..2d4a622c30 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.8.0] (Prowler v5.8.0) +## [5.8.0] (Prowler v5.8.0) ### Added - Add CIS 1.11 compliance framework for Kubernetes. [(#7790)](https://github.com/prowler-cloud/prowler/pull/7790) @@ -14,12 +14,24 @@ All notable changes to the **Prowler SDK** are documented in this file. - Add a level for Prowler ThreatScore in the accordion in Dashboard. [(#7739)](https://github.com/prowler-cloud/prowler/pull/7739) - Add CIS 4.0 compliance framework for GCP. [(7785)](https://github.com/prowler-cloud/prowler/pull/7785) - Add `repository_has_codeowners_file` check for GitHub provider. [(#7752)](https://github.com/prowler-cloud/prowler/pull/7752) +- Add `repository_default_branch_requires_signed_commits` check for GitHub provider. [(#7777)](https://github.com/prowler-cloud/prowler/pull/7777) +- Add `repository_inactive_not_archived` check for GitHub provider. [(#7786)](https://github.com/prowler-cloud/prowler/pull/7786) +- Add `repository_dependency_scanning_enabled` check for GitHub provider. [(#7771)](https://github.com/prowler-cloud/prowler/pull/7771) +- Add `repository_secret_scanning_enabled` check for GitHub provider. [(#7759)](https://github.com/prowler-cloud/prowler/pull/7759) - Add `repository_default_branch_requires_codeowners_review` check for GitHub provider. [(#7753)](https://github.com/prowler-cloud/prowler/pull/7753) +- Add NIS 2 compliance framework for AWS. [(7839)](https://github.com/prowler-cloud/prowler/pull/7839) +- Add NIS 2 compliance framework for Azure. [(7857)](https://github.com/prowler-cloud/prowler/pull/7857) +- Add search bar in Dashboard Overview page. [(#7804)](https://github.com/prowler-cloud/prowler/pull/7804) -### Fixed +--- + +### [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) --- @@ -48,6 +60,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Update and upgrade CIS for all the providers [(#7738)](https://github.com/prowler-cloud/prowler/pull/7738) - Cover policies with conditions with SNS endpoint in `sns_topics_not_publicly_accessible`. [(#7750)](https://github.com/prowler-cloud/prowler/pull/7750) - Change severity logic for `ec2_securitygroup_allow_ingress_from_internet_to_all_ports` check. [(#7764)](https://github.com/prowler-cloud/prowler/pull/7764) +- Automatically encrypt password in Microsoft365 provider. [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784) --- diff --git a/prowler/compliance/aws/nis2_aws.json b/prowler/compliance/aws/nis2_aws.json new file mode 100644 index 0000000000..a8c5aabce8 --- /dev/null +++ b/prowler/compliance/aws/nis2_aws.json @@ -0,0 +1,2104 @@ +{ + "Framework": "NIS2", + "Version": "", + "Provider": "AWS", + "Description": "ANNEX to the Commission Implementing Regulation laying down rules for the application of Directive (EU) 2022/2555 as regards technical and methodological requirements of cybersecurity risk-management measures and further specification of the cases in which an incident is considered to be significant with regard to DNS service providers, TLD name registries, cloud computing service providers, data centre service providers, content delivery network providers, managed service providers, managed security service providers, providers of online market places, of online search engines and of social networking services platforms, and trust service providers", + "Requirements": [ + { + "Id": "1.1.1.a", + "Description": "set out the relevant entities approach to managing the security of their network and information systems;", + "Checks": [ + "route53_domains_privacy_protection_enabled", + "secretsmanager_not_publicly_accessible", + "account_security_contact_information_is_registered" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "generic" + } + ] + }, + { + "Id": "1.1.1.c", + "Description": "set out network and information security objectives;", + "Checks": [ + "route53_domains_privacy_protection_enabled" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "route53" + } + ] + }, + { + "Id": "1.1.1.d", + "Description": "include a commitment to continual improvement of the security of network and information systems;", + "Checks": [ + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_rotate_access_key_90_days", + "route53_domains_privacy_protection_enabled", + "secretsmanager_secret_rotated_periodically" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "generic" + } + ] + }, + { + "Id": "1.1.1.h", + "Description": "list the documentation to be kept and the duration of retention of the documentation;", + "Checks": [ + "kinesis_stream_data_retention_period", + "s3_bucket_server_access_logging_enabled", + "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "generic" + } + ] + }, + { + "Id": "1.1.2", + "Description": "The network and information system security policy shall be reviewed and, where appropriate, updated by management bodies at least annually and when significant incidents or significant changes to operations or risks occur. The result of the reviews shall be documented.", + "Checks": [ + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_rotate_access_key_90_days", + "secretsmanager_secret_rotated_periodically" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "generic" + } + ] + }, + { + "Id": "1.2.1", + "Description": "As part of their policy on the security of network and information systems referred to in point 1.1., the relevant entities shall lay down responsibilities and authorities for network and information system security and assign them to roles, allocate them according to the relevant entities needs, and communicate them to the management bodies.", + "Checks": [ + "iam_policy_attached_only_to_group_or_roles", + "iam_role_administratoraccess_policy", + "iam_policy_cloudshell_admin_not_attached" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "iam" + } + ] + }, + { + "Id": "1.2.3", + "Description": "At least one person shall report directly to the management bodies on matters of network and information system security.", + "Checks": [ + "account_security_contact_information_is_registered" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "account" + } + ] + }, + { + "Id": "1.2.4", + "Description": "Depending on the size of the relevant entities, network and information system security shall be covered by dedicated roles or duties carried out in addition to existing roles.", + "Checks": [ + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "iam" + } + ] + }, + { + "Id": "2.1.1", + "Description": "For the purpose of Article 21(2), point (a) of Directive (EU) 2022/2555, the relevant entities shall establish and maintain an appropriate risk management framework to identify and address the risks posed to the security of network and information systems. The relevant entities shall perform and document risk assessments and, based on the results, establish, implement and monitor a risk treatment plan. Risk assessment results and residual risks shall be accepted by management bodies or, where applicable, by persons who are accountable and have the authority to manage risks, provided that the relevant entities ensure adequate reporting to the management bodies.", + "Checks": [ + "ssmincidents_enabled_with_plans", + "iam_securityaudit_role_created", + "iam_support_role_created" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.a", + "Description": "follow a risk management methodology;", + "Checks": [ + "ssmincidents_enabled_with_plans", + "iam_securityaudit_role_created", + "iam_support_role_created" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.d", + "Description": "in line with an all-hazards approach, identify and document the risks posed to the security of network and information systems, in particular in relation to third parties and risks that could lead to disruptions in the availability, integrity, authenticity and confidentiality of the network and information systems, including the identification of single point of failures;", + "Checks": [ + "networkfirewall_multi_az", + "networkfirewall_policy_rule_group_associated", + "backup_plans_exist" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.e", + "Description": "analyse the risks posed to the security of network and information systems, including threat, likelihood, impact, and risk level, taking into account cyber threat intelligence and vulnerabilities;", + "Checks": [ + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "iam" + } + ] + }, + { + "Id": "2.1.2.f", + "Description": "evaluate the identified risks based on the risk criteria;", + "Checks": [ + "iam_securityaudit_role_created", + "iam_policy_attached_only_to_group_or_roles", + "secretsmanager_secret_rotated_periodically" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.g", + "Description": "identify and prioritise appropriate risk treatment options and measures;", + "Checks": [ + "guardduty_no_high_severity_findings", + "inspector2_active_findings_exist", + "accessanalyzer_enabled_without_findings" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.h", + "Description": "continuously monitor the implementation of the risk treatment measures;", + "Checks": [ + "guardduty_no_high_severity_findings", + "inspector2_active_findings_exist", + "accessanalyzer_enabled_without_findings" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.i", + "Description": "identify who is responsible for implementing the risk treatment measures and when they should be implemented;", + "Checks": [ + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "ssm" + } + ] + }, + { + "Id": "2.1.2.j", + "Description": "document the chosen risk treatment measures in a risk treatment plan and the reasons justifying the acceptance of residual risks in a comprehensible manner.", + "Checks": [ + "backup_plans_exist", + "backup_reportplans_exist" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "backup" + } + ] + }, + { + "Id": "2.1.3", + "Description": "When identifying and prioritising appropriate risk treatment options and measures, the relevant entities shall take into account the risk assessment results, the results of the procedure to assess the effectiveness of cybersecurity risk-management measures, the cost of implementation in relation to the expected benefit, the asset classification referred to in point 12.1., and the business impact analysis referred to in point 4.1.3.", + "Checks": [ + "backup_recovery_point_encrypted" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "backup" + } + ] + }, + { + "Id": "2.1.4", + "Description": "The relevant entities shall review and, where appropriate, update the risk assessment results and the risk treatment plan at planned intervals and at least annually, and when significant changes to operations or risks or significant incidents occur.", + "Checks": [ + "secretsmanager_secret_rotated_periodically", + "iam_rotate_access_key_90_days" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.2.1", + "Description": "The relevant entities shall regularly review the compliance with their policies on network and information system security, topic-specific policies, rules, and standards. The management bodies shall be informed of the status of network and information security on the basis of the compliance reviews by means of regular reporting.", + "Checks": [ + "networkfirewall_policy_rule_group_associated", + "account_security_contact_information_is_registered", + "iam_support_role_created", + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.2 Compliance monitoring", + "Service": "generic" + } + ] + }, + { + "Id": "2.2.3", + "Description": "The relevant entities shall perform the compliance monitoring at planned intervals and when significant incidents or significant changes to operations or risks occur.", + "Checks": [ + "secretsmanager_secret_rotated_periodically", + "account_maintain_current_contact_details", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.2 Compliance monitoring", + "Service": "generic" + } + ] + }, + { + "Id": "2.3.1", + "Description": "The relevant entities shall review independently their approach to managing network and information system security and its implementation including people, processes and technologies.", + "Checks": [ + "iam_rotate_access_key_90_days", + "iam_securityaudit_role_created", + "secretsmanager_secret_rotated_periodically", + "cloudwatch_log_metric_filter_root_usage" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.3 Independent review of information and network security", + "Service": "generic" + } + ] + }, + { + "Id": "3.1.1", + "Description": "For the purpose of Article 21(2), point (b) of Directive (EU) 2022/2555, the relevant entities shall establish and implement an incident handling policy laying down the roles, responsibilities, and procedures for detecting, analysing, containing or responding to, recovering from, documenting and reporting of incidents in a timely manner.", + "Checks": [ + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "ssm" + } + ] + }, + { + "Id": "3.1.2.a", + "Description": "a categorisation system for incidents that is consistent with the event assessment and classification carried out pursuant to point 3.4.1.;", + "Checks": [ + "cloudtrail_multi_region_enabled_logging_management_events", + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "generic" + } + ] + }, + { + "Id": "3.1.2.c", + "Description": "assignment of roles to detect and appropriately respond to incidents to competent employees;", + "Checks": [ + "iam_role_cross_service_confused_deputy_prevention", + "iam_securityaudit_role_created", + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "generic" + } + ] + }, + { + "Id": "3.1.2.d", + "Description": "documents to be used in the course of incident detection and response such as incident response manuals, escalation charts, contact lists and templates.", + "Checks": [ + "ssmincidents_enabled_with_plans", + "ssm_documents_set_as_public", + "iam_support_role_created", + "account_security_contact_information_is_registered" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "generic" + } + ] + }, + { + "Id": "3.1.3", + "Description": "The roles, responsibilities and procedures laid down in the policy shall be tested and reviewed and, where appropriate, updated at planned intervals and after significant incidents or significant changes to operations or risks.", + "Checks": [ + "secretsmanager_secret_rotated_periodically", + "iam_rotate_access_key_90_days", + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.1", + "Description": "The relevant entities shall lay down procedures and use tools to monitor and log activities on their network and information systems to detect events that could be considered as incidents and respond accordingly to mitigate the impact.", + "Checks": [ + "cloudwatch_log_metric_filter_root_usage" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.2.2", + "Description": "To the extent feasible, monitoring shall be automated and carried out either continuously or in periodic intervals, subject to business capabilities. The relevant entities shall implement their monitoring activities in a way which minimises false positives and false negatives.", + "Checks": [ + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_root_usage" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.2.3.a", + "Description": "relevant outbound and inbound network traffic;", + "Checks": [ + "cloudfront_distributions_origin_traffic_encrypted", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "ec2_launch_template_no_public_ip" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.b", + "Description": "creation, modification or deletion of users of the relevant entities network and information systems and extension of the permissions;", + "Checks": [ + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.2.3.c", + "Description": "access to systems and applications;", + "Checks": [ + "acm_certificates_transparency_logs_enabled", + "apigateway_restapi_logging_enabled", + "apigatewayv2_api_access_logging_enabled", + "appsync_field_level_logging_enabled", + "athena_workgroup_logging_enabled", + "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", + "bedrock_model_invocation_logging_enabled", + "bedrock_model_invocation_logs_encryption_enabled", + "cloudfront_distributions_logging_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_group_not_publicly_accessible", + "cloudwatch_log_group_retention_policy_specific_days_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "codebuild_project_logging_enabled", + "codebuild_project_s3_logs_encrypted", + "datasync_task_logging_enabled", + "dms_replication_task_source_logging_enabled", + "dms_replication_task_target_logging_enabled", + "ec2_client_vpn_endpoint_connection_logging_enabled", + "ecs_task_definitions_logging_block_mode", + "ecs_task_definitions_logging_enabled", + "eks_control_plane_logging_all_types_enabled", + "elasticbeanstalk_environment_cloudwatch_logging_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "glue_etl_jobs_cloudwatch_logs_encryption_enabled", + "glue_etl_jobs_logging_enabled", + "guardduty_eks_audit_log_enabled", + "mq_broker_logging_enabled", + "neptune_cluster_integration_cloudwatch_logs", + "networkfirewall_logging_enabled", + "opensearch_service_domains_audit_logging_enabled", + "opensearch_service_domains_cloudwatch_logging_enabled", + "rds_cluster_integration_cloudwatch_logs", + "rds_instance_integration_cloudwatch_logs", + "redshift_cluster_audit_logging", + "route53_public_hosted_zones_cloudwatch_logging_enabled", + "s3_bucket_server_access_logging_enabled", + "stepfunctions_statemachine_logging_enabled", + "vpc_flow_logs_enabled", + "waf_global_webacl_logging_enabled", + "wafv2_webacl_logging_enabled", + "wafv2_webacl_rule_logging_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.d", + "Description": "authentication-related events;", + "Checks": [ + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "directoryservice_supported_mfa_radius_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.e", + "Description": "all privileged access to systems and applications, and activities performed by administrative accounts;", + "Checks": [ + "accessanalyzer_enabled", + "cloudwatch_log_metric_filter_root_usage" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.f", + "Description": "access or changes to critical configuration and backup files;", + "Checks": [ + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.g", + "Description": "event logs and logs from security tools, such as antivirus, intrusion detection systems or firewalls;", + "Checks": [ + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "cloudtrail_cloudwatch_logging_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.h", + "Description": "use of system resources, as well as their performance;", + "Checks": [ + "rds_instance_enhanced_monitoring_enabled", + "ec2_instance_detailed_monitoring_enabled", + "guardduty_eks_runtime_monitoring_enabled", + "kafka_cluster_enhanced_monitoring_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.4", + "Description": "The logs shall be regularly reviewed for any unusual or unwanted trends. Where appropriate, the relevant entities shall lay down appropriate values for alarm thresholds. If the laid down values for alarm threshold are exceeded, an alarm shall be triggered, where appropriate, automatically. The relevant entities shall ensure that, in case of an alarm, a qualified and appropriate response is initiated in a timely manner.", + "Checks": [ + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudwatch_changes_to_network_route_tables_alarm_configured" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.2.5", + "Description": "The relevant entities shall maintain and back up logs for a predefined period and shall protect them from unauthorised access or changes.", + "Checks": [ + "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.4.1", + "Description": "The relevant entities shall assess suspicious events to determine whether they constitute incidents and, if so, determine their nature and severity.", + "Checks": [ + "cloudtrail_threat_detection_privilege_escalation", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.4 Event assessment and classification", + "Service": "cloudtrail" + } + ] + }, + { + "Id": "3.4.2.c", + "Description": "review the appropriate logs for the purposes of event assessment and classification;", + "Checks": [ + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudwatch_log_metric_filter_unauthorized_api_calls" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.4 Event assessment and classification", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.4.2.d", + "Description": "put in place a process for log correlation and analysis, and reassess and reclassify events in case of new information becoming available or after analysis of previously available information.", + "Checks": [ + "cloudtrail_log_file_validation_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.4 Event assessment and classification", + "Service": "generic" + } + ] + }, + { + "Id": "3.5.1", + "Description": "The relevant entities shall respond to incidents in accordance with documented procedures and in a timely manner.", + "Checks": [ + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "ssm" + } + ] + }, + { + "Id": "3.5.3.a", + "Description": "with the Computer Security Incident Response Teams (CSIRTs) or, where applicable, the competent authorities, related to incident notification;", + "Checks": [ + "route53_domains_privacy_protection_enabled", + "account_security_contact_information_is_registered", + "account_maintain_current_contact_details", + "ec2_instance_secrets_user_data", + "secretsmanager_not_publicly_accessible" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "generic" + } + ] + }, + { + "Id": "3.5.4", + "Description": "The relevant entities shall log incident response activities in accordance with the procedures referred to in point 3.2.1., and record evidence.", + "Checks": [ + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_unauthorized_api_calls" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.6.1", + "Description": "Where appropriate, the relevant entities shall carry out post-incident reviews after recovery from incidents. The post-incident reviews shall identify, where possible, the root cause of the incident and result in documented lessons learned to reduce the occurrence and consequences of future incidents.", + "Checks": [ + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.6 Post-incident reviews", + "Service": "ssm" + } + ] + }, + { + "Id": "3.6.2", + "Description": "The relevant entities shall ensure that post-incident reviews contribute to improving their approach to network and information security, to risk treatment measures, and to incident handling, detection and response procedures.", + "Checks": [ + "ssmincidents_enabled_with_plans", + "backup_plans_exist", + "backup_recovery_point_encrypted", + "backup_reportplans_exist", + "backup_vaults_encrypted", + "backup_vaults_exist", + "documentdb_cluster_backup_enabled", + "dynamodb_table_protected_by_backup_plan", + "ec2_ebs_volume_protected_by_backup_plan", + "efs_have_backup_enabled", + "elasticache_redis_cluster_backup_enabled", + "fsx_file_system_copy_tags_to_backups_enabled", + "neptune_cluster_backup_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_backup_enabled", + "rds_instance_protected_by_backup_plan" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.6 Post-incident reviews", + "Service": "generic" + } + ] + }, + { + "Id": "3.6.3", + "Description": "The relevant entities shall review at planned intervals if incidents led to post-incident reviews.", + "Checks": [ + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.6 Post-incident reviews", + "Service": "generic" + } + ] + }, + { + "Id": "4.1.1", + "Description": "For the purpose of Article 21(2), point (c) of Directive (EU) 2022/2555, the relevant entities shall lay down and maintain a business continuity and disaster recovery plan to apply in the case of incidents.", + "Checks": [ + "backup_recovery_point_encrypted", + "backup_reportplans_exist", + "backup_plans_exist", + "rds_instance_backup_enabled", + "rds_instance_protected_by_backup_plan", + "rds_cluster_protected_by_backup_plan" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "generic" + } + ] + }, + { + "Id": "4.1.2.f", + "Description": "recovery plans for specific operations, including recovery objectives;", + "Checks": [ + "backup_plans_exist", + "backup_reportplans_exist", + "backup_vaults_exist" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "backup" + } + ] + }, + { + "Id": "4.1.2.g", + "Description": "required resources, including backups and redundancies;", + "Checks": [ + "backup_plans_exist", + "backup_recovery_point_encrypted", + "backup_reportplans_exist", + "backup_vaults_encrypted", + "backup_vaults_exist", + "documentdb_cluster_backup_enabled", + "dynamodb_table_protected_by_backup_plan", + "ec2_ebs_volume_protected_by_backup_plan", + "efs_have_backup_enabled", + "elasticache_redis_cluster_backup_enabled", + "fsx_file_system_copy_tags_to_backups_enabled", + "neptune_cluster_backup_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_backup_enabled", + "rds_instance_protected_by_backup_plan" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "generic" + } + ] + }, + { + "Id": "4.1.3", + "Description": "The relevant entities shall carry out a business impact analysis to assess the potential impact of severe disruptions to their business operations and shall, based on the results of the business impact analysis, establish continuity requirements for the network and information systems.", + "Checks": [ + "backup_plans_exist" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "backup" + } + ] + }, + { + "Id": "4.1.4", + "Description": "The business continuity plan and disaster recovery plan shall be tested, reviewed and, where appropriate, updated at planned intervals and following significant incidents or significant changes to operations or risks. The relevant entities shall ensure that the plans incorporate lessons learnt from such tests.", + "Checks": [ + "backup_plans_exist", + "backup_reportplans_exist", + "rds_cluster_protected_by_backup_plan", + "rds_instance_protected_by_backup_plan", + "dynamodb_table_protected_by_backup_plan" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "generic" + } + ] + }, + { + "Id": "4.2.2.b", + "Description": "assurance that backup copies are complete and accurate, including configuration data and data stored in cloud computing service environment;", + "Checks": [ + "backup_vaults_encrypted", + "backup_vaults_exist", + "backup_recovery_point_encrypted", + "backup_plans_exist" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "backup" + } + ] + }, + { + "Id": "4.2.2.e", + "Description": "restoring data from backup copies;", + "Checks": [ + "rds_cluster_backtrack_enabled", + "rds_instance_backup_enabled", + "backup_vaults_exist" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "generic" + } + ] + }, + { + "Id": "4.2.2.f", + "Description": "retention periods based on business and regulatory requirements.", + "Checks": [ + "kinesis_stream_data_retention_period", + "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "generic" + } + ] + }, + { + "Id": "4.3.1", + "Description": "The relevant entities shall put in place a process for crisis management.", + "Checks": [ + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.3 Crisis management", + "Service": "ssm" + } + ] + }, + { + "Id": "4.3.2.a", + "Description": "roles and responsibilities for personnel and, where appropriate, suppliers and service providers, specifying the allocation of roles in crisis situations, including specific steps to follow;", + "Checks": [ + "iam_support_role_created" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.3 Crisis management", + "Service": "iam" + } + ] + }, + { + "Id": "4.3.2.c", + "Description": "application of appropriate measures to ensure the maintenance of network and information system security in crisis situations.", + "Checks": [ + "iam_no_expired_server_certificates_stored", + "route53_domains_privacy_protection_enabled", + "backup_vaults_encrypted" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.3 Crisis management", + "Service": "generic" + } + ] + }, + { + "Id": "5.1.2.a", + "Description": "the cybersecurity practices of the suppliers and service providers, including their secure development procedures;", + "Checks": [ + "codebuild_report_group_export_encrypted", + "codebuild_project_s3_logs_encrypted", + "codebuild_project_source_repo_url_no_sensitive_credentials", + "cloudfront_distributions_origin_traffic_encrypted", + "bedrock_model_invocation_logs_encryption_enabled" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "5.1.4.f", + "Description": "an obligation on suppliers and service providers to handle vulnerabilities that present a risk to the security of the network and information systems of the relevant entities;", + "Checks": [ + "guardduty_no_high_severity_findings", + "inspector2_active_findings_exist" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "5.1.7.b", + "Description": "review incidents related to ICT products and ICT services from suppliers and service providers;", + "Checks": [ + "ssmincidents_enabled_with_plans", + "iam_support_role_created", + "account_security_contact_information_is_registered", + "account_maintain_current_contact_details" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "5.1.7.d", + "Description": "analyse the risks presented by changes related to ICT products and ICT services from suppliers and service providers and, where appropriate, take mitigating measures in a timely manner.", + "Checks": [ + "guardduty_no_high_severity_findings", + "inspector2_active_findings_exist" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "6.1.2.b", + "Description": "requirements regarding security updates throughout the entire lifetime of the ICT services or ICT products, or replacement after the end of the support period;", + "Checks": [ + "elasticbeanstalk_environment_managed_updates_enabled", + "opensearch_service_domains_updated_to_the_latest_service_software_version" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.1 Security in acquisition of ICT services or ICT products", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.1", + "Description": "Before developing a network and information system, including software, the relevant entities shall lay down rules for the secure development of network and information systems and apply them when developing network and information systems in-house, or when outsourcing the development of network and information systems. The rules shall cover all development phases, including specification, design, development, implementation and testing.", + "Checks": [ + "glue_development_endpoints_cloudwatch_logs_encryption_enabled", + "glue_development_endpoints_job_bookmark_encryption_enabled", + "glue_development_endpoints_s3_encryption_enabled", + "sagemaker_models_network_isolation_enabled", + "ecs_task_definitions_host_networking_mode_users", + "eks_cluster_network_policy_enabled", + "networkfirewall_deletion_protection", + "networkfirewall_in_all_vpc", + "networkfirewall_policy_default_action_fragmented_packets", + "networkfirewall_policy_default_action_full_packets" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.2.a", + "Description": "carry out an analysis of security requirements at the specification and design phases of any development or acquisition project undertaken by the relevant entities or on behalf of those entities;", + "Checks": [ + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.2.b", + "Description": "apply principles for engineering secure systems and secure coding principles to any information system development activities such as promoting cybersecurity-by-design, zero-trust architectures;", + "Checks": [ + "codebuild_report_group_export_encrypted", + "codebuild_project_logging_enabled", + "codebuild_project_no_secrets_in_variables", + "codebuild_project_older_90_days", + "codebuild_project_s3_logs_encrypted", + "codebuild_project_source_repo_url_no_sensitive_credentials", + "codebuild_project_user_controlled_buildspec" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.2.c", + "Description": "lay down security requirements regarding development environments;", + "Checks": [ + "codebuild_project_no_secrets_in_variables", + "codebuild_project_s3_logs_encrypted", + "codebuild_report_group_export_encrypted", + "codebuild_project_source_repo_url_no_sensitive_credentials", + "glue_development_endpoints_job_bookmark_encryption_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.4", + "Description": "The relevant entities shall review and, where necessary, update their secure development rules at planned intervals.", + "Checks": [ + "secretsmanager_secret_rotated_periodically", + "iam_rotate_access_key_90_days" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "generic" + } + ] + }, + { + "Id": "6.4.1", + "Description": "The relevant entities shall apply change management procedures to control changes of network and information systems. Where applicable, the procedures shall be consistent with the relevant entities general policies concerning change management.", + "Checks": [ + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.4 Change management, repairs and maintenance", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "6.6.1.a", + "Description": "security patches are applied within a reasonable time after they become available;", + "Checks": [ + "ssm_managed_compliant_patching", + "memorydb_cluster_auto_minor_version_upgrades", + "elasticbeanstalk_environment_managed_updates_enabled", + "elasticache_redis_cluster_auto_minor_version_upgrades", + "glue_development_endpoints_job_bookmark_encryption_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.6 Security patch management", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.b", + "Description": "determine and apply controls to protect the relevant entities internal network domains from unauthorised access;", + "Checks": [ + "networkfirewall_deletion_protection", + "networkfirewall_in_all_vpc", + "networkfirewall_logging_enabled", + "networkfirewall_multi_az", + "networkfirewall_policy_default_action_fragmented_packets", + "networkfirewall_policy_default_action_full_packets", + "networkfirewall_policy_rule_group_associated" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "networkfirewall" + } + ] + }, + { + "Id": "6.7.2.e", + "Description": "not use systems used for administration of the security policy implementation for other purposes;", + "Checks": [ + "iam_inline_policy_no_administrative_privileges", + "iam_avoid_root_usage" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "iam" + } + ] + }, + { + "Id": "6.7.2.g", + "Description": "where appropriate, exclusively allow access to the relevant entities network and information systems by devices authorised by those entities;", + "Checks": [ + "ec2_instance_port_cassandra_exposed_to_internet", + "ec2_instance_port_cifs_exposed_to_internet", + "ec2_instance_port_elasticsearch_kibana_exposed_to_internet", + "ec2_instance_port_ftp_exposed_to_internet", + "ec2_instance_port_kafka_exposed_to_internet", + "ec2_instance_port_kerberos_exposed_to_internet", + "ec2_instance_port_ldap_exposed_to_internet", + "ec2_instance_port_memcached_exposed_to_internet", + "ec2_instance_port_mongodb_exposed_to_internet", + "ec2_instance_port_mysql_exposed_to_internet", + "ec2_instance_port_oracle_exposed_to_internet", + "ec2_instance_port_postgresql_exposed_to_internet", + "ec2_instance_port_rdp_exposed_to_internet", + "ec2_instance_port_redis_exposed_to_internet", + "ec2_instance_port_sqlserver_exposed_to_internet", + "ec2_instance_port_ssh_exposed_to_internet", + "ec2_instance_port_telnet_exposed_to_internet", + "ec2_networkacl_allow_ingress_tcp_port_22", + "ec2_networkacl_allow_ingress_tcp_port_3389" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "ec2" + } + ] + }, + { + "Id": "6.7.2.i", + "Description": "establish communication between distinct systems only through trusted channels that are isolated using logical, cryptographic or physical separation from other communication channels and provide assured identification of their end points and protection of the channel data from modification or disclosure;", + "Checks": [ + "kafka_cluster_in_transit_encryption_enabled", + "kafka_connector_in_transit_encryption_enabled", + "dynamodb_accelerator_cluster_in_transit_encryption_enabled", + "redshift_cluster_in_transit_encryption_enabled", + "transfer_server_in_transit_encryption_enabled", + "elbv2_nlb_tls_termination_enabled", + "kafka_cluster_mutual_tls_authentication_enabled", + "autoscaling_group_launch_configuration_requires_imdsv2", + "ec2_instance_account_imdsv2_enabled", + "ec2_instance_imdsv2_enabled", + "ec2_launch_template_imdsv2_required" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.l", + "Description": "apply best practices for the security of the DNS, and for Internet routing security and routing hygiene of traffic originating from and destined to the network.", + "Checks": [ + "route53_domains_privacy_protection_enabled", + "route53_dangling_ip_subdomain_takeover", + "elbv2_nlb_tls_termination_enabled", + "kafka_cluster_mutual_tls_authentication_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.8.2.a", + "Description": "consider the functional, logical and physical relationship, including location, between trustworthy systems and services;", + "Checks": [ + "iam_role_cross_service_confused_deputy_prevention", + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "vpc_endpoint_connections_trust_boundaries" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.8 Network segmentation", + "Service": "generic" + } + ] + }, + { + "Id": "6.9.2", + "Description": "For that purpose, the relevant entities shall in particular implement measures that detect or prevent the use of malicious or unauthorised software. The relevant entities shall, where appropriate, ensure that their network and information systems are equipped with detection and response software, which is updated regularly in accordance with the risk assessment carried out pursuant to point 2.1 and the contractual agreements with the providers.", + "Checks": [ + "cloudtrail_threat_detection_privilege_escalation", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.9 Protection against malicious and unauthorised software", + "Service": "cloudtrail" + } + ] + }, + { + "Id": "7.2.b", + "Description": "the methods for monitoring, measurement, analysis and evaluation, as applicable, to ensure valid results;", + "Checks": [ + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_root_usage" + ], + "Attributes": [ + { + "Section": "7 POLICIES AND PROCEDURES TO ASSESS THE EFFECTIVENESS OF CYBERSECURITY RISK-MANAGEMENT MEASURES (ARTICLE 21(2), POINT (F), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "7.2 The policy and procedures referred to in point 7.1. shall take into account results of the risk assessment pursuant to point 2.1. and past significant incidents. The relevant entities shall determine:", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "7.2.d", + "Description": "who is responsible for monitoring and measuring the effectiveness of the cybersecurity risk-management measures;", + "Checks": [ + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "7 POLICIES AND PROCEDURES TO ASSESS THE EFFECTIVENESS OF CYBERSECURITY RISK-MANAGEMENT MEASURES (ARTICLE 21(2), POINT (F), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "7.2 The policy and procedures referred to in point 7.1. shall take into account results of the risk assessment pursuant to point 2.1. and past significant incidents. The relevant entities shall determine:", + "Service": "iam" + } + ] + }, + { + "Id": "7.2.e", + "Description": "when the results from monitoring and measurement are to be analysed and evaluated;", + "Checks": [ + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "7 POLICIES AND PROCEDURES TO ASSESS THE EFFECTIVENESS OF CYBERSECURITY RISK-MANAGEMENT MEASURES (ARTICLE 21(2), POINT (F), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "7.2 The policy and procedures referred to in point 7.1. shall take into account results of the risk assessment pursuant to point 2.1. and past significant incidents. The relevant entities shall determine:", + "Service": "iam" + } + ] + }, + { + "Id": "7.2.f", + "Description": "who has to analyse and evaluate these results.", + "Checks": [ + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "7 POLICIES AND PROCEDURES TO ASSESS THE EFFECTIVENESS OF CYBERSECURITY RISK-MANAGEMENT MEASURES (ARTICLE 21(2), POINT (F), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "7.2 The policy and procedures referred to in point 7.1. shall take into account results of the risk assessment pursuant to point 2.1. and past significant incidents. The relevant entities shall determine:", + "Service": "iam" + } + ] + }, + { + "Id": "9.2.a", + "Description": "in accordance with the relevant entities classification of assets, the type, strength and quality of the cryptographic measures required to protect the relevant entities assets, including data at rest and data in transit;", + "Checks": [ + "apigateway_restapi_cache_encrypted", + "athena_workgroup_encryption", + "backup_recovery_point_encrypted", + "cloudtrail_kms_encryption_enabled", + "codebuild_project_s3_logs_encrypted", + "codebuild_report_group_export_encrypted", + "dynamodb_accelerator_cluster_encryption_enabled", + "dms_endpoint_redis_in_transit_encryption_enabled", + "dynamodb_accelerator_cluster_in_transit_encryption_enabled", + "elasticache_redis_cluster_in_transit_encryption_enabled", + "kafka_connector_in_transit_encryption_enabled", + "redshift_cluster_in_transit_encryption_enabled", + "transfer_server_in_transit_encryption_enabled" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c", + "Description": "the relevant entities approach to key management, including, where appropriate, methods for the following:", + "Checks": [ + "iam_rotate_access_key_90_days", + "secretsmanager_secret_rotated_periodically", + "kms_key_not_publicly_accessible", + "iam_no_root_access_key", + "iam_user_no_setup_initial_access_key", + "iam_user_two_active_access_key" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.i", + "Description": "generating different keys for cryptographic systems and applications;", + "Checks": [ + "acm_certificates_with_secure_key_algorithms" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "acm" + } + ] + }, + { + "Id": "9.2.c.ii", + "Description": "issuing and obtaining public key certificates;", + "Checks": [ + "acm_certificates_with_secure_key_algorithms", + "acm_certificates_transparency_logs_enabled", + "route53_domains_privacy_protection_enabled", + "iam_no_expired_server_certificates_stored" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.iii", + "Description": "distributing keys to intended entities, including how to activate keys when received;", + "Checks": [ + "iam_user_no_setup_initial_access_key" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "iam" + } + ] + }, + { + "Id": "9.2.c.iv", + "Description": "storing keys, including how authorised users obtain access to keys;", + "Checks": [ + "secretsmanager_automatic_rotation_enabled", + "secretsmanager_not_publicly_accessible", + "secretsmanager_secret_rotated_periodically", + "secretsmanager_secret_unused" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "secretsmanager" + } + ] + }, + { + "Id": "9.2.c.v", + "Description": "changing or updating keys, including rules on when and how to change keys;", + "Checks": [ + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_password_policy_lowercase", + "iam_password_policy_minimum_length_14", + "iam_password_policy_number", + "iam_password_policy_reuse_24", + "iam_password_policy_symbol", + "iam_password_policy_uppercase" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "iam" + } + ] + }, + { + "Id": "9.2.c.vi", + "Description": "backing up or archiving keys;", + "Checks": [ + "backup_vaults_encrypted" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "backup" + } + ] + }, + { + "Id": "9.2.c.vii", + "Description": "logging and auditing of key management-related activities;", + "Checks": [ + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_sign_in_without_mfa" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "9.2.c.xii", + "Description": "setting activation and deactivation dates for keys ensuring that the keys can only be used for the specified period of time according to the organization's rules on key management.", + "Checks": [ + "iam_rotate_access_key_90_days" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "iam" + } + ] + }, + { + "Id": "11.1.1", + "Description": "For the purpose of Article 21(2), point (i) of Directive (EU) 2022/2555, the relevant entities shall establish, document and implement logical and physical access control policies for the access to their network and information systems, based on business requirements as well as network and information system security requirements.", + "Checks": [ + "accessanalyzer_enabled", + "apigateway_restapi_client_certificate_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cognito_identity_pool_guest_access_disabled", + "ec2_ebs_snapshot_account_block_public_access", + "ec2_instance_profile_attached", + "ecs_task_definitions_containers_readonly_access", + "efs_access_point_enforce_root_directory", + "efs_access_point_enforce_user_identity", + "efs_not_publicly_accessible" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.1 Access control policy", + "Service": "generic" + } + ] + }, + { + "Id": "11.1.2.c", + "Description": "ensure that access is only granted to users that have been adequately authenticated.", + "Checks": [ + "apigatewayv2_api_access_logging_enabled", + "cognito_identity_pool_guest_access_disabled", + "iam_user_mfa_enabled_console_access", + "iam_administrator_access_with_mfa" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.1 Access control policy", + "Service": "generic" + } + ] + }, + { + "Id": "11.2.1", + "Description": "The relevant entities shall provide, modify, remove and document access rights to network and information systems in accordance with the access control policy referred to in point 11.1.", + "Checks": [ + "accessanalyzer_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "accessanalyzer" + } + ] + }, + { + "Id": "11.2.2.a", + "Description": "assign and revoke access rights based on the principles of need-to-know, least privilege and separation of duties;", + "Checks": [ + "iam_policy_attached_only_to_group_or_roles", + "iam_policy_allows_privilege_escalation", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "iam" + } + ] + }, + { + "Id": "11.2.2.d", + "Description": "ensure that access rights appropriately address third-party access, such as visitors, suppliers and service providers, in particular by limiting access rights in scope and in duration;", + "Checks": [ + "apigateway_restapi_client_certificate_enabled", + "cognito_identity_pool_guest_access_disabled", + "ec2_ebs_snapshot_account_block_public_access", + "ec2_instance_profile_attached", + "ecs_task_definitions_containers_readonly_access", + "efs_access_point_enforce_root_directory" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "generic" + } + ] + }, + { + "Id": "11.2.2.e", + "Description": "maintain a register of access rights granted;", + "Checks": [ + "accessanalyzer_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "accessanalyzer" + } + ] + }, + { + "Id": "11.2.2.f", + "Description": "apply logging to the management of access rights.", + "Checks": [ + "apigatewayv2_api_access_logging_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "s3_bucket_server_access_logging_enabled", + "wafv2_webacl_logging_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "generic" + } + ] + }, + { + "Id": "11.3.1", + "Description": "The relevant entities shall maintain policies for management of privileged accounts and system administration accounts as part of the access control policy referred to in point 11.1.", + "Checks": [ + "iam_role_administratoraccess_policy" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.3.2.a", + "Description": "establish strong identification, authentication such as multi-factor authentication, and authorisation procedures for privileged accounts and system administration accounts;", + "Checks": [ + "iam_user_mfa_enabled_console_access", + "iam_root_mfa_enabled", + "iam_administrator_access_with_mfa" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.3.2.b", + "Description": "set up specific accounts to be used for system administration operations exclusively, such as installation, configuration, management or maintenance;", + "Checks": [ + "iam_avoid_root_usage" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.3.2.c", + "Description": "individualise and restrict system administration privileges to the highest extent possible,", + "Checks": [ + "iam_avoid_root_usage", + "iam_inline_policy_no_administrative_privileges" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.3.2.d", + "Description": "provide that system administration accounts are only used to connect to system administration systems.", + "Checks": [ + "iam_user_console_access_unused" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.4.2.a", + "Description": "only use system administration systems for system administration purposes, and not for any other operations;", + "Checks": [ + "iam_avoid_root_usage" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "iam" + } + ] + }, + { + "Id": "11.4.2.b", + "Description": "separate logically such systems from application software not used for system administrative purposes,", + "Checks": [ + "iam_inline_policy_no_administrative_privileges" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "iam" + } + ] + }, + { + "Id": "11.4.2.c", + "Description": "protect access to system administration systems through authentication and encryption.", + "Checks": [ + "dms_endpoint_mongodb_authentication_enabled", + "iam_user_mfa_enabled_console_access", + "kafka_cluster_mutual_tls_authentication_enabled", + "neptune_cluster_iam_authentication_enabled", + "opensearch_service_domains_use_cognito_authentication_for_kibana", + "rds_cluster_iam_authentication_enabled", + "rds_instance_iam_authentication_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "generic" + } + ] + }, + { + "Id": "11.5.2.a", + "Description": "set up unique identities for network and information systems and their users;", + "Checks": [ + "fsx_file_system_copy_tags_to_backups_enabled", + "fsx_file_system_copy_tags_to_volumes_enabled", + "neptune_cluster_copy_tags_to_snapshots", + "organizations_tags_policies_enabled_and_attached", + "rds_cluster_copy_tags_to_snapshots", + "rds_instance_copy_tags_to_snapshots" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.5 Identification", + "Service": "generic" + } + ] + }, + { + "Id": "11.5.2.d", + "Description": "apply logging to the management of identities.", + "Checks": [ + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.5 Identification", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "11.5.4", + "Description": "The relevant entities shall regularly review the identities for network and information systems and their users and, if no longer needed, deactivate them without delay.", + "Checks": [ + "iam_user_accesskey_unused", + "iam_user_console_access_unused" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.5 Identification", + "Service": "iam" + } + ] + }, + { + "Id": "11.6.1", + "Description": "The relevant entities shall implement secure authentication procedures and technologies based on access restrictions and the policy on access control.", + "Checks": [ + "cognito_user_pool_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_root_mfa_enabled", + "iam_user_mfa_enabled_console_access" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.6.2.a", + "Description": "ensure the strength of authentication is appropriate to the classification of the asset to be accessed;", + "Checks": [ + "cognito_user_pool_password_policy_lowercase", + "cognito_user_pool_password_policy_minimum_length_14", + "cognito_user_pool_password_policy_number", + "cognito_user_pool_password_policy_symbol", + "cognito_user_pool_password_policy_uppercase", + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_password_policy_lowercase" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.6.2.c", + "Description": "require the change of authentication credentials initially, at predefined intervals and upon suspicion that the credentials were compromised;", + "Checks": [ + "secretsmanager_secret_rotated_periodically", + "iam_rotate_access_key_90_days", + "kms_cmk_rotation_enabled", + "secretsmanager_automatic_rotation_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.6.2.d", + "Description": "require the reset of authentication credentials and the blocking of users after a predefined number of unsuccessful log-in attempts;", + "Checks": [ + "cognito_user_pool_blocks_compromised_credentials_sign_in_attempts", + "cognito_user_pool_blocks_potential_malicious_sign_in_attempts" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "cognito" + } + ] + }, + { + "Id": "11.6.2.e", + "Description": "terminate inactive sessions after a predefined period of inactivity; and", + "Checks": [ + "appstream_fleet_session_idle_disconnect_timeout", + "appstream_fleet_maximum_session_duration", + "appstream_fleet_session_disconnect_timeout" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "appstream" + } + ] + }, + { + "Id": "11.7.2", + "Description": "The relevant entities shall ensure that the strength of authentication is appropriate for the classification of the asset to be accessed.", + "Checks": [ + "cloudtrail_bucket_requires_mfa_delete", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cognito_user_pool_mfa_enabled", + "directoryservice_supported_mfa_radius_enabled", + "iam_administrator_access_with_mfa", + "iam_root_hardware_mfa_enabled", + "iam_root_mfa_enabled", + "iam_user_hardware_mfa_enabled", + "iam_user_mfa_enabled_console_access", + "s3_bucket_no_mfa_delete" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.7 Multi-factor authentication", + "Service": "generic" + } + ] + }, + { + "Id": "12.1.2.c", + "Description": "align the availability requirements of the assets with the delivery and recovery objectives set out in their business continuity and disaster recovery plans.", + "Checks": [ + "backup_vaults_exist", + "backup_plans_exist", + "dynamodb_table_protected_by_backup_plan", + "ec2_ebs_volume_protected_by_backup_plan", + "eks_control_plane_logging_all_types_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_protected_by_backup_plan", + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.1 Asset classification", + "Service": "generic" + } + ] + }, + { + "Id": "12.2.2.a", + "Description": "cover the entire life cycle of the assets, including acquisition, use, storage, transportation and disposal;", + "Checks": [ + "dlm_ebs_snapshot_lifecycle_policy_exists", + "ecr_repositories_lifecycle_policy_enabled", + "s3_bucket_lifecycle_enabled" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + }, + { + "Id": "12.2.2.b", + "Description": "provide rules on the safe use, safe storage, safe transport, and the irretrievable deletion and destruction of the assets;", + "Checks": [ + "backup_vaults_exist", + "backup_plans_exist", + "dynamodb_table_protected_by_backup_plan", + "ec2_ebs_volume_protected_by_backup_plan", + "eks_control_plane_logging_all_types_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_protected_by_backup_plan", + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + }, + { + "Id": "12.2.2.c", + "Description": "provide that the transfer shall take place in a secure manner, in accordance with the type of asset to be transferred.", + "Checks": [ + "transfer_server_in_transit_encryption_enabled" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "transfer" + } + ] + } + ] +} diff --git a/prowler/compliance/azure/nis2_azure.json b/prowler/compliance/azure/nis2_azure.json new file mode 100644 index 0000000000..d3a350bfc7 --- /dev/null +++ b/prowler/compliance/azure/nis2_azure.json @@ -0,0 +1,1898 @@ +{ + "Framework": "NIS2", + "Version": "", + "Provider": "Azure", + "Description": "ANNEX to the Commission Implementing Regulation laying down rules for the application of Directive (EU) 2022/2555 as regards technical and methodological requirements of cybersecurity risk-management measures and further specification of the cases in which an incident is considered to be significant with regard to DNS service providers, TLD name registries, cloud computing service providers, data centre service providers, content delivery network providers, managed service providers, managed security service providers, providers of online market places, of online search engines and of social networking services platforms, and trust service providers", + "Requirements": [ + { + "Id": "1.1.1.a", + "Description": "set out the relevant entities approach to managing the security of their network and information systems;", + "Checks": [ + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "generic" + } + ] + }, + { + "Id": "1.1.1.c", + "Description": "set out network and information security objectives;", + "Checks": [ + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "generic" + } + ] + }, + { + "Id": "1.2.1", + "Description": "As part of their policy on the security of network and information systems referred to in point 1.1., the relevant entities shall lay down responsibilities and authorities for network and information system security and assign them to roles, allocate them according to the relevant entities needs, and communicate them to the management bodies.", + "Checks": [ + "aks_network_policy_enabled", + "network_http_internet_access_restricted", + "storage_default_network_access_rule_is_denied" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "generic" + } + ] + }, + { + "Id": "1.2.4", + "Description": "Depending on the size of the relevant entities, network and information system security shall be covered by dedicated roles or duties carried out in addition to existing roles.", + "Checks": [ + "sqlserver_azuread_administrator_enabled", + "sqlserver_va_emails_notifications_admins_enabled" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "sqlserver" + } + ] + }, + { + "Id": "2.1.2.f", + "Description": "evaluate the identified risks based on the risk criteria;", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_rbac_key_expiration_set" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "keyvault" + } + ] + }, + { + "Id": "2.1.2.g", + "Description": "identify and prioritise appropriate risk treatment options and measures;", + "Checks": [ + "aisearch_service_not_publicly_accessible", + "aks_cluster_rbac_enabled", + "aks_clusters_created_with_private_nodes", + "aks_clusters_public_access_disabled", + "aks_network_policy_enabled", + "app_ensure_auth_is_set_up", + "app_ensure_http_is_redirected_to_https", + "app_function_access_keys_configured", + "app_function_application_insights_enabled", + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_function_latest_runtime_version", + "app_function_not_publicly_accessible", + "app_function_vnet_integration_enabled", + "app_http_logs_enabled", + "app_minimum_tls_version_12", + "app_register_with_identity", + "containerregistry_admin_user_disabled", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_firewall_use_selected_networks", + "cosmosdb_account_use_aad_and_rbac", + "cosmosdb_account_use_private_endpoints" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.h", + "Description": "continuously monitor the implementation of the risk treatment measures;", + "Checks": [ + "app_http_logs_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "keyvault_logging_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private", + "mysql_flexible_server_audit_log_connection_activated", + "mysql_flexible_server_audit_log_enabled", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "postgresql_flexible_server_log_retention_days_greater_3" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.i", + "Description": "identify who is responsible for implementing the risk treatment measures and when they should be implemented;", + "Checks": [ + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_register_with_identity" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "app" + } + ] + }, + { + "Id": "2.2.1", + "Description": "The relevant entities shall regularly review the compliance with their policies on network and information system security, topic-specific policies, rules, and standards. The management bodies shall be informed of the status of network and information security on the basis of the compliance reviews by means of regular reporting.", + "Checks": [ + "sqlserver_va_scan_reports_configured", + "defender_container_images_scan_enabled", + "sqlserver_va_periodic_recurring_scans_enabled" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.2 Compliance monitoring", + "Service": "generic" + } + ] + }, + { + "Id": "2.2.3", + "Description": "The relevant entities shall perform the compliance monitoring at planned intervals and when significant incidents or significant changes to operations or risks occur.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.2 Compliance monitoring", + "Service": "monitor" + } + ] + }, + { + "Id": "2.3.1", + "Description": "The relevant entities shall review independently their approach to managing network and information system security and its implementation including people, processes and technologies.", + "Checks": [ + "entra_global_admin_in_less_than_five_users", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "entra_privileged_user_has_mfa", + "entra_security_defaults_enabled", + "entra_trusted_named_locations_exists", + "entra_user_with_vm_access_has_mfa", + "entra_users_cannot_create_microsoft_365_groups", + "aks_network_policy_enabled", + "network_bastion_host_exists", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "network_public_ip_shodan", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_watcher_enabled", + "storage_default_network_access_rule_is_denied" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.3 Independent review of information and network security", + "Service": "generic" + } + ] + }, + { + "Id": "3.1.1", + "Description": "For the purpose of Article 21(2), point (b) of Directive (EU) 2022/2555, the relevant entities shall establish and implement an incident handling policy laying down the roles, responsibilities, and procedures for detecting, analysing, containing or responding to, recovering from, documenting and reporting of incidents in a timely manner.", + "Checks": [ + "sqlserver_va_scan_reports_configured" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "sqlserver" + } + ] + }, + { + "Id": "3.1.2.c", + "Description": "assignment of roles to detect and appropriately respond to incidents to competent employees;", + "Checks": [ + "defender_ensure_notify_emails_to_owners", + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_alerts_severity_is_high" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "defender" + } + ] + }, + { + "Id": "3.1.2.d", + "Description": "documents to be used in the course of incident detection and response such as incident response manuals, escalation charts, contact lists and templates.", + "Checks": [ + "defender_auto_provisioning_log_analytics_agent_vms_on", + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "defender" + } + ] + }, + { + "Id": "3.1.3", + "Description": "The roles, responsibilities and procedures laid down in the policy shall be tested and reviewed and, where appropriate, updated at planned intervals and after significant incidents or significant changes to operations or risks.", + "Checks": [ + "defender_ensure_notify_emails_to_owners", + "defender_ensure_notify_alerts_severity_is_high", + "entra_global_admin_in_less_than_five_users", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "entra_privileged_user_has_mfa", + "entra_security_defaults_enabled", + "entra_trusted_named_locations_exists", + "entra_user_with_vm_access_has_mfa", + "entra_users_cannot_create_microsoft_365_groups" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.1", + "Description": "The relevant entities shall lay down procedures and use tools to monitor and log activities on their network and information systems to detect events that could be considered as incidents and respond accordingly to mitigate the impact.", + "Checks": [ + "app_http_logs_enabled", + "keyvault_logging_enabled", + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.2", + "Description": "To the extent feasible, monitoring shall be automated and carried out either continuously or in periodic intervals, subject to business capabilities. The relevant entities shall implement their monitoring activities in a way which minimises false positives and false negatives.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "monitor" + } + ] + }, + { + "Id": "3.2.3.a", + "Description": "relevant outbound and inbound network traffic;", + "Checks": [ + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.b", + "Description": "creation, modification or deletion of users of the relevant entities network and information systems and extension of the permissions;", + "Checks": [ + "entra_global_admin_in_less_than_five_users", + "monitor_alert_create_policy_assignment", + "monitor_alert_delete_policy_assignment", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private", + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.c", + "Description": "access to systems and applications;", + "Checks": [ + "app_http_logs_enabled", + "keyvault_logging_enabled", + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "mysql_flexible_server_audit_log_connection_activated", + "mysql_flexible_server_audit_log_enabled", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.d", + "Description": "authentication-related events;", + "Checks": [ + "network_flow_log_captured_sent", + "mysql_flexible_server_audit_log_connection_activated", + "keyvault_logging_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.e", + "Description": "all privileged access to systems and applications, and activities performed by administrative accounts;", + "Checks": [ + "app_http_logs_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "keyvault_logging_enabled", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.f", + "Description": "access or changes to critical configuration and backup files;", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "monitor" + } + ] + }, + { + "Id": "3.2.3.g", + "Description": "event logs and logs from security tools, such as antivirus, intrusion detection systems or firewalls;", + "Checks": [ + "app_http_logs_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "keyvault_logging_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private", + "mysql_flexible_server_audit_log_connection_activated", + "mysql_flexible_server_audit_log_enabled", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "postgresql_flexible_server_log_retention_days_greater_3" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.h", + "Description": "use of system resources, as well as their performance;", + "Checks": [ + "appinsights_ensure_is_configured", + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.4", + "Description": "The logs shall be regularly reviewed for any unusual or unwanted trends. Where appropriate, the relevant entities shall lay down appropriate values for alarm thresholds. If the laid down values for alarm threshold are exceeded, an alarm shall be triggered, where appropriate, automatically. The relevant entities shall ensure that, in case of an alarm, a qualified and appropriate response is initiated in a timely manner.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "monitor" + } + ] + }, + { + "Id": "3.2.5", + "Description": "The relevant entities shall maintain and back up logs for a predefined period and shall protect them from unauthorised access or changes.", + "Checks": [ + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_retention_days_greater_3", + "sqlserver_auditing_retention_90_days" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.4.1", + "Description": "The relevant entities shall assess suspicious events to determine whether they constitute incidents and, if so, determine their nature and severity.", + "Checks": [ + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.4 Event assessment and classification", + "Service": "monitor" + } + ] + }, + { + "Id": "3.5.1", + "Description": "The relevant entities shall respond to incidents in accordance with documented procedures and in a timely manner.", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "sqlserver_va_emails_notifications_admins_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "generic" + } + ] + }, + { + "Id": "3.5.3.a", + "Description": "with the Computer Security Incident Response Teams (CSIRTs) or, where applicable, the competent authorities, related to incident notification;", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "sqlserver_va_emails_notifications_admins_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "generic" + } + ] + }, + { + "Id": "3.5.4", + "Description": "The relevant entities shall log incident response activities in accordance with the procedures referred to in point 3.2.1., and record evidence.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "monitor" + } + ] + }, + { + "Id": "3.6.2", + "Description": "The relevant entities shall ensure that post-incident reviews contribute to improving their approach to network and information security, to risk treatment measures, and to incident handling, detection and response procedures.", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_assessments_vm_endpoint_protection_installed", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_cosmosdb_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_os_relational_databases_is_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_iot_hub_defender_is_on", + "defender_ensure_mcas_is_enabled", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_ensure_system_updates_are_applied", + "defender_ensure_wdatp_is_enabled", + "sqlserver_microsoft_defender_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.6 Post-incident reviews", + "Service": "defender" + } + ] + }, + { + "Id": "4.1.1", + "Description": "For the purpose of Article 21(2), point (c) of Directive (EU) 2022/2555, the relevant entities shall lay down and maintain a business continuity and disaster recovery plan to apply in the case of incidents.", + "Checks": [ + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "keyvault" + } + ] + }, + { + "Id": "4.1.2.f", + "Description": "recovery plans for specific operations, including recovery objectives;", + "Checks": [ + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "keyvault" + } + ] + }, + { + "Id": "4.1.2.g", + "Description": "required resources, including backups and redundancies;", + "Checks": [ + "keyvault_recoverable", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_retention_days_greater_3", + "sqlserver_auditing_retention_90_days" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "generic" + } + ] + }, + { + "Id": "4.1.4", + "Description": "The business continuity plan and disaster recovery plan shall be tested, reviewed and, where appropriate, updated at planned intervals and following significant incidents or significant changes to operations or risks. The relevant entities shall ensure that the plans incorporate lessons learnt from such tests.", + "Checks": [ + "keyvault_recoverable", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_retention_days_greater_3", + "sqlserver_auditing_retention_90_days" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "generic" + } + ] + }, + { + "Id": "4.2.2.b", + "Description": "assurance that backup copies are complete and accurate, including configuration data and data stored in cloud computing service environment;", + "Checks": [ + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "keyvault" + } + ] + }, + { + "Id": "4.2.2.e", + "Description": "restoring data from backup copies;", + "Checks": [ + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "generic" + } + ] + }, + { + "Id": "4.2.2.f", + "Description": "retention periods based on business and regulatory requirements.", + "Checks": [ + "sqlserver_auditing_retention_90_days", + "storage_ensure_soft_delete_is_enabled", + "network_flow_log_more_than_90_days" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "generic" + } + ] + }, + { + "Id": "4.3.2.a", + "Description": "roles and responsibilities for personnel and, where appropriate, suppliers and service providers, specifying the allocation of roles in crisis situations, including specific steps to follow;", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "sqlserver_va_emails_notifications_admins_enabled" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.3 Crisis management", + "Service": "generic" + } + ] + }, + { + "Id": "4.3.2.c", + "Description": "application of appropriate measures to ensure the maintenance of network and information system security in crisis situations.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.3 Crisis management", + "Service": "monitor" + } + ] + }, + { + "Id": "5.1.2.a", + "Description": "the cybersecurity practices of the suppliers and service providers, including their secure development procedures;", + "Checks": [ + "aks_network_policy_enabled", + "app_client_certificates_on", + "app_ensure_auth_is_set_up", + "app_ensure_http_is_redirected_to_https", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_ensure_using_http20", + "app_ftp_deployment_disabled", + "app_function_access_keys_configured", + "app_function_application_insights_enabled", + "app_function_ftps_deployment_disabled", + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_function_latest_runtime_version", + "app_function_not_publicly_accessible", + "app_function_vnet_integration_enabled", + "app_http_logs_enabled", + "app_minimum_tls_version_12", + "app_register_with_identity", + "appinsights_ensure_is_configured" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "app" + } + ] + }, + { + "Id": "5.1.4.f", + "Description": "an obligation on suppliers and service providers to handle vulnerabilities that present a risk to the security of the network and information systems of the relevant entities;", + "Checks": [ + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "sqlserver_vulnerability_assessment_enabled" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "5.1.7.b", + "Description": "review incidents related to ICT products and ICT services from suppliers and service providers;", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "sqlserver_va_emails_notifications_admins_enabled" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "6.1.2.b", + "Description": "requirements regarding security updates throughout the entire lifetime of the ICT services or ICT products, or replacement after the end of the support period;", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_non_rbac_secret_expiration_set", + "postgresql_flexible_server_log_retention_days_greater_3", + "network_flow_log_more_than_90_days", + "sqlserver_auditing_retention_90_days" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.1 Security in acquisition of ICT services or ICT products", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.1", + "Description": "Before developing a network and information system, including software, the relevant entities shall lay down rules for the secure development of network and information systems and apply them when developing network and information systems in-house, or when outsourcing the development of network and information systems. The rules shall cover all development phases, including specification, design, development, implementation and testing.", + "Checks": [ + "defender_ensure_system_updates_are_applied", + "app_client_certificates_on", + "app_ensure_auth_is_set_up", + "app_ensure_http_is_redirected_to_https", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_ensure_using_http20", + "app_ftp_deployment_disabled", + "app_function_access_keys_configured", + "app_function_application_insights_enabled", + "app_function_ftps_deployment_disabled", + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_function_latest_runtime_version", + "app_function_not_publicly_accessible", + "app_function_vnet_integration_enabled", + "app_http_logs_enabled", + "app_minimum_tls_version_12", + "app_register_with_identity" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "app" + } + ] + }, + { + "Id": "6.2.2.b", + "Description": "apply principles for engineering secure systems and secure coding principles to any information system development activities such as promoting cybersecurity-by-design, zero-trust architectures;", + "Checks": [ + "app_client_certificates_on", + "app_ensure_auth_is_set_up", + "app_ensure_http_is_redirected_to_https", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_ensure_using_http20", + "app_ftp_deployment_disabled", + "app_function_access_keys_configured", + "app_function_application_insights_enabled", + "app_function_ftps_deployment_disabled", + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_function_latest_runtime_version", + "app_function_not_publicly_accessible", + "app_function_vnet_integration_enabled", + "app_http_logs_enabled", + "app_minimum_tls_version_12", + "app_register_with_identity" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "app" + } + ] + }, + { + "Id": "6.2.2.c", + "Description": "lay down security requirements regarding development environments;", + "Checks": [ + "app_client_certificates_on", + "app_ensure_auth_is_set_up", + "app_ensure_http_is_redirected_to_https", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_ensure_using_http20", + "app_ftp_deployment_disabled", + "app_function_access_keys_configured", + "app_function_application_insights_enabled", + "app_function_ftps_deployment_disabled", + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_function_latest_runtime_version", + "app_function_not_publicly_accessible", + "app_function_vnet_integration_enabled", + "app_http_logs_enabled", + "app_minimum_tls_version_12", + "app_register_with_identity" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "app" + } + ] + }, + { + "Id": "6.4.1", + "Description": "The relevant entities shall apply change management procedures to control changes of network and information systems. Where applicable, the procedures shall be consistent with the relevant entities general policies concerning change management.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.4 Change management, repairs and maintenance", + "Service": "monitor" + } + ] + }, + { + "Id": "6.6.1.a", + "Description": "security patches are applied within a reasonable time after they become available;", + "Checks": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "sqlserver_vulnerability_assessment_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.6 Security patch management", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.b", + "Description": "determine and apply controls to protect the relevant entities internal network domains from unauthorised access;", + "Checks": [ + "aisearch_service_not_publicly_accessible", + "aks_network_policy_enabled", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_firewall_use_selected_networks", + "network_bastion_host_exists", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "network_public_ip_shodan", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_watcher_enabled", + "storage_default_network_access_rule_is_denied" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.e", + "Description": "not use systems used for administration of the security policy implementation for other purposes;", + "Checks": [ + "app_function_identity_without_admin_privileges", + "containerregistry_admin_user_disabled", + "entra_global_admin_in_less_than_five_users", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "sqlserver_azuread_administrator_enabled", + "sqlserver_va_emails_notifications_admins_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.g", + "Description": "where appropriate, exclusively allow access to the relevant entities network and information systems by devices authorised by those entities;", + "Checks": [ + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "app_function_ftps_deployment_disabled", + "app_ensure_auth_is_set_up", + "app_function_identity_is_configured" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.i", + "Description": "establish communication between distinct systems only through trusted channels that are isolated using logical, cryptographic or physical separation from other communication channels and provide assured identification of their end points and protection of the channel data from modification or disclosure;", + "Checks": [ + "storage_ensure_private_endpoints_in_storage_accounts", + "storage_secure_transfer_required_is_enabled", + "app_minimum_tls_version_12", + "mysql_flexible_server_minimum_tls_version_12", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "app_ensure_http_is_redirected_to_https", + "defender_ensure_defender_for_dns_is_on", + "sqlserver_tde_encryption_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.l", + "Description": "apply best practices for the security of the DNS, and for Internet routing security and routing hygiene of traffic originating from and destined to the network.", + "Checks": [ + "storage_ensure_private_endpoints_in_storage_accounts", + "storage_secure_transfer_required_is_enabled", + "app_minimum_tls_version_12", + "mysql_flexible_server_minimum_tls_version_12", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "app_ensure_http_is_redirected_to_https", + "defender_ensure_defender_for_dns_is_on", + "sqlserver_tde_encryption_enabled", + "network_bastion_host_exists", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "network_public_ip_shodan", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_watcher_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.8.2.a", + "Description": "consider the functional, logical and physical relationship, including location, between trustworthy systems and services;", + "Checks": [ + "entra_trusted_named_locations_exists", + "network_watcher_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.8 Network segmentation", + "Service": "generic" + } + ] + }, + { + "Id": "6.9.2", + "Description": "For that purpose, the relevant entities shall in particular implement measures that detect or prevent the use of malicious or unauthorised software. The relevant entities shall, where appropriate, ensure that their network and information systems are equipped with detection and response software, which is updated regularly in accordance with the risk assessment carried out pursuant to point 2.1 and the contractual agreements with the providers.", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_assessments_vm_endpoint_protection_installed", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_cosmosdb_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_os_relational_databases_is_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_iot_hub_defender_is_on", + "defender_ensure_mcas_is_enabled", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_ensure_system_updates_are_applied", + "defender_ensure_wdatp_is_enabled", + "sqlserver_microsoft_defender_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.9 Protection against malicious and unauthorised software", + "Service": "generic" + } + ] + }, + { + "Id": "7.2.b", + "Description": "the methods for monitoring, measurement, analysis and evaluation, as applicable, to ensure valid results;", + "Checks": [ + "defender_ensure_notify_alerts_severity_is_high", + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "7 POLICIES AND PROCEDURES TO ASSESS THE EFFECTIVENESS OF CYBERSECURITY RISK-MANAGEMENT MEASURES (ARTICLE 21(2), POINT (F), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "7.2 The policy and procedures referred to in point 7.1. shall take into account results of the risk assessment pursuant to point 2.1. and past significant incidents. The relevant entities shall determine:", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.a", + "Description": "in accordance with the relevant entities classification of assets, the type, strength and quality of the cryptographic measures required to protect the relevant entities assets, including data at rest and data in transit;", + "Checks": [ + "app_minimum_tls_version_12", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "storage_secure_transfer_required_is_enabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c", + "Description": "the relevant entities approach to key management, including, where appropriate, methods for the following:", + "Checks": [ + "defender_ensure_defender_for_keyvault_is_on", + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_key_rotation_enabled", + "keyvault_logging_enabled", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_private_endpoints", + "keyvault_rbac_enabled", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "keyvault_recoverable", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "sqlserver_tde_encrypted_with_cmk", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.i", + "Description": "generating different keys for cryptographic systems and applications;", + "Checks": [ + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "sqlserver_tde_encrypted_with_cmk", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.ii", + "Description": "issuing and obtaining public key certificates;", + "Checks": [ + "app_client_certificates_on" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "app" + } + ] + }, + { + "Id": "9.2.c.iii", + "Description": "distributing keys to intended entities, including how to activate keys when received;", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_rbac_key_expiration_set", + "storage_key_rotation_90_days" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.iv", + "Description": "storing keys, including how authorised users obtain access to keys;", + "Checks": [ + "app_function_access_keys_configured", + "defender_ensure_defender_for_keyvault_is_on", + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_key_rotation_enabled", + "keyvault_logging_enabled", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_private_endpoints", + "keyvault_rbac_enabled", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "keyvault_recoverable", + "sqlserver_tde_encrypted_with_cmk", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_key_rotation_90_days" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.v", + "Description": "changing or updating keys, including rules on when and how to change keys;", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_key_rotation_enabled", + "keyvault_logging_enabled", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_private_endpoints", + "keyvault_rbac_enabled", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "keyvault" + } + ] + }, + { + "Id": "9.2.c.vi", + "Description": "backing up or archiving keys;", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_recoverable", + "keyvault_rbac_key_expiration_set" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "keyvault" + } + ] + }, + { + "Id": "9.2.c.vii", + "Description": "logging and auditing of key management-related activities;", + "Checks": [ + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "keyvault_logging_enabled", + "sqlserver_auditing_enabled" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.xii", + "Description": "setting activation and deactivation dates for keys ensuring that the keys can only be used for the specified period of time according to the organization's rules on key management.", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "keyvault_non_rbac_secret_expiration_set" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "keyvault" + } + ] + }, + { + "Id": "11.1.1", + "Description": "For the purpose of Article 21(2), point (i) of Directive (EU) 2022/2555, the relevant entities shall establish, document and implement logical and physical access control policies for the access to their network and information systems, based on business requirements as well as network and information system security requirements.", + "Checks": [ + "app_ensure_auth_is_set_up", + "app_function_ftps_deployment_disabled", + "aisearch_service_not_publicly_accessible", + "aks_network_policy_enabled", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_firewall_use_selected_networks", + "network_bastion_host_exists", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "network_public_ip_shodan", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_watcher_enabled", + "storage_default_network_access_rule_is_denied" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.1 Access control policy", + "Service": "generic" + } + ] + }, + { + "Id": "11.1.2.c", + "Description": "ensure that access is only granted to users that have been adequately authenticated.", + "Checks": [ + "app_ensure_auth_is_set_up", + "app_function_ftps_deployment_disabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.1 Access control policy", + "Service": "app" + } + ] + }, + { + "Id": "11.2.1", + "Description": "The relevant entities shall provide, modify, remove and document access rights to network and information systems in accordance with the access control policy referred to in point 11.1.", + "Checks": [ + "monitor_storage_account_with_activity_logs_is_private", + "storage_default_network_access_rule_is_denied" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "generic" + } + ] + }, + { + "Id": "11.2.2.a", + "Description": "assign and revoke access rights based on the principles of need-to-know, least privilege and separation of duties;", + "Checks": [ + "entra_global_admin_in_less_than_five_users", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "entra_users_cannot_create_microsoft_365_groups" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "entra" + } + ] + }, + { + "Id": "11.2.2.d", + "Description": "ensure that access rights appropriately address third-party access, such as visitors, suppliers and service providers, in particular by limiting access rights in scope and in duration;", + "Checks": [ + "monitor_storage_account_with_activity_logs_is_private", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "entra_users_cannot_create_microsoft_365_groups", + "aks_clusters_created_with_private_nodes", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "keyvault_private_endpoints", + "monitor_storage_account_with_activity_logs_is_private", + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "generic" + } + ] + }, + { + "Id": "11.2.2.f", + "Description": "apply logging to the management of access rights.", + "Checks": [ + "app_http_logs_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "keyvault_logging_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private", + "mysql_flexible_server_audit_log_connection_activated", + "mysql_flexible_server_audit_log_enabled", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "postgresql_flexible_server_log_retention_days_greater_3" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "generic" + } + ] + }, + { + "Id": "11.3.1", + "Description": "The relevant entities shall maintain policies for management of privileged accounts and system administration accounts as part of the access control policy referred to in point 11.1.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "monitor" + } + ] + }, + { + "Id": "11.3.2.a", + "Description": "establish strong identification, authentication such as multi-factor authentication, and authorisation procedures for privileged accounts and system administration accounts;", + "Checks": [ + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "entra" + } + ] + }, + { + "Id": "11.3.2.b", + "Description": "set up specific accounts to be used for system administration operations exclusively, such as installation, configuration, management or maintenance;", + "Checks": [ + "entra_global_admin_in_less_than_five_users" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "entra" + } + ] + }, + { + "Id": "11.3.2.c", + "Description": "individualise and restrict system administration privileges to the highest extent possible,", + "Checks": [ + "entra_global_admin_in_less_than_five_users", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "entra_users_cannot_create_microsoft_365_groups" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "entra" + } + ] + }, + { + "Id": "11.3.2.d", + "Description": "provide that system administration accounts are only used to connect to system administration systems.", + "Checks": [ + "entra_global_admin_in_less_than_five_users", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "entra" + } + ] + }, + { + "Id": "11.4.2.b", + "Description": "separate logically such systems from application software not used for system administrative purposes,", + "Checks": [ + "entra_global_admin_in_less_than_five_users", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "entra" + } + ] + }, + { + "Id": "11.4.2.c", + "Description": "protect access to system administration systems through authentication and encryption.", + "Checks": [ + "entra_trusted_named_locations_exists", + "entra_user_with_vm_access_has_mfa", + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_privileged_user_has_mfa" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "entra" + } + ] + }, + { + "Id": "11.5.2.a", + "Description": "set up unique identities for network and information systems and their users;", + "Checks": [ + "app_function_identity_is_configured" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.5 Identification", + "Service": "app" + } + ] + }, + { + "Id": "11.5.2.d", + "Description": "apply logging to the management of identities.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.5 Identification", + "Service": "monitor" + } + ] + }, + { + "Id": "11.6.1", + "Description": "The relevant entities shall implement secure authentication procedures and technologies based on access restrictions and the policy on access control.", + "Checks": [ + "app_ensure_auth_is_set_up", + "app_function_ftps_deployment_disabled", + "aisearch_service_not_publicly_accessible", + "aks_network_policy_enabled", + "containerregistry_not_publicly_accessible" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.6.2.a", + "Description": "ensure the strength of authentication is appropriate to the classification of the asset to be accessed;", + "Checks": [ + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "entra" + } + ] + }, + { + "Id": "11.6.2.c", + "Description": "require the change of authentication credentials initially, at predefined intervals and upon suspicion that the credentials were compromised;", + "Checks": [ + "keyvault_key_rotation_enabled", + "storage_key_rotation_90_days" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.7.2", + "Description": "The relevant entities shall ensure that the strength of authentication is appropriate for the classification of the asset to be accessed.", + "Checks": [ + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.7 Multi-factor authentication", + "Service": "generic" + } + ] + }, + { + "Id": "12.1.2.c", + "Description": "align the availability requirements of the assets with the delivery and recovery objectives set out in their business continuity and disaster recovery plans.", + "Checks": [ + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.1 Asset classification", + "Service": "keyvault" + } + ] + }, + { + "Id": "12.2.2.a", + "Description": "cover the entire life cycle of the assets, including acquisition, use, storage, transportation and disposal;", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_retention_days_greater_3", + "sqlserver_auditing_retention_90_days", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied", + "storage_ensure_azure_services_are_trusted_to_access_is_enabled", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_ensure_minimum_tls_version_12", + "storage_ensure_private_endpoints_in_storage_accounts", + "storage_ensure_soft_delete_is_enabled", + "storage_infrastructure_encryption_is_enabled", + "storage_key_rotation_90_days", + "storage_secure_transfer_required_is_enabled" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + }, + { + "Id": "12.2.2.b", + "Description": "provide rules on the safe use, safe storage, safe transport, and the irretrievable deletion and destruction of the assets;", + "Checks": [ + "storage_ensure_soft_delete_is_enabled", + "keyvault_recoverable", + "storage_secure_transfer_required_is_enabled", + "app_minimum_tls_version_12", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + }, + { + "Id": "12.2.2.c", + "Description": "provide that the transfer shall take place in a secure manner, in accordance with the type of asset to be transferred.", + "Checks": [ + "storage_secure_transfer_required_is_enabled", + "app_minimum_tls_version_12", + "mysql_flexible_server_minimum_tls_version_12", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + } + ] +} diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index c4bcd40b8e..1385ffef4f 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -515,3 +515,8 @@ m365: # m365.exchange_mailbox_properties_auditing_enabled # Maximum number of days to keep audit logs audit_log_age: 90 + +# GitHub Configuration +github: + # github.repository_inactive_not_archived --> CIS recommends 180 days (6 months) + inactive_not_archived_days_threshold: 180 diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index 0ef3195b2a..9c8ee40eb6 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -329,9 +329,8 @@ class CheckMetadata(BaseModel): checks = set() if service: - # This is a special case for the AWS provider since `lambda` is a reserved keyword in Python - if service == "awslambda": - service = "lambda" + if service == "lambda": + service = "awslambda" checks = { check_name for check_name, check_metadata in bulk_checks_metadata.items() @@ -548,7 +547,7 @@ class CheckReportGithub(Check_Report): resource_name: str resource_id: str - repository: str + owner: str def __init__( self, @@ -556,7 +555,7 @@ class CheckReportGithub(Check_Report): resource: Any, resource_name: str = None, resource_id: str = None, - repository: str = "global", + owner: str = None, ) -> None: """Initialize the GitHub Check's finding information. @@ -565,12 +564,16 @@ class CheckReportGithub(Check_Report): resource: Basic information about the resource. Defaults to None. resource_name: The name of the resource related with the finding. resource_id: The id of the resource related with the finding. - repository: The repository of the resource related with the finding. + owner: The owner of the resource related with the finding. """ super().__init__(metadata, resource) self.resource_name = resource_name or getattr(resource, "name", "") self.resource_id = resource_id or getattr(resource, "id", "") - self.repository = repository or getattr(resource, "repository", "") + self.owner = ( + owner + or getattr(resource, "owner", "") # For Repositories + or getattr(resource, "name", "") # For Organizations + ) @dataclass diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index b7547da815..ffa0a2f44e 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -251,7 +251,7 @@ class Finding(BaseModel): output_data["resource_uid"] = check_output.resource_id output_data["account_name"] = provider.identity.account_name output_data["account_uid"] = provider.identity.account_id - output_data["region"] = check_output.repository + output_data["region"] = check_output.owner elif provider.type == "m365": output_data["auth_method"] = ( diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index adada2f513..cae67482e7 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -17,7 +17,7 @@ def stdout_report(finding, color, verbose, status, fix): if finding.check_metadata.Provider == "kubernetes": details = finding.namespace.lower() if finding.check_metadata.Provider == "github": - details = finding.repository + details = finding.owner if finding.check_metadata.Provider == "m365": details = finding.location if finding.check_metadata.Provider == "nhn": diff --git a/prowler/providers/aws/services/vpc/vpc_endpoint_for_ec2_enabled/vpc_endpoint_for_ec2_enabled.metadata.json b/prowler/providers/aws/services/vpc/vpc_endpoint_for_ec2_enabled/vpc_endpoint_for_ec2_enabled.metadata.json index 2a12db3c4e..3bd51b2656 100644 --- a/prowler/providers/aws/services/vpc/vpc_endpoint_for_ec2_enabled/vpc_endpoint_for_ec2_enabled.metadata.json +++ b/prowler/providers/aws/services/vpc/vpc_endpoint_for_ec2_enabled/vpc_endpoint_for_ec2_enabled.metadata.json @@ -3,7 +3,7 @@ "CheckID": "vpc_endpoint_for_ec2_enabled", "CheckTitle": "Amazon EC2 should be configured to use VPC endpoints that are created for the Amazon EC2 service.", "CheckType": [], - "ServiceName": "ec2", + "ServiceName": "vpc", "SubServiceName": "", "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", "Severity": "medium", diff --git a/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.py b/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.py index e942aabc6f..4c1fb89756 100644 --- a/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.py +++ b/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.py @@ -11,19 +11,20 @@ class app_function_access_keys_configured(Check): functions, ) in app_client.functions.items(): for function in functions.values(): - report = Check_Report_Azure(metadata=self.metadata(), resource=function) - report.subscription = subscription_name - report.status = "FAIL" - report.status_extended = ( - f"Function {function.name} does not have function keys configured." - ) - - if len(function.function_keys) > 0: - report.status = "PASS" - report.status_extended = ( - f"Function {function.name} has function keys configured." + if function.function_keys is not None: + report = Check_Report_Azure( + metadata=self.metadata(), resource=function ) + report.subscription = subscription_name + report.status = "FAIL" + report.status_extended = f"Function {function.name} does not have function keys configured." - findings.append(report) + if len(function.function_keys) > 0: + report.status = "PASS" + report.status_extended = ( + f"Function {function.name} has function keys configured." + ) + + findings.append(report) return findings diff --git a/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.py b/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.py index 06ce41a2b0..3c5102dbbb 100644 --- a/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.py +++ b/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.py @@ -14,26 +14,29 @@ class app_function_application_insights_enabled(Check): functions, ) in app_client.functions.items(): for function in functions.values(): - report = Check_Report_Azure(metadata=self.metadata(), resource=function) - report.subscription = subscription_name - report.status = "FAIL" - report.status_extended = ( - f"Function {function.name} is not using Application Insights." - ) - - if function.enviroment_variables.get( - "APPINSIGHTS_INSTRUMENTATIONKEY", "" - ) in [ - component.instrumentation_key - for component in appinsights_client.components[ - subscription_name - ].values() - ]: - report.status = "PASS" + if function.enviroment_variables is not None: + report = Check_Report_Azure( + metadata=self.metadata(), resource=function + ) + report.subscription = subscription_name + report.status = "FAIL" report.status_extended = ( - f"Function {function.name} is using Application Insights." + f"Function {function.name} is not using Application Insights." ) - findings.append(report) + if function.enviroment_variables.get( + "APPINSIGHTS_INSTRUMENTATIONKEY", "" + ) in [ + component.instrumentation_key + for component in appinsights_client.components[ + subscription_name + ].values() + ]: + report.status = "PASS" + report.status_extended = ( + f"Function {function.name} is using Application Insights." + ) + + findings.append(report) return findings diff --git a/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.py b/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.py index 180434ec21..3cd8d349b4 100644 --- a/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.py +++ b/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.py @@ -11,20 +11,25 @@ class app_function_latest_runtime_version(Check): functions, ) in app_client.functions.items(): for function in functions.values(): - report = Check_Report_Azure(metadata=self.metadata(), resource=function) - report.subscription = subscription_name - report.status = "PASS" - report.status_extended = ( - f"Function {function.name} is using the latest runtime." - ) + if function.enviroment_variables is not None: + report = Check_Report_Azure( + metadata=self.metadata(), resource=function + ) + report.subscription = subscription_name + report.status = "PASS" + report.status_extended = ( + f"Function {function.name} is using the latest runtime." + ) - if ( - function.enviroment_variables.get("FUNCTIONS_EXTENSION_VERSION", "") - != "~4" - ): - report.status = "FAIL" - report.status_extended = f"Function {function.name} is not using the latest runtime. The current runtime is '{function.enviroment_variables.get('FUNCTIONS_EXTENSION_VERSION', '')}' and should be '~4'." + if ( + function.enviroment_variables.get( + "FUNCTIONS_EXTENSION_VERSION", "" + ) + != "~4" + ): + report.status = "FAIL" + report.status_extended = f"Function {function.name} is not using the latest runtime. The current runtime is '{function.enviroment_variables.get('FUNCTIONS_EXTENSION_VERSION', '')}' and should be '~4'." - findings.append(report) + findings.append(report) return findings diff --git a/prowler/providers/azure/services/app/app_service.py b/prowler/providers/azure/services/app/app_service.py index fc2ee5cf13..584555e2ad 100644 --- a/prowler/providers/azure/services/app/app_service.py +++ b/prowler/providers/azure/services/app/app_service.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Dict, List +from typing import Dict, List, Optional from azure.mgmt.web import WebSiteManagementClient @@ -124,14 +124,16 @@ class App(AzureService): # Filter function apps if getattr(function, "kind", "").startswith("functionapp"): # List host keys - host_keys = client.web_apps.list_host_keys( - resource_group_name=function.resource_group, - name=function.name, - ) # Need to add role 'Logic App Contributor' to the service principal to get the host keys or add to the reader role the permission 'Microsoft.Web/sites/host/listkeys' + host_keys = self._get_function_host_keys( + subscription_name, function.resource_group, function.name + ) + if host_keys is not None: + function_keys = getattr(host_keys, "function_keys", {}) + else: + function_keys = None - function_config = client.web_apps.get_configuration( - resource_group_name=function.resource_group, - name=function.name, + function_config = self._get_function_config( + subscription_name, function.resource_group, function.name ) functions[subscription_name].update( @@ -141,16 +143,9 @@ class App(AzureService): name=function.name, location=function.location, kind=function.kind, - function_keys=getattr( - host_keys, "function_keys", {} - ), + function_keys=function_keys, enviroment_variables=getattr( - client.web_apps.list_application_settings( - resource_group_name=function.resource_group, - name=function.name, - ), - "properties", - {}, + function_config, "properties", None ), identity=getattr(function, "identity", None), public_access=( @@ -167,7 +162,7 @@ class App(AzureService): "", ), ftps_state=getattr( - function_config, "ftps_state", "" + function_config, "ftps_state", None ), ) } @@ -209,6 +204,30 @@ class App(AzureService): ) return monitor_diagnostics_settings + def _get_function_host_keys(self, subscription, resource_group, name): + try: + return self.clients[subscription].web_apps.list_host_keys( + resource_group_name=resource_group, + name=name, + ) + except Exception as error: + logger.error( + f"Error getting host keys for {name} in {resource_group}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + + def _get_function_config(self, subscription, resource_group, name): + try: + return self.clients[subscription].web_apps.list_application_settings( + resource_group_name=resource_group, + name=name, + ) + except Exception as error: + logger.error( + f"Error getting configuration for {name} in {resource_group}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + @dataclass class ManagedServiceIdentity: @@ -250,9 +269,9 @@ class FunctionApp: name: str location: str kind: str - function_keys: Dict[str, str] - enviroment_variables: Dict[str, str] + function_keys: Optional[Dict[str, str]] + enviroment_variables: Optional[Dict[str, str]] identity: ManagedServiceIdentity public_access: bool vnet_subnet_id: str - ftps_state: str + ftps_state: Optional[str] diff --git a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.metadata.json index 20fc3d837c..d8a4f1812b 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.metadata.json @@ -1,16 +1,16 @@ { "Provider": "azure", "CheckID": "defender_ensure_notify_alerts_severity_is_high", - "CheckTitle": "Ensure That 'Notify about alerts with the following severity' is Set to 'High'", + "CheckTitle": "Ensure that email notifications are configured for alerts with a minimum severity of 'High' or lower", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AzureEmailNotifications", - "Description": "Microsoft Defender for Cloud emails the subscription owners whenever a high-severity alert is triggered for their subscription. You should provide a security contact email address as an additional email address.", - "Risk": "Microsoft Defender for Cloud emails the Subscription Owner to notify them about security alerts. Adding your Security Contact's email address to the 'Additional email addresses' field ensures that your organization's Security Team is included in these alerts. This ensures that the proper people are aware of any potential compromise in order to mitigate the risk in a timely fashion.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details", + "Description": "Microsoft Defender for Cloud sends email notifications when alerts of a certain severity level or higher are triggered. By setting the minimum severity to 'High', 'Medium', or even 'Low', you ensure that alerts with equal or greater severity (e.g., High or Critical) are still delivered. Selecting a lower threshold like 'Low' results in more comprehensive alert coverage.", + "Risk": "If this setting is too restrictive (e.g., set to 'Critical' only), important security alerts with 'High' or 'Medium' severity might be missed. Ensuring that 'High' or a lower threshold is configured helps security teams stay informed about significant threats and respond in a timely manner.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/email-notifications-alerts#manage-notifications-on-email", "Remediation": { "Code": { "CLI": "", @@ -19,7 +19,7 @@ "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/bc_azr_general_4#terraform" }, "Recommendation": { - "Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Defender for Cloud 3. Click on Environment Settings 4. Click on the appropriate Management Group, Subscription, or Workspace 5. Click on Email notifications 6. Enter a valid security contact email address (or multiple addresses separated by commas) in the Additional email addresses field 7. Click Save", + "Text": "1. From Azure Home select the Portal Menu. 2. Select Microsoft Defender for Cloud. 3. Click on Environment Settings. 4. Click on the appropriate Management Group, Subscription, or Workspace. 5. Click on Email notifications. 6. Under 'Notify about alerts with the following severity (or higher)', select at least 'High' (or optionally 'Medium' or 'Low' for broader coverage). 7. Click Save.", "Url": "https://docs.microsoft.com/en-us/rest/api/securitycenter/securitycontacts/list" } }, diff --git a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py index b7154e99aa..34eaab9624 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py +++ b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py @@ -13,12 +13,15 @@ class defender_ensure_notify_alerts_severity_is_high(Check): for contact in security_contacts.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=contact) report.subscription = subscription_name - report.status = "PASS" - report.status_extended = f"Notifiy alerts are enabled for severity high in subscription {subscription_name}." + report.status = "FAIL" + report.status_extended = f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {subscription_name}." - if contact.alert_notifications_minimal_severity != "High": - report.status = "FAIL" - report.status_extended = f"Notifiy alerts are not enabled for severity high in subscription {subscription_name}." + if ( + contact.alert_notifications_minimal_severity != "Critical" + and contact.alert_notifications_minimal_severity != "" + ): + report.status = "PASS" + report.status_extended = f"Notifications are enabled for alerts with a minimum severity of high or lower ({contact.alert_notifications_minimal_severity}) in subscription {subscription_name}." findings.append(report) diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled.py b/prowler/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled.py index 5461cd45e4..f9c7237302 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled.py @@ -12,7 +12,7 @@ class sqlserver_auditing_enabled(Check): ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has a auditing policy configured." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has an auditing policy configured." for auditing_policy in sql_server.auditing_policies: if auditing_policy.state == "Disabled": report.status = "FAIL" diff --git a/prowler/providers/gcp/gcp_provider.py b/prowler/providers/gcp/gcp_provider.py index 789c434bb4..63c1c6d51d 100644 --- a/prowler/providers/gcp/gcp_provider.py +++ b/prowler/providers/gcp/gcp_provider.py @@ -515,9 +515,9 @@ class GcpProvider(Provider): credentials=session, ) - # Test the connection using the Service Usage API since it is enabled by default - client = discovery.build("serviceusage", "v1", credentials=session) - request = client.services().list(parent=f"projects/{project_id}") + # Test the connection using OAuth2 API to verify token validity + client = discovery.build("oauth2", "v2", credentials=session) + request = client.tokeninfo() request.execute() return Connection(is_connected=True) diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index d34b2b3e66..f217066929 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -4,6 +4,7 @@ from typing import Union from colorama import Fore, Style from github import Auth, Github, GithubIntegration +from github.GithubRetry import GithubRetry from prowler.config.config import ( default_config_file_path, @@ -300,9 +301,10 @@ class GithubProvider(Provider): credentials = self.session try: + retry_config = GithubRetry(total=3) if credentials.token: auth = Auth.Token(credentials.token) - g = Github(auth=auth) + g = Github(auth=auth, retry=retry_config) try: identity = GithubIdentityInfo( account_id=g.get_user().id, @@ -318,7 +320,7 @@ class GithubProvider(Provider): elif credentials.id != 0 and credentials.key: auth = Auth.AppAuth(credentials.id, credentials.key) - gi = GithubIntegration(auth=auth) + gi = GithubIntegration(auth=auth, retry=retry_config) try: identity = GithubAppIdentityInfo(app_id=gi.get_app().id) return identity diff --git a/prowler/providers/github/lib/service/service.py b/prowler/providers/github/lib/service/service.py index b9e4bf2974..911a22b975 100644 --- a/prowler/providers/github/lib/service/service.py +++ b/prowler/providers/github/lib/service/service.py @@ -1,4 +1,5 @@ from github import Auth, Github, GithubIntegration +from github.GithubRetry import GithubRetry from prowler.lib.logger import logger from prowler.providers.github.github_provider import GithubProvider @@ -20,16 +21,17 @@ class GithubService: def __set_clients__(self, session): clients = [] try: + retry_config = GithubRetry(total=3) if session.token: auth = Auth.Token(session.token) - clients = [Github(auth=auth)] + clients = [Github(auth=auth, retry=retry_config)] elif session.key and session.id: auth = Auth.AppAuth( session.id, session.key, ) - gi = GithubIntegration(auth=auth) + gi = GithubIntegration(auth=auth, retry=retry_config) for installation in gi.get_installations(): clients.append(installation.get_github_for_installation()) diff --git a/prowler/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled.py b/prowler/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled.py index 5b561927c3..145e4261e1 100644 --- a/prowler/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled.py +++ b/prowler/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled.py @@ -22,19 +22,13 @@ class repository_branch_delete_on_merge_enabled(Check): """ findings = [] for repo in repository_client.repositories.values(): - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" - report.status_extended = ( - f"Repository {repo.name} does not delete branches on merge." - ) + report.status_extended = f"Repository {repo.name} does not delete branches on merge in default branch ({repo.default_branch.name})." if repo.delete_branch_on_merge: report.status = "PASS" - report.status_extended = ( - f"Repository {repo.name} does delete branches on merge." - ) + report.status_extended = f"Repository {repo.name} does delete branches on merge in default branch ({repo.default_branch.name})." findings.append(report) diff --git a/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py b/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py index 12d504a926..bf615a21b6 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py +++ b/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py @@ -22,16 +22,14 @@ class repository_default_branch_deletion_disabled(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.default_branch_deletion is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.branch_deletion is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = ( f"Repository {repo.name} does allow default branch deletion." ) - if not repo.default_branch_deletion: + if not repo.default_branch.branch_deletion: report.status = "PASS" report.status_extended = ( f"Repository {repo.name} does deny default branch deletion." diff --git a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py index 2183b84a79..16eb7fa794 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py +++ b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py @@ -22,20 +22,14 @@ class repository_default_branch_disallows_force_push(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.allow_force_pushes is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.allow_force_pushes is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" - report.status_extended = ( - f"Repository {repo.name} does allow force push." - ) + report.status_extended = f"Repository {repo.name} does allow force pushes on default branch ({repo.default_branch.name})." - if not repo.allow_force_pushes: + if not repo.default_branch.allow_force_pushes: report.status = "PASS" - report.status_extended = ( - f"Repository {repo.name} does deny force push." - ) + report.status_extended = f"Repository {repo.name} does deny force pushes on default branch ({repo.default_branch.name})." findings.append(report) diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py index 916ddd35c5..0a70cf0aea 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py @@ -22,14 +22,12 @@ class repository_default_branch_protection_applies_to_admins(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.enforce_admins is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.enforce_admins is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not enforce administrators to be subject to the same branch protection rules as other users." - if repo.enforce_admins: + if repo.default_branch.enforce_admins: report.status = "PASS" report.status_extended = f"Repository {repo.name} does enforce administrators to be subject to the same branch protection rules as other users." diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py index a6c9be5a37..1348018ee1 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py @@ -22,16 +22,14 @@ class repository_default_branch_protection_enabled(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.default_branch_protection is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.protected is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" - report.status_extended = f"Repository {repo.name} does not enforce branch protection on default branch ({repo.default_branch})." + report.status_extended = f"Repository {repo.name} does not enforce branch protection on default branch ({repo.default_branch.name})." - if repo.default_branch_protection: + if repo.default_branch.protected: report.status = "PASS" - report.status_extended = f"Repository {repo.name} does enforce branch protection on default branch ({repo.default_branch})." + report.status_extended = f"Repository {repo.name} does enforce branch protection on default branch ({repo.default_branch.name})." findings.append(report) diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py index 11cc3fd05b..4c5b75010a 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py @@ -22,11 +22,9 @@ class repository_default_branch_requires_codeowners_review(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.require_code_owner_reviews is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) - if repo.require_code_owner_reviews: + if repo.default_branch.require_code_owner_reviews is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + if repo.default_branch.require_code_owner_reviews: report.status = "PASS" report.status_extended = f"Repository {repo.name} requires code owner approval for changes to owned code." else: diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py index 1bee931478..da33754cf5 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py @@ -22,20 +22,14 @@ class repository_default_branch_requires_conversation_resolution(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.conversation_resolution is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.conversation_resolution is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" - report.status_extended = ( - f"Repository {repo.name} does not require conversation resolution." - ) + report.status_extended = f"Repository {repo.name} does not require conversation resolution on default branch ({repo.default_branch.name})." - if repo.conversation_resolution: + if repo.default_branch.conversation_resolution: report.status = "PASS" - report.status_extended = ( - f"Repository {repo.name} does require conversation resolution." - ) + report.status_extended = f"Repository {repo.name} does require conversation resolution on default branch ({repo.default_branch.name})." findings.append(report) diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py index a06c8b4a3f..29a0e51b51 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py @@ -22,16 +22,14 @@ class repository_default_branch_requires_linear_history(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.required_linear_history is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.required_linear_history is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" - report.status_extended = f"Repository {repo.name} does not require linear history on default branch ({repo.default_branch})." + report.status_extended = f"Repository {repo.name} does not require linear history on default branch ({repo.default_branch.name})." - if repo.required_linear_history: + if repo.default_branch.required_linear_history: report.status = "PASS" - report.status_extended = f"Repository {repo.name} does require linear history on default branch ({repo.default_branch})." + report.status_extended = f"Repository {repo.name} does require linear history on default branch ({repo.default_branch.name})." findings.append(report) diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py index ac75115010..6312b98d02 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py @@ -22,14 +22,12 @@ class repository_default_branch_requires_multiple_approvals(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.approval_count is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.approval_count is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not enforce at least 2 approvals for code changes." - if repo.approval_count >= 2: + if repo.default_branch.approval_count >= 2: report.status = "PASS" report.status_extended = f"Repository {repo.name} does enforce at least 2 approvals for code changes." diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/__init__.py b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json new file mode 100644 index 0000000000..2b0ddb8c8a --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_default_branch_requires_signed_commits", + "CheckTitle": "Check if repository requires signed commits", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "github:user-id:repository/repository-name", + "Severity": "medium", + "ResourceType": "GitHubRepository", + "Description": "Ensure that every commit in a pull request is signed and verified before merging to the default branch.", + "Risk": "If repositories do not require signed commits, there is no way to verify the authenticity and integrity of code changes. This could allow malicious actors to impersonate legitimate contributors and introduce unauthorized or harmful changes to the codebase.", + "RelatedUrl": "https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the 'Require signed commits' option in branch protection rules to ensure that all commits are cryptographically signed and verified before they can be merged.", + "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-signed-commits" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py new file mode 100644 index 0000000000..4b6aa3ae4c --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py @@ -0,0 +1,36 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_default_branch_requires_signed_commits(Check): + """Check if a repository requires signed commits + + This class verifies whether each repository requires signed commits for the default branch. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Repository Requires Signed Commits check + + Iterates over all repositories and checks if they require signed commits. + + Returns: + List[CheckReportGithub]: A list of reports for each repository + """ + findings = [] + for repo in repository_client.repositories.values(): + if repo.default_branch.require_signed_commits is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} does not require signed commits on default branch ({repo.default_branch.name})." + + if repo.default_branch.require_signed_commits: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} does require signed commits on default branch ({repo.default_branch.name})." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py index 21d6b173da..e67b9def2c 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py +++ b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py @@ -22,16 +22,14 @@ class repository_default_branch_status_checks_required(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.status_checks is not None: - report = CheckReportGithub( - self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.status_checks is not None: + report = CheckReportGithub(self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = ( f"Repository {repo.name} does not enforce status checks." ) - if repo.status_checks: + if repo.default_branch.status_checks: report.status = "PASS" report.status_extended = ( f"Repository {repo.name} does enforce status checks." diff --git a/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/__init__.py b/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.metadata.json b/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.metadata.json new file mode 100644 index 0000000000..c245667ed1 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_dependency_scanning_enabled", + "CheckTitle": "Check if package vulnerability scanning is enabled for dependencies in the repository", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "github:user-id:repository/repository-name", + "Severity": "high", + "ResourceType": "GitHubRepository", + "Description": "Implement scanning tools to detect, prevent, and monitor known open-source vulnerabilities in packages used within the organization's projects. This check verifies that dependency/package vulnerability scanning (e.g., Dependabot alerts) is enabled for the repository.", + "Risk": "If package vulnerability scanning is not enabled, known vulnerabilities in dependencies may go undetected, increasing the risk of exploitation and security breaches.", + "RelatedUrl": "https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable Dependabot alerts or another package vulnerability scanner in the repository settings to automatically detect and alert on vulnerable dependencies.", + "Url": "https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.py b/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.py new file mode 100644 index 0000000000..f983d7f901 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.py @@ -0,0 +1,36 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_dependency_scanning_enabled(Check): + """Check if package vulnerability scanning (Dependabot alerts) is enabled for dependencies in the repository + + This class verifies whether each repository has package vulnerability scanning enabled. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Repository Package Vulnerabilities Scanner check + + Iterates over all repositories and checks if package vulnerability scanning is enabled. + + Returns: + List[CheckReportGithub]: A list of reports for each repository + """ + findings = [] + for repo in repository_client.repositories.values(): + if repo.dependabot_alerts_enabled is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + if repo.dependabot_alerts_enabled: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} has package vulnerability scanning (Dependabot alerts) enabled." + else: + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} does not have package vulnerability scanning (Dependabot alerts) enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py index a158f63880..7120e0411a 100644 --- a/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py +++ b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py @@ -23,9 +23,7 @@ class repository_has_codeowners_file(Check): findings = [] for repo in repository_client.repositories.values(): if repo.codeowners_exists is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + report = CheckReportGithub(metadata=self.metadata(), resource=repo) if repo.codeowners_exists: report.status = "PASS" report.status_extended = ( diff --git a/prowler/providers/github/services/repository/repository_inactive_not_archived/__init__.py b/prowler/providers/github/services/repository/repository_inactive_not_archived/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.metadata.json b/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.metadata.json new file mode 100644 index 0000000000..07395fe6b7 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_inactive_not_archived", + "CheckTitle": "Check for inactive repositories that are not archived", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "GitHubRepository", + "Description": "Ensure that repositories with no activity are reviewed and considered for archival. Inactive repositories may have outdated dependencies or security configurations that could pose security risks.", + "Risk": "Inactive repositories that are not archived may contain outdated dependencies, unpatched vulnerabilities, or misconfigured security settings. These repositories increase the attack surface and could be targeted by malicious actors.", + "RelatedUrl": "https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Review inactive repositories and either: 1) Archive them if they are no longer needed, 2) Update their dependencies and security configurations if they are still required, or 3) Delete them if they contain no valuable information.", + "Url": "https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.py b/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.py new file mode 100644 index 0000000000..c6426fa194 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.py @@ -0,0 +1,43 @@ +from datetime import datetime, timezone +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_inactive_not_archived(Check): + """Check if unarchived repositories have been inactive for more than 6 months.""" + + def execute(self) -> List[CheckReportGithub]: + findings = [] + + now = datetime.now(timezone.utc) + + days_threshold = repository_client.audit_config.get( + "inactive_not_archived_days_threshold", 180 + ) + + for repo in repository_client.repositories.values(): + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + + if repo.archived: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} is properly archived." + findings.append(report) + continue + + latest_activity = repo.pushed_at + days_inactive = (now - latest_activity).days + + if days_inactive >= days_threshold: + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} has been inactive for {days_inactive} days and is not archived (threshold: {days_threshold} days)." + else: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} has been active within the last {days_threshold} days ({days_inactive} days ago)." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.py b/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.py index c1ec7b51e4..3de966ed25 100644 --- a/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.py +++ b/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.py @@ -23,9 +23,7 @@ class repository_public_has_securitymd_file(Check): findings = [] for repo in repository_client.repositories.values(): if not repo.private and repo.securitymd is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "PASS" report.status_extended = ( f"Repository {repo.name} does have a SECURITY.md file." diff --git a/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.metadata.json b/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.metadata.json new file mode 100644 index 0000000000..4f9f07cc26 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_secret_scanning_enabled", + "CheckTitle": "Check if secret scanning is enabled to detect sensitive data in the repository", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "github:user-id:repository/repository-name", + "Severity": "high", + "ResourceType": "GitHubRepository", + "Description": "Ensure that scanners are in place to detect and prevent sensitive data, such as confidential ID numbers, passwords, and other sensitive information, from being committed in the source code. This check verifies that secret scanning is enabled to identify and prevent sensitive data from being included in the repository.", + "Risk": "If secret scanning is not enabled, sensitive data may be inadvertently committed to the repository, increasing the risk of data breaches and exploitation by attackers.", + "RelatedUrl": "https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable secret scanning in the repository settings to automatically detect and prevent sensitive data from being committed to the codebase.", + "Url": "https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.py b/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.py new file mode 100644 index 0000000000..57d09be486 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.py @@ -0,0 +1,36 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_secret_scanning_enabled(Check): + """Check if secret scanning is enabled to detect sensitive data in the repository + + This class verifies whether each repository has secret scanning enabled. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Repository Secret Scanning check + + Iterates over all repositories and checks if secret scanning is enabled. + + Returns: + List[CheckReportGithub]: A list of reports for each repository + """ + findings = [] + for repo in repository_client.repositories.values(): + if repo.secret_scanning_enabled is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + if getattr(repo, "secret_scanning_enabled", None): + report.status = "PASS" + report.status_extended = f"Repository {repo.name} has secret scanning enabled to detect sensitive data." + else: + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} does not have secret scanning enabled to detect sensitive data." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index a3afffccc1..6c0101503b 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional from pydantic import BaseModel @@ -61,6 +62,7 @@ class Repository(GithubService): allow_force_pushes = True branch_deletion = True require_code_owner_reviews = False + require_signed_commits = False status_checks = False enforce_admins = False conversation_resolution = False @@ -95,6 +97,9 @@ class Repository(GithubService): if require_pr else False ) + require_signed_commits = ( + branch.get_required_signatures() + ) except Exception as error: # If the branch is not found, it is not protected if "404" in str(error): @@ -110,6 +115,7 @@ class Repository(GithubService): allow_force_pushes = None branch_deletion = None require_code_owner_reviews = None + require_signed_commits = None status_checks = None enforce_admins = None conversation_resolution = None @@ -117,24 +123,68 @@ class Repository(GithubService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + secret_scanning_enabled = False + dependabot_alerts_enabled = False + try: + if ( + repo.security_and_analysis + and repo.security_and_analysis.secret_scanning + ): + secret_scanning_enabled = ( + repo.security_and_analysis.secret_scanning.status + == "enabled" + ) + try: + # Use get_dependabot_alerts to check if Dependabot alerts are enabled + repo.get_dependabot_alerts().totalCount + # If the call succeeds, Dependabot is enabled (even if no alerts) + dependabot_alerts_enabled = True + except Exception as error: + error_str = str(error) + if ( + "403" in error_str + and "Dependabot alerts are disabled for this repository." + in error_str + ): + dependabot_alerts_enabled = False + else: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + dependabot_alerts_enabled = None + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + secret_scanning_enabled = None + dependabot_alerts_enabled = None repos[repo.id] = Repo( id=repo.id, name=repo.name, + owner=repo.owner.login, full_name=repo.full_name, - default_branch=repo.default_branch, + default_branch=Branch( + name=default_branch, + protected=branch_protection, + default_branch=True, + require_pull_request=require_pr, + approval_count=approval_cnt, + required_linear_history=required_linear_history, + allow_force_pushes=allow_force_pushes, + branch_deletion=branch_deletion, + status_checks=status_checks, + enforce_admins=enforce_admins, + conversation_resolution=conversation_resolution, + require_code_owner_reviews=require_code_owner_reviews, + require_signed_commits=require_signed_commits, + ), private=repo.private, + archived=repo.archived, + pushed_at=repo.pushed_at, securitymd=securitymd_exists, - require_pull_request=require_pr, - approval_count=approval_cnt, - required_linear_history=required_linear_history, - allow_force_pushes=allow_force_pushes, - default_branch_deletion=branch_deletion, - status_checks=status_checks, - enforce_admins=enforce_admins, - conversation_resolution=conversation_resolution, - default_branch_protection=branch_protection, codeowners_exists=codeowners_exists, - require_code_owner_reviews=require_code_owner_reviews, + secret_scanning_enabled=secret_scanning_enabled, + dependabot_alerts_enabled=dependabot_alerts_enabled, delete_branch_on_merge=delete_branch_on_merge, ) @@ -145,24 +195,37 @@ class Repository(GithubService): return repos +class Branch(BaseModel): + """Model for Github Branch""" + + name: str + protected: bool + default_branch: bool + require_pull_request: Optional[bool] + approval_count: Optional[int] + required_linear_history: Optional[bool] + allow_force_pushes: Optional[bool] + branch_deletion: Optional[bool] + status_checks: Optional[bool] + enforce_admins: Optional[bool] + require_code_owner_reviews: Optional[bool] + require_signed_commits: Optional[bool] + conversation_resolution: Optional[bool] + + class Repo(BaseModel): """Model for Github Repository""" id: int name: str + owner: str full_name: str - default_branch_protection: Optional[bool] - default_branch: str + default_branch: Branch private: bool + archived: bool + pushed_at: datetime securitymd: Optional[bool] - require_pull_request: Optional[bool] - required_linear_history: Optional[bool] - allow_force_pushes: Optional[bool] - default_branch_deletion: Optional[bool] - status_checks: Optional[bool] - enforce_admins: Optional[bool] - approval_count: Optional[int] codeowners_exists: Optional[bool] - require_code_owner_reviews: Optional[bool] + secret_scanning_enabled: Optional[bool] + dependabot_alerts_enabled: Optional[bool] delete_branch_on_merge: Optional[bool] - conversation_resolution: Optional[bool] diff --git a/prowler/providers/m365/exceptions/exceptions.py b/prowler/providers/m365/exceptions/exceptions.py index 7d599386f9..fe2b8b442c 100644 --- a/prowler/providers/m365/exceptions/exceptions.py +++ b/prowler/providers/m365/exceptions/exceptions.py @@ -106,9 +106,9 @@ class M365BaseException(ProwlerException): "message": "The provided User is not valid.", "remediation": "Check the User and ensure it is a valid user.", }, - (6025, "M365NotValidEncryptedPasswordError"): { - "message": "The provided Encrypted Password is not valid.", - "remediation": "Check the Encrypted Password and ensure it is a valid password.", + (6025, "M365NotValidPasswordError"): { + "message": "The provided Password is not valid.", + "remediation": "Check the Password and ensure it is a valid password.", }, (6026, "M365UserNotBelongingToTenantError"): { "message": "The provided User does not belong to the specified tenant.", @@ -312,7 +312,7 @@ class M365NotValidUserError(M365CredentialsError): ) -class M365NotValidEncryptedPasswordError(M365CredentialsError): +class M365NotValidPasswordError(M365CredentialsError): def __init__(self, file=None, original_exception=None, message=None): super().__init__( 6025, file=file, original_exception=original_exception, message=message diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index a91e25d52d..fede39c401 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -1,4 +1,5 @@ import os +import platform import msal @@ -64,17 +65,59 @@ class M365PowerShell(PowerShellSession): The credentials are sanitized to prevent command injection and stored securely in the PowerShell session. """ + + credentials.encrypted_passwd = self.encrypt_password(credentials.passwd) + # Sanitize user and password - user = self.sanitize(credentials.user) - passwd = self.sanitize(credentials.passwd) + sanitized_user = self.sanitize(credentials.user) + sanitized_encrypted_passwd = self.sanitize(credentials.encrypted_passwd) # Securely convert encrypted password to SecureString - self.execute(f'$user = "{user}"') - self.execute(f'$secureString = "{passwd}" | ConvertTo-SecureString') + self.execute(f'$user = "{sanitized_user}"') + self.execute( + f'$secureString = "{sanitized_encrypted_passwd}" | ConvertTo-SecureString' + ) self.execute( "$credential = New-Object System.Management.Automation.PSCredential ($user, $secureString)" ) + def encrypt_password(self, password: str) -> str: + """ + Encrypts a password using Windows CryptProtectData on Windows systems + or UTF-16LE encoding on other systems. + + Args: + password (str): The password to encrypt + + Returns: + str: The encrypted password in hexadecimal format + + Raises: + ValueError: If password is None or empty + """ + try: + if platform.system() == "Windows": + import win32crypt + + encrypted_blob = win32crypt.CryptProtectData( + password.encode("utf-16le"), None, None, None, None, 0 + ) + + encrypted_bytes = encrypted_blob + if isinstance(encrypted_blob, tuple): + encrypted_bytes = encrypted_blob[1] + elif hasattr(encrypted_blob, "data"): + encrypted_bytes = encrypted_blob.data + + return encrypted_bytes.hex() + + else: + return password.encode("utf-16le").hex() + except Exception as error: + raise Exception( + f"[{os.path.basename(__file__)}] Error encrypting password: {str(error)}" + ) + def test_credentials(self, credentials: M365Credentials) -> bool: """ Test Microsoft 365 credentials by attempting to authenticate against Entra ID. @@ -87,14 +130,11 @@ class M365PowerShell(PowerShellSession): bool: True if credentials are valid and authentication succeeds, False otherwise. """ self.execute( - f'$securePassword = "{self.sanitize(credentials.passwd)}" | ConvertTo-SecureString' + f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' # encrypted password already sanitized ) self.execute( f'$credential = New-Object System.Management.Automation.PSCredential("{self.sanitize(credentials.user)}", $securePassword)' ) - decrypted_password = self.execute( - 'Write-Output "$($credential.GetNetworkCredential().Password)"' - ) # Validate user belongs to tenant user_domain = credentials.user.split("@")[1] @@ -116,7 +156,7 @@ class M365PowerShell(PowerShellSession): # Validate credentials result = app.acquire_token_by_username_password( username=credentials.user, - password=decrypted_password, # Needs to be in plain text + password=credentials.passwd, scopes=["https://graph.microsoft.com/.default"], ) diff --git a/prowler/providers/m365/m365_provider.py b/prowler/providers/m365/m365_provider.py index a4135b0692..cf647ce978 100644 --- a/prowler/providers/m365/m365_provider.py +++ b/prowler/providers/m365/m365_provider.py @@ -43,7 +43,7 @@ from prowler.providers.m365.exceptions.exceptions import ( M365NotTenantIdButClientIdAndClientSecretError, M365NotValidClientIdError, M365NotValidClientSecretError, - M365NotValidEncryptedPasswordError, + M365NotValidPasswordError, M365NotValidTenantIdError, M365NotValidUserError, M365SetUpRegionConfigError, @@ -117,7 +117,7 @@ class M365Provider(Provider): client_id: str = None, client_secret: str = None, user: str = None, - encrypted_password: str = None, + password: str = None, init_modules: bool = False, region: str = "M365Global", config_content: dict = None, @@ -164,7 +164,7 @@ class M365Provider(Provider): client_id, client_secret, user, - encrypted_password, + password, ) logger.info("Checking if region is different than default one") @@ -172,13 +172,13 @@ class M365Provider(Provider): # Get the dict from the static credentials m365_credentials = None - if tenant_id and client_id and client_secret and user and encrypted_password: + if tenant_id and client_id and client_secret and user and password: m365_credentials = self.validate_static_credentials( tenant_id=tenant_id, client_id=client_id, client_secret=client_secret, user=user, - encrypted_password=encrypted_password, + password=password, ) # Set up the M365 session @@ -282,7 +282,7 @@ class M365Provider(Provider): client_id: str, client_secret: str, user: str, - encrypted_password: str, + password: str, ): """ Validates the authentication arguments for the M365 provider. @@ -296,7 +296,7 @@ class M365Provider(Provider): client_id (str): The M365 Client ID. client_secret (str): The M365 Client Secret. user (str): The M365 User Account. - encrpted_password (str): The M365 Encrypted Password. + password (str): The M365 User Password. Raises: M365BrowserAuthNoTenantIDError: If browser authentication is enabled but the tenant ID is not found. @@ -324,10 +324,10 @@ class M365Provider(Provider): message="M365 Tenant ID (--tenant-id) is required for browser authentication mode", ) elif env_auth: - if not user or not encrypted_password or not tenant_id: + if not user or not password or not tenant_id: raise M365MissingEnvironmentCredentialsError( file=os.path.basename(__file__), - message="M365 provider requires AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, M365_USER and M365_ENCRYPTED_PASSWORD environment variables to be set when using --env-auth", + message="M365 provider requires AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, M365_USER and M365_PASSWORD environment variables to be set when using --env-auth", ) else: if not tenant_id: @@ -396,7 +396,7 @@ class M365Provider(Provider): if m365_credentials: credentials = M365Credentials( user=m365_credentials.get("user", ""), - passwd=m365_credentials.get("encrypted_password", ""), + passwd=m365_credentials.get("password", ""), client_id=m365_credentials.get("client_id", ""), client_secret=m365_credentials.get("client_secret", ""), tenant_id=m365_credentials.get("tenant_id", ""), @@ -404,18 +404,18 @@ class M365Provider(Provider): ) elif env_auth: m365_user = getenv("M365_USER") - m365_password = getenv("M365_ENCRYPTED_PASSWORD") + m365_password = getenv("M365_PASSWORD") client_id = getenv("AZURE_CLIENT_ID") client_secret = getenv("AZURE_CLIENT_SECRET") tenant_id = getenv("AZURE_TENANT_ID") if not m365_user or not m365_password: logger.critical( - "M365 provider: Missing M365_USER or M365_ENCRYPTED_PASSWORD environment variables needed for credentials authentication" + "M365 provider: Missing M365_USER or M365_PASSWORD environment variables needed for credentials authentication" ) raise M365MissingEnvironmentCredentialsError( file=os.path.basename(__file__), - message="Missing M365_USER or M365_ENCRYPTED_PASSWORD environment variables required for credentials authentication.", + message="Missing M365_USER or M365_PASSWORD environment variables required for credentials authentication.", ) credentials = M365Credentials( client_id=client_id, @@ -494,7 +494,7 @@ class M365Provider(Provider): - client_id: The M365 client ID. - client_secret: The M365 client secret - user: The M365 user email - - encrypted_password: The M365 encrypted password + - password: The M365 user password - provider_id: The M365 provider ID (in this case the Tenant ID). region_config (M365RegionConfig): The region configuration object. @@ -621,7 +621,7 @@ class M365Provider(Provider): client_id: str = None, client_secret: str = None, user: str = None, - encrypted_password: str = None, + password: str = None, provider_id: str = None, ) -> Connection: """Test connection to M365 tenant and PowerShell modules. @@ -640,7 +640,7 @@ class M365Provider(Provider): client_id (str): The M365 client ID. client_secret (str): The M365 client secret. user (str): The M365 user email. - encrypted_password (str): The M365 encrypted_password. + password (str): The M365 password. provider_id (str): The M365 provider ID (in this case the Tenant ID). @@ -674,20 +674,20 @@ class M365Provider(Provider): client_id, client_secret, user, - encrypted_password, + password, ) region_config = M365Provider.setup_region_config(region) # Get the dict from the static credentials m365_credentials = None if tenant_id and client_id and client_secret: - if not user and not encrypted_password: + if not user and not password: m365_credentials = M365Provider.validate_static_credentials( tenant_id=tenant_id, client_id=client_id, client_secret=client_secret, user="user", - encrypted_password="encrypted_password", + password="password", ) else: m365_credentials = M365Provider.validate_static_credentials( @@ -695,7 +695,7 @@ class M365Provider(Provider): client_id=client_id, client_secret=client_secret, user=user, - encrypted_password=encrypted_password, + password=password, ) # Set up the M365 session @@ -733,7 +733,7 @@ class M365Provider(Provider): ) # Set up PowerShell credentials - if user and encrypted_password: + if user and password: M365Provider.setup_powershell( env_auth, m365_credentials, @@ -975,7 +975,7 @@ class M365Provider(Provider): client_id: str = None, client_secret: str = None, user: str = None, - encrypted_password: str = None, + password: str = None, ) -> dict: """ Validates the static credentials for the M365 provider. @@ -985,7 +985,7 @@ class M365Provider(Provider): client_id (str): The M365 client ID. client_secret (str): The M365 client secret. user (str): The M365 user email. - encrypted_password (str): The M365 encrypted password. + password (str): The M365 user password. Raises: M365NotValidTenantIdError: If the provided M365 Tenant ID is not valid. @@ -1030,11 +1030,11 @@ class M365Provider(Provider): message="The provided User is not valid.", ) - # Validate the Encrypted Password - if not encrypted_password: - raise M365NotValidEncryptedPasswordError( + # Validate the Password + if not password: + raise M365NotValidPasswordError( file=os.path.basename(__file__), - message="The provided Encrypted Password is not valid.", + message="The provided Password is not valid.", ) try: @@ -1044,7 +1044,7 @@ class M365Provider(Provider): "client_id": client_id, "client_secret": client_secret, "user": user, - "encrypted_password": encrypted_password, + "password": password, } except M365NotValidTenantIdError as tenant_id_error: logger.error( diff --git a/prowler/providers/m365/models.py b/prowler/providers/m365/models.py index d0d73263b5..f77bc8ee4c 100644 --- a/prowler/providers/m365/models.py +++ b/prowler/providers/m365/models.py @@ -22,12 +22,13 @@ class M365RegionConfig(BaseModel): class M365Credentials(BaseModel): + user: str = "" + passwd: str = "" + encrypted_passwd: str = "" client_id: str = "" client_secret: str = "" tenant_id: str = "" tenant_domains: list[str] = [] - user: str = "" - passwd: str = "" class M365OutputOptions(ProviderOutputOptions): diff --git a/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.py b/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.py index d61fdec68d..f38f94f64f 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.py +++ b/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.py @@ -36,7 +36,7 @@ class admincenter_groups_not_public_visibility(Check): report.status = "FAIL" report.status_extended = f"Group {group.name} has {group.visibility} visibility and should be Private." - if group.visibility != "Public": + if group.visibility and group.visibility != "Public": report.status = "PASS" report.status_extended = ( f"Group {group.name} has {group.visibility} visibility." diff --git a/prowler/providers/m365/services/admincenter/admincenter_service.py b/prowler/providers/m365/services/admincenter/admincenter_service.py index feac9c8375..32b2b12805 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_service.py +++ b/prowler/providers/m365/services/admincenter/admincenter_service.py @@ -11,8 +11,6 @@ from prowler.providers.m365.m365_provider import M365Provider class AdminCenter(M365Service): def __init__(self, provider: M365Provider): super().__init__(provider) - if self.powershell: - self.powershell.close() self.organization_config = None self.sharing_policy = None @@ -203,7 +201,7 @@ class DirectoryRole(BaseModel): class Group(BaseModel): id: str name: str - visibility: str + visibility: Optional[str] class Domain(BaseModel): diff --git a/prowler/providers/m365/services/entra/entra_identity_protection_sign_in_risk_enabled/entra_identity_protection_sign_in_risk_enabled.py b/prowler/providers/m365/services/entra/entra_identity_protection_sign_in_risk_enabled/entra_identity_protection_sign_in_risk_enabled.py index c9878c8315..6b8db3230f 100644 --- a/prowler/providers/m365/services/entra/entra_identity_protection_sign_in_risk_enabled/entra_identity_protection_sign_in_risk_enabled.py +++ b/prowler/providers/m365/services/entra/entra_identity_protection_sign_in_risk_enabled/entra_identity_protection_sign_in_risk_enabled.py @@ -9,13 +9,13 @@ from prowler.providers.m365.services.entra.entra_service import ( class entra_identity_protection_sign_in_risk_enabled(Check): - """Check if at least one Conditional Access policy is a Identity Protection sign-in risk policy. + """Check if at least one Conditional Access policy is an Identity Protection sign-in risk policy. - This check ensures that at least one Conditional Access policy is a Identity Protection sign-in risk policy. + This check ensures that at least one Conditional Access policy is an Identity Protection sign-in risk policy. """ def execute(self) -> list[CheckReportM365]: - """Execute the check to ensure that at least one Conditional Access policy is a Identity Protection sign-in risk policy. + """Execute the check to ensure that at least one Conditional Access policy is an Identity Protection sign-in risk policy. Returns: list[CheckReportM365]: A list containing the results of the check. diff --git a/prowler/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled.py b/prowler/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled.py index 8864663e68..b27f6dcf3e 100644 --- a/prowler/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled.py +++ b/prowler/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled.py @@ -9,13 +9,13 @@ from prowler.providers.m365.services.entra.entra_service import ( class entra_identity_protection_user_risk_enabled(Check): - """Check if at least one Conditional Access policy is a Identity Protection user risk policy. + """Check if at least one Conditional Access policy is an Identity Protection user risk policy. - This check ensures that at least one Conditional Access policy is a Identity Protection user risk policy. + This check ensures that at least one Conditional Access policy is an Identity Protection user risk policy. """ def execute(self) -> list[CheckReportM365]: - """Execute the check to ensure that at least one Conditional Access policy is a Identity Protection user risk policy. + """Execute the check to ensure that at least one Conditional Access policy is an Identity Protection user risk policy. Returns: list[CheckReportM365]: A list containing the results of the check. @@ -29,7 +29,7 @@ class entra_identity_protection_user_risk_enabled(Check): resource_id="conditionalAccessPolicies", ) report.status = "FAIL" - report.status_extended = "No Conditional Access Policy is an user risk based Identity Protection Policy." + report.status_extended = "No Conditional Access Policy is a user risk based Identity Protection Policy." for policy in entra_client.conditional_access_policies.values(): if policy.state == ConditionalAccessPolicyState.DISABLED: @@ -62,13 +62,13 @@ class entra_identity_protection_user_risk_enabled(Check): ) if RiskLevel.HIGH not in policy.conditions.user_risk_levels: report.status = "FAIL" - report.status_extended = f"Conditional Access Policy '{policy.display_name}' is an user risk based Identity Protection Policy but does not protect against high risk potential account compromises." + report.status_extended = f"Conditional Access Policy '{policy.display_name}' is a user risk based Identity Protection Policy but does not protect against high risk potential account compromises." elif policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: report.status = "FAIL" - report.status_extended = f"Conditional Access Policy '{policy.display_name}' is an user risk based Identity Protection Policy and reports high risk potential account compromises, but does not protect against them." + report.status_extended = f"Conditional Access Policy '{policy.display_name}' is a user risk based Identity Protection Policy and reports high risk potential account compromises, but does not protect against them." else: report.status = "PASS" - report.status_extended = f"Conditional Access Policy '{policy.display_name}' is an user risk based Identity Protection Policy and does protect against high risk potential account compromises." + report.status_extended = f"Conditional Access Policy '{policy.display_name}' is a user risk based Identity Protection Policy and does protect against high risk potential account compromises." break findings.append(report) diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index b14f82b03c..05e152bf2d 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -493,6 +493,7 @@ class ConditionalAccessGrantControl(Enum): BLOCK = "block" DOMAIN_JOINED_DEVICE = "domainJoinedDevice" PASSWORD_CHANGE = "passwordChange" + COMPLIANT_DEVICE = "compliantDevice" class GrantControlOperator(Enum): diff --git a/prowler/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user.py b/prowler/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user.py index 95f575f83c..8330dc4730 100644 --- a/prowler/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user.py +++ b/prowler/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user.py @@ -12,7 +12,7 @@ class compute_instance_login_user(Check): ) report.status = "PASS" report.status_extended = ( - f"VM Instance {instance.name} has a appropriate login user." + f"VM Instance {instance.name} has an appropriate login user." ) if instance.login_user: report.status = "FAIL" diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py index 414de90288..fdbb8b6d70 100644 --- a/tests/lib/check/models_test.py +++ b/tests/lib/check/models_test.py @@ -37,7 +37,7 @@ mock_metadata_lambda = CheckMetadata( CheckID="awslambda_function_url_public", CheckTitle="Check 1", CheckType=["type1"], - ServiceName="lambda", + ServiceName="awslambda", SubServiceName="subservice1", ResourceIdTemplate="template1", Severity="high", diff --git a/tests/lib/outputs/compliance/fixtures.py b/tests/lib/outputs/compliance/fixtures.py index ddf25ad661..c656165195 100644 --- a/tests/lib/outputs/compliance/fixtures.py +++ b/tests/lib/outputs/compliance/fixtures.py @@ -857,7 +857,6 @@ KISA_ISMSP_AWS = Compliance( ], ) -PROWLER_THREATSCORE_AWS_NAME = "prowler_threatscore_aws" PROWLER_THREATSCORE_AWS = Compliance( Framework="ProwlerThreatScore", Version="1.0", @@ -901,7 +900,6 @@ PROWLER_THREATSCORE_AWS = Compliance( ], ) -PROWLER_THREATSCORE_AZURE_NAME = "prowler_threatscore_azure" PROWLER_THREATSCORE_AZURE = Compliance( Framework="ProwlerThreatScore", Version="1.0", @@ -945,7 +943,6 @@ PROWLER_THREATSCORE_AZURE = Compliance( ], ) -PROWLER_THREATSCORE_GCP_NAME = "prowler_threatscore_gcp" PROWLER_THREATSCORE_GCP = Compliance( Framework="ProwlerThreatScore", Version="1.0", @@ -989,7 +986,6 @@ PROWLER_THREATSCORE_GCP = Compliance( ], ) -PROWLER_THREATSCORE_M365_NAME = "prowler_threatscore_m365" PROWLER_THREATSCORE_M365 = Compliance( Framework="ProwlerThreatScore", Version="1.0", diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws_test.py index b4771b6139..bae12554ee 100644 --- a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws_test.py +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws_test.py @@ -10,10 +10,7 @@ from prowler.lib.outputs.compliance.prowler_threatscore.models import ( from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_aws import ( ProwlerThreatScoreAWS, ) -from tests.lib.outputs.compliance.fixtures import ( - PROWLER_THREATSCORE_AWS, - PROWLER_THREATSCORE_AWS_NAME, -) +from tests.lib.outputs.compliance.fixtures import PROWLER_THREATSCORE_AWS from tests.lib.outputs.fixtures.fixtures import generate_finding_output from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1 @@ -24,9 +21,7 @@ class TestProwlerThreatScoreAWS: generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"}) ] - output = ProwlerThreatScoreAWS( - findings, PROWLER_THREATSCORE_AWS, PROWLER_THREATSCORE_AWS_NAME - ) + output = ProwlerThreatScoreAWS(findings, PROWLER_THREATSCORE_AWS) output_data = output.data[0] assert isinstance(output_data, ProwlerThreatScoreAWSModel) assert output_data.Provider == "aws" @@ -134,9 +129,7 @@ class TestProwlerThreatScoreAWS: findings = [ generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"}) ] - output = ProwlerThreatScoreAWS( - findings, PROWLER_THREATSCORE_AWS, PROWLER_THREATSCORE_AWS_NAME - ) + output = ProwlerThreatScoreAWS(findings, PROWLER_THREATSCORE_AWS) output._file_descriptor = mock_file with patch.object(mock_file, "close", return_value=None): diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure_test.py index 64b79acb9e..d259641967 100644 --- a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure_test.py +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure_test.py @@ -10,10 +10,7 @@ from prowler.lib.outputs.compliance.prowler_threatscore.models import ( from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_azure import ( ProwlerThreatScoreAzure, ) -from tests.lib.outputs.compliance.fixtures import ( - PROWLER_THREATSCORE_AZURE, - PROWLER_THREATSCORE_AZURE_NAME, -) +from tests.lib.outputs.compliance.fixtures import PROWLER_THREATSCORE_AZURE from tests.lib.outputs.fixtures.fixtures import generate_finding_output from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, @@ -33,9 +30,7 @@ class TestProwlerThreatScoreAzure: ) ] - output = ProwlerThreatScoreAzure( - findings, PROWLER_THREATSCORE_AZURE, PROWLER_THREATSCORE_AZURE_NAME - ) + output = ProwlerThreatScoreAzure(findings, PROWLER_THREATSCORE_AZURE) output_data = output.data[0] assert isinstance(output_data, ProwlerThreatScoreAzureModel) assert output_data.Provider == "azure" @@ -144,9 +139,7 @@ class TestProwlerThreatScoreAzure: findings = [ generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"}) ] - output = ProwlerThreatScoreAzure( - findings, PROWLER_THREATSCORE_AZURE, PROWLER_THREATSCORE_AZURE_NAME - ) + output = ProwlerThreatScoreAzure(findings, PROWLER_THREATSCORE_AZURE) output._file_descriptor = mock_file with patch.object(mock_file, "close", return_value=None): diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp_test.py index 4727cc748c..5318392ea3 100644 --- a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp_test.py +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp_test.py @@ -10,10 +10,7 @@ from prowler.lib.outputs.compliance.prowler_threatscore.models import ( from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_gcp import ( ProwlerThreatScoreGCP, ) -from tests.lib.outputs.compliance.fixtures import ( - PROWLER_THREATSCORE_GCP, - PROWLER_THREATSCORE_GCP_NAME, -) +from tests.lib.outputs.compliance.fixtures import PROWLER_THREATSCORE_GCP from tests.lib.outputs.fixtures.fixtures import generate_finding_output from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID @@ -30,9 +27,7 @@ class TestProwlerThreatScoreGCP: ) ] - output = ProwlerThreatScoreGCP( - findings, PROWLER_THREATSCORE_GCP, PROWLER_THREATSCORE_GCP_NAME - ) + output = ProwlerThreatScoreGCP(findings, PROWLER_THREATSCORE_GCP) output_data = output.data[0] assert isinstance(output_data, ProwlerThreatScoreGCPModel) assert output_data.Provider == "gcp" @@ -140,9 +135,7 @@ class TestProwlerThreatScoreGCP: findings = [ generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"}) ] - output = ProwlerThreatScoreGCP( - findings, PROWLER_THREATSCORE_GCP, PROWLER_THREATSCORE_GCP_NAME - ) + output = ProwlerThreatScoreGCP(findings, PROWLER_THREATSCORE_GCP) output._file_descriptor = mock_file with patch.object(mock_file, "close", return_value=None): diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py index ea0b3e529f..fa43a336ec 100644 --- a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py @@ -10,10 +10,7 @@ from prowler.lib.outputs.compliance.prowler_threatscore.models import ( from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_m365 import ( ProwlerThreatScoreM365, ) -from tests.lib.outputs.compliance.fixtures import ( - PROWLER_THREATSCORE_M365, - PROWLER_THREATSCORE_M365_NAME, -) +from tests.lib.outputs.compliance.fixtures import PROWLER_THREATSCORE_M365 from tests.lib.outputs.fixtures.fixtures import generate_finding_output from tests.providers.m365.m365_fixtures import TENANT_ID @@ -30,9 +27,7 @@ class TestProwlerThreatScoreM365: ) ] - output = ProwlerThreatScoreM365( - findings, PROWLER_THREATSCORE_M365, PROWLER_THREATSCORE_M365_NAME - ) + output = ProwlerThreatScoreM365(findings, PROWLER_THREATSCORE_M365) output_data = output.data[0] assert isinstance(output_data, ProwlerThreatScoreM365Model) assert output_data.Provider == "m365" @@ -142,9 +137,7 @@ class TestProwlerThreatScoreM365: findings = [ generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"}) ] - output = ProwlerThreatScoreM365( - findings, PROWLER_THREATSCORE_M365, PROWLER_THREATSCORE_M365_NAME - ) + output = ProwlerThreatScoreM365(findings, PROWLER_THREATSCORE_M365) output._file_descriptor = mock_file with patch.object(mock_file, "close", return_value=None): diff --git a/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py b/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py index a2764139c6..23aadef795 100644 --- a/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py @@ -31,7 +31,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: result = check.execute() assert len(result) == 0 - def test_defender_severity_alerts_low(self): + def test_defender_severity_alerts_critical(self): resource_id = str(uuid4()) defender_client = mock.MagicMock defender_client.security_contacts = { @@ -41,7 +41,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: name="default", emails="", phone="", - alert_notifications_minimal_severity="Low", + alert_notifications_minimal_severity="Critical", alert_notifications_state="On", notified_roles=["Contributor"], notified_roles_state="On", @@ -69,7 +69,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Notifiy alerts are not enabled for severity high in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {AZURE_SUBSCRIPTION_ID}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" @@ -113,7 +113,51 @@ class Test_defender_ensure_notify_alerts_severity_is_high: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Notifiy alerts are enabled for severity high in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Notifications are enabled for alerts with a minimum severity of high or lower (High) in subscription {AZURE_SUBSCRIPTION_ID}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "default" + assert result[0].resource_id == resource_id + + def test_defender_severity_alerts_low(self): + resource_id = str(uuid4()) + defender_client = mock.MagicMock + defender_client.security_contacts = { + AZURE_SUBSCRIPTION_ID: { + resource_id: SecurityContacts( + resource_id=resource_id, + name="default", + emails="", + phone="", + alert_notifications_minimal_severity="Low", + alert_notifications_state="On", + notified_roles=["Contributor"], + notified_roles_state="On", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_notify_alerts_severity_is_high.defender_ensure_notify_alerts_severity_is_high.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_notify_alerts_severity_is_high.defender_ensure_notify_alerts_severity_is_high import ( + defender_ensure_notify_alerts_severity_is_high, + ) + + check = defender_ensure_notify_alerts_severity_is_high() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Notifications are enabled for alerts with a minimum severity of high or lower (Low) in subscription {AZURE_SUBSCRIPTION_ID}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" @@ -156,7 +200,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Notifiy alerts are not enabled for severity high in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {AZURE_SUBSCRIPTION_ID}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" diff --git a/tests/providers/azure/services/entra/entra_service_test.py b/tests/providers/azure/services/entra/entra_service_test.py index b09b6edaa4..11cd49b40c 100644 --- a/tests/providers/azure/services/entra/entra_service_test.py +++ b/tests/providers/azure/services/entra/entra_service_test.py @@ -93,7 +93,7 @@ async def mock_entra_get_conditional_access_policy(_): "include": ["797f4846-ba00-4fd7-ba43-dac1f8f63013"], "exclude": [], }, - access_controls={"grant": ["MFA"], "block": []}, + access_controls={"grant": ["MFA", "compliantDevice"], "block": []}, ) } } @@ -216,7 +216,7 @@ class Test_Entra_Service: ) assert entra_client.conditional_access_policy[DOMAIN]["id-1"].access_controls[ "grant" - ] == ["MFA"] + ] == ["MFA", "compliantDevice"] assert ( entra_client.conditional_access_policy[DOMAIN]["id-1"].access_controls[ "block" diff --git a/tests/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled_test.py b/tests/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled_test.py index 20c18ea998..e11294a778 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled_test.py @@ -122,7 +122,7 @@ class Test_sqlserver_auditing_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has a auditing policy configured." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has an auditing policy configured." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/github/github_provider_test.py b/tests/providers/github/github_provider_test.py index 30d770ef3b..60b705b629 100644 --- a/tests/providers/github/github_provider_test.py +++ b/tests/providers/github/github_provider_test.py @@ -59,7 +59,9 @@ class TestGitHubProvider: account_id=ACCOUNT_ID, account_url=ACCOUNT_URL, ) - assert provider._audit_config == {} + assert provider._audit_config == { + "inactive_not_archived_days_threshold": 180, + } assert provider._fixer_config == fixer_config def test_github_provider_OAuth(self): @@ -99,7 +101,9 @@ class TestGitHubProvider: account_id=ACCOUNT_ID, account_url=ACCOUNT_URL, ) - assert provider._audit_config == {} + assert provider._audit_config == { + "inactive_not_archived_days_threshold": 180, + } assert provider._fixer_config == fixer_config def test_github_provider_App(self): @@ -133,5 +137,7 @@ class TestGitHubProvider: assert provider._type == "github" assert provider.session == GithubSession(token="", id=APP_ID, key=APP_KEY) assert provider.identity == GithubAppIdentityInfo(app_id=APP_ID) - assert provider._audit_config == {} + assert provider._audit_config == { + "inactive_not_archived_days_threshold": 180, + } assert provider._fixer_config == fixer_config diff --git a/tests/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled_test.py b/tests/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled_test.py index fc9a5c3ddd..e10050e0cc 100644 --- a/tests/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled_test.py +++ b/tests/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -29,15 +30,36 @@ class Test_repository_branch_delete_on_merge_enabled_test: def test_branch_deletion_disabled(self): repository_client = mock.MagicMock - repo_name = "repo1" + repo_name = "repo2" + default_branch = "main" repository_client.repositories = { - 1: Repo( - id=1, + 2: Repo( + id=2, name=repo_name, - full_name="account-name/repo1", - default_branch="main", + owner="account-name", + full_name="account-name/repo2", + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, delete_branch_on_merge=False, ), } @@ -59,25 +81,46 @@ class Test_repository_branch_delete_on_merge_enabled_test: check = repository_branch_delete_on_merge_enabled() result = check.execute() assert len(result) == 1 - assert result[0].resource_id == 1 - assert result[0].resource_name == "repo1" + assert result[0].resource_id == 2 + assert result[0].resource_name == repo_name assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Repository {repo_name} does not delete branches on merge." + == f"Repository {repo_name} does not delete branches on merge in default branch ({default_branch})." ) def test_branch_deletion_enabled(self): repository_client = mock.MagicMock repo_name = "repo1" + default_branch = "main" repository_client.repositories = { 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch="main", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, delete_branch_on_merge=True, ), } @@ -100,9 +143,9 @@ class Test_repository_branch_delete_on_merge_enabled_test: result = check.execute() assert len(result) == 1 assert result[0].resource_id == 1 - assert result[0].resource_name == "repo1" + assert result[0].resource_name == repo_name assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Repository {repo_name} does delete branches on merge." + == f"Repository {repo_name} does delete branches on merge in default branch ({default_branch})." ) diff --git a/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py b/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py index 825e4bdc6e..b4d385fdf1 100644 --- a/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -30,16 +31,39 @@ class Test_repository_default_branch_deletion_disabled_test: def test_allow_branch_deletion_enabled(self): repository_client = mock.MagicMock repo_name = "repo1" - default_branch = "main" + default_branch = Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ) + now = datetime.now(timezone.utc) + repository_client.repositories = { 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", default_branch=default_branch, - default_branch_deletion=True, private=False, + archived=False, + pushed_at=now, + default_branch_deletion=True, securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -61,7 +85,7 @@ class Test_repository_default_branch_deletion_disabled_test: result = check.execute() assert len(result) == 1 assert result[0].resource_id == 1 - assert result[0].resource_name == "repo1" + assert result[0].resource_name == repo_name assert result[0].status == "FAIL" assert ( result[0].status_extended @@ -71,16 +95,39 @@ class Test_repository_default_branch_deletion_disabled_test: def test_allow_branch_deletion_disabled(self): repository_client = mock.MagicMock repo_name = "repo1" - default_branch = "main" + default_branch = Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=False, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ) + now = datetime.now(timezone.utc) + repository_client.repositories = { 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - private=False, default_branch=default_branch, + private=False, + archived=False, + pushed_at=now, default_branch_deletion=False, - securitymd=True, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -102,7 +149,7 @@ class Test_repository_default_branch_deletion_disabled_test: result = check.execute() assert len(result) == 1 assert result[0].resource_id == 1 - assert result[0].resource_name == "repo1" + assert result[0].resource_name == repo_name assert result[0].status == "PASS" assert ( result[0].status_extended diff --git a/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py b/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py index a9da35d0fe..b35393764e 100644 --- a/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -27,7 +28,7 @@ class Test_repository_default_branch_disallows_force_push_test: result = check.execute() assert len(result) == 0 - def test_allow_force_push_enabled(self): + def test_force_push_allowed(self): repository_client = mock.MagicMock repo_name = "repo1" default_branch = "main" @@ -35,11 +36,31 @@ class Test_repository_default_branch_disallows_force_push_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch=default_branch, - allow_force_pushes=True, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -65,10 +86,10 @@ class Test_repository_default_branch_disallows_force_push_test: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Repository {repo_name} does allow force push." + == f"Repository {repo_name} does allow force pushes on default branch ({default_branch})." ) - def test_allow_force_push_disabled(self): + def test_force_push_disallowed(self): repository_client = mock.MagicMock repo_name = "repo1" default_branch = "main" @@ -76,11 +97,31 @@ class Test_repository_default_branch_disallows_force_push_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, - default_branch=default_branch, - allow_force_pushes=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, ), } @@ -106,5 +147,5 @@ class Test_repository_default_branch_disallows_force_push_test: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Repository {repo_name} does deny force push." + == f"Repository {repo_name} does deny force pushes on default branch ({default_branch})." ) diff --git a/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py b/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py index f85bbdced9..296c3d78e5 100644 --- a/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -35,11 +36,31 @@ class Test_repository_default_branch_protection_applies_to_admins_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch=default_branch, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, - enforce_admins=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -76,11 +97,31 @@ class Test_repository_default_branch_protection_applies_to_admins_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, - default_branch=default_branch, - enforce_admins=True, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, ), } diff --git a/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py b/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py index 3723863298..67bce91575 100644 --- a/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -35,11 +36,31 @@ class Test_repository_default_branch_protection_enabled_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch=default_branch, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, - default_branch_protection=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -76,11 +97,31 @@ class Test_repository_default_branch_protection_enabled_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, - default_branch=default_branch, - default_branch_protection=True, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, ), } diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py index fe64448402..ab3d579b75 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -34,13 +35,31 @@ class Test_repository_default_branch_requires_codeowners_review: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch="main", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, - require_pull_request=False, - approval_count=0, - require_code_owner_reviews=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -76,13 +95,31 @@ class Test_repository_default_branch_requires_codeowners_review: 2: Repo( id=2, name=repo_name, + owner="account-name", full_name="account-name/repo2", - default_branch="main", + default_branch=Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, - require_pull_request=False, - approval_count=0, - require_code_owner_reviews=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, ), } diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py index 2fe4b1dbd5..8f5a82bbd8 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -35,11 +36,31 @@ class Test_repository_default_branch_requires_conversation_resolution_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch=default_branch, - conversation_resolution=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -65,7 +86,7 @@ class Test_repository_default_branch_requires_conversation_resolution_test: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Repository {repo_name} does not require conversation resolution." + == f"Repository {repo_name} does not require conversation resolution on default branch ({default_branch})." ) def test_conversation_resolution_enabled(self): @@ -76,11 +97,31 @@ class Test_repository_default_branch_requires_conversation_resolution_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, - default_branch=default_branch, - conversation_resolution=True, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, ), } @@ -106,5 +147,5 @@ class Test_repository_default_branch_requires_conversation_resolution_test: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Repository {repo_name} does require conversation resolution." + == f"Repository {repo_name} does require conversation resolution on default branch ({default_branch})." ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py index 5759697999..b072396804 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -35,11 +36,31 @@ class Test_repository_default_branch_requires_linear_history_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch=default_branch, - required_linear_history=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -76,11 +97,31 @@ class Test_repository_default_branch_requires_linear_history_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, - default_branch=default_branch, - required_linear_history=True, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, ), } diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py index be8905fc7d..ddb9e89df6 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -34,13 +35,31 @@ class Test_repository_default_branch_requires_multiple_approvals: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch_protection=False, - default_branch="main", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, - require_pull_request=False, - approval_count=0, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -76,13 +95,31 @@ class Test_repository_default_branch_requires_multiple_approvals: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch_protection=False, - default_branch="master", + default_branch=Branch( + name="master", + protected=False, + default_branch=True, + require_pull_request=True, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, - require_pull_request=True, - approval_count=0, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -118,13 +155,31 @@ class Test_repository_default_branch_requires_multiple_approvals: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch_protection=True, - default_branch="master", + default_branch=Branch( + name="master", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=2, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, - require_pull_request=True, - approval_count=2, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py new file mode 100644 index 0000000000..5785dd2a14 --- /dev/null +++ b/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py @@ -0,0 +1,151 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_default_branch_requires_signed_commits: + def test_no_repositories(self): + repository_client = mock.MagicMock + repository_client.repositories = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits import ( + repository_default_branch_requires_signed_commits, + ) + + check = repository_default_branch_requires_signed_commits() + result = check.execute() + assert len(result) == 0 + + def test_signed_commits_not_required(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits import ( + repository_default_branch_requires_signed_commits, + ) + + check = repository_default_branch_requires_signed_commits() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not require signed commits on default branch ({default_branch})." + ) + + def test_signed_commits_required(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits import ( + repository_default_branch_requires_signed_commits, + ) + + check = repository_default_branch_requires_signed_commits() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} does require signed commits on default branch ({default_branch})." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py b/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py index 0da7d9425f..1ec8acc3e0 100644 --- a/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -35,11 +36,32 @@ class Test_repository_default_branch_status_checks_required_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch=default_branch, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), status_checks=False, + archived=False, + pushed_at=datetime.now(timezone.utc), private=False, securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -76,11 +98,32 @@ class Test_repository_default_branch_status_checks_required_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", private=False, - default_branch=default_branch, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=True, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), status_checks=True, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } diff --git a/tests/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled_test.py b/tests/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled_test.py new file mode 100644 index 0000000000..990886ad00 --- /dev/null +++ b/tests/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled_test.py @@ -0,0 +1,149 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_dependency_scanning_enabled: + def test_no_repositories(self): + repository_client = mock.MagicMock + repository_client.repositories = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_dependency_scanning_enabled.repository_dependency_scanning_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_dependency_scanning_enabled.repository_dependency_scanning_enabled import ( + repository_dependency_scanning_enabled, + ) + + check = repository_dependency_scanning_enabled() + result = check.execute() + assert len(result) == 0 + + def test_one_repository_no_dependabot(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=True, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_dependency_scanning_enabled.repository_dependency_scanning_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_dependency_scanning_enabled.repository_dependency_scanning_enabled import ( + repository_dependency_scanning_enabled, + ) + + check = repository_dependency_scanning_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not have package vulnerability scanning (Dependabot alerts) enabled." + ) + + def test_one_repository_with_dependabot(self): + repository_client = mock.MagicMock + repo_name = "repo2" + repository_client.repositories = { + 2: Repo( + id=2, + name=repo_name, + owner="account-name", + full_name="account-name/repo2", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_dependency_scanning_enabled.repository_dependency_scanning_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_dependency_scanning_enabled.repository_dependency_scanning_enabled import ( + repository_dependency_scanning_enabled, + ) + + check = repository_dependency_scanning_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 2 + assert result[0].resource_name == repo_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} has package vulnerability scanning (Dependabot alerts) enabled." + ) diff --git a/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py b/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py index 57cb5a53e7..a181ec8404 100644 --- a/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py +++ b/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -34,13 +35,30 @@ class Test_repository_has_codeowners_file: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch="main", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, securitymd=True, - require_pull_request=False, - approval_count=0, codeowners_exists=False, + secret_scanning_enabled=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, ), } @@ -76,13 +94,30 @@ class Test_repository_has_codeowners_file: 2: Repo( id=2, name=repo_name, + owner="account-name", full_name="account-name/repo2", - default_branch="main", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, securitymd=True, - require_pull_request=False, - approval_count=0, codeowners_exists=True, + secret_scanning_enabled=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, ), } diff --git a/tests/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived_test.py b/tests/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived_test.py new file mode 100644 index 0000000000..69ff88a93a --- /dev/null +++ b/tests/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived_test.py @@ -0,0 +1,286 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_inactive_not_archived: + def test_no_repositories(self): + repository_client = mock.MagicMock + repository_client.repositories = {} + repository_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import ( + repository_inactive_not_archived, + ) + + check = repository_inactive_not_archived() + result = check.execute() + assert len(result) == 0 + + def test_repository_active_not_archived(self): + repository_client = mock.MagicMock + repo_name = "test-repo" + default_branch = "main" + now = datetime.now(timezone.utc) + recent_activity = now - timedelta(days=30) # 30 days ago + + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/test-repo", + private=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + archived=False, + pushed_at=recent_activity, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + repository_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import ( + repository_inactive_not_archived, + ) + + check = repository_inactive_not_archived() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} has been active within the last 180 days (30 days ago)." + ) + + def test_repository_inactive_not_archived(self): + repository_client = mock.MagicMock + repo_name = "test-repo" + default_branch = "main" + now = datetime.now(timezone.utc) + old_activity = now - timedelta(days=200) # 200 days ago + + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/test-repo", + private=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + archived=False, + pushed_at=old_activity, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + repository_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import ( + repository_inactive_not_archived, + ) + + check = repository_inactive_not_archived() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "FAIL" + assert "has been inactive for 200 days" in result[0].status_extended + assert "and is not archived" in result[0].status_extended + + def test_repository_inactive_but_archived(self): + repository_client = mock.MagicMock + repo_name = "test-repo" + default_branch = "main" + now = datetime.now(timezone.utc) + old_activity = now - timedelta(days=200) # 200 days ago + + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/test-repo", + private=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + archived=True, + pushed_at=old_activity, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + repository_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import ( + repository_inactive_not_archived, + ) + + check = repository_inactive_not_archived() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} is properly archived." + ) + + def test_custom_days_threshold(self): + repository_client = mock.MagicMock + repo_name = "test-repo" + default_branch = "main" + now = datetime.now(timezone.utc) + old_activity = now - timedelta(days=50) + + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/test-repo", + private=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + archived=False, + pushed_at=old_activity, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + repository_client.audit_config = {"inactive_not_archived_days_threshold": 40} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import ( + repository_inactive_not_archived, + ) + + check = repository_inactive_not_archived() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "FAIL" + assert "has been inactive for 50 days" in result[0].status_extended + assert "and is not archived" in result[0].status_extended diff --git a/tests/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file_test.py b/tests/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file_test.py index 776d557252..45cf49edd7 100644 --- a/tests/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file_test.py +++ b/tests/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -34,12 +35,30 @@ class Test_repository_public_has_securitymd_file_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch="main", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, securitymd=False, - require_pull_request=False, - approval_count=0, + codeowners_exists=False, + secret_scanning_enabled=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, ), } @@ -75,12 +94,30 @@ class Test_repository_public_has_securitymd_file_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch="main", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, securitymd=True, - require_pull_request=False, - approval_count=0, + codeowners_exists=False, + secret_scanning_enabled=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, ), } diff --git a/tests/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled_test.py b/tests/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled_test.py new file mode 100644 index 0000000000..ccdcc4dd25 --- /dev/null +++ b/tests/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled_test.py @@ -0,0 +1,147 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_secret_scanning_enabled: + def test_no_repositories(self): + repository_client = mock.MagicMock + repository_client.repositories = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_secret_scanning_enabled.repository_secret_scanning_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_secret_scanning_enabled.repository_secret_scanning_enabled import ( + repository_secret_scanning_enabled, + ) + + check = repository_secret_scanning_enabled() + result = check.execute() + assert len(result) == 0 + + def test_one_repository_no_secret_scanning(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_secret_scanning_enabled.repository_secret_scanning_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_secret_scanning_enabled.repository_secret_scanning_enabled import ( + repository_secret_scanning_enabled, + ) + + check = repository_secret_scanning_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not have secret scanning enabled to detect sensitive data." + ) + + def test_one_repository_with_secret_scanning(self): + repository_client = mock.MagicMock + repo_name = "repo2" + repository_client.repositories = { + 2: Repo( + id=2, + name=repo_name, + owner="account-name", + full_name="account-name/repo2", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=True, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_secret_scanning_enabled.repository_secret_scanning_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_secret_scanning_enabled.repository_secret_scanning_enabled import ( + repository_secret_scanning_enabled, + ) + + check = repository_secret_scanning_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 2 + assert result[0].resource_name == repo_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} has secret scanning enabled to detect sensitive data." + ) diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index 82535fad14..487ac41e7c 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -1,6 +1,8 @@ +from datetime import datetime, timezone from unittest.mock import MagicMock, patch from prowler.providers.github.services.repository.repository_service import ( + Branch, Repo, Repository, ) @@ -12,22 +14,30 @@ def mock_list_repositories(_): 1: Repo( id=1, name="repo1", + owner="account-name", full_name="account-name/repo1", - default_branch_protection=True, - default_branch="main", + default_branch=Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=2, + required_linear_history=True, + allow_force_pushes=True, + branch_deletion=True, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, securitymd=True, - require_pull_request=True, - required_linear_history=True, - allow_force_pushes=True, - default_branch_deletion=True, - status_checks=True, - approval_count=2, codeowners_exists=True, - require_code_owner_reviews=True, - enforce_admins=True, + secret_scanning_enabled=True, + archived=False, + pushed_at=datetime.now(timezone.utc), delete_branch_on_merge=True, - conversation_resolution=True, ), } @@ -51,19 +61,29 @@ class Test_Repository_Service: assert repository_service.repositories[1].name == "repo1" assert repository_service.repositories[1].full_name == "account-name/repo1" assert repository_service.repositories[1].private is False - assert repository_service.repositories[1].default_branch == "main" + assert repository_service.repositories[1].default_branch.name == "main" assert repository_service.repositories[1].securitymd - assert repository_service.repositories[1].required_linear_history - assert repository_service.repositories[1].require_pull_request - assert repository_service.repositories[1].allow_force_pushes - assert repository_service.repositories[1].default_branch_deletion - assert repository_service.repositories[1].status_checks - assert repository_service.repositories[1].enforce_admins + assert repository_service.repositories[1].default_branch.required_linear_history + assert repository_service.repositories[1].default_branch.require_pull_request + assert repository_service.repositories[1].default_branch.allow_force_pushes + assert repository_service.repositories[1].default_branch.branch_deletion + assert repository_service.repositories[1].default_branch.status_checks + assert repository_service.repositories[1].default_branch.enforce_admins assert repository_service.repositories[1].delete_branch_on_merge - assert repository_service.repositories[1].conversation_resolution - assert repository_service.repositories[1].approval_count == 2 + assert repository_service.repositories[1].default_branch.conversation_resolution + assert repository_service.repositories[1].default_branch.approval_count == 2 assert repository_service.repositories[1].codeowners_exists is True - assert repository_service.repositories[1].require_code_owner_reviews is True + assert ( + repository_service.repositories[1].default_branch.require_code_owner_reviews + is True + ) + assert repository_service.repositories[1].secret_scanning_enabled is True + assert ( + repository_service.repositories[1].default_branch.require_signed_commits + is True + ) + assert repository_service.repositories[1].archived is False + assert repository_service.repositories[1].pushed_at is not None class Test_Repository_FileExists: diff --git a/tests/providers/m365/lib/powershell/m365_powershell_test.py b/tests/providers/m365/lib/powershell/m365_powershell_test.py index 7bc3236433..739dd7e8ce 100644 --- a/tests/providers/m365/lib/powershell/m365_powershell_test.py +++ b/tests/providers/m365/lib/powershell/m365_powershell_test.py @@ -75,6 +75,7 @@ class Testm365PowerShell: credentials = M365Credentials( user="test@example.com", passwd="test_password", + encrypted_passwd="test_password", client_id="test_client_id", client_secret="test_client_secret", tenant_id="test_tenant_id", @@ -89,13 +90,19 @@ class Testm365PowerShell: ) session = M365PowerShell(credentials, identity) + # Mock encrypt_password to return a known value + session.encrypt_password = MagicMock(return_value="encrypted_password") session.execute = MagicMock() session.init_credential(credentials) + # Verify encrypt_password was called + session.encrypt_password.assert_called_once_with(credentials.passwd) + + # Verify execute was called with the correct commands session.execute.assert_any_call(f'$user = "{credentials.user}"') session.execute.assert_any_call( - f'$secureString = "{credentials.passwd}" | ConvertTo-SecureString' + f'$secureString = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' ) session.execute.assert_any_call( "$credential = New-Object System.Management.Automation.PSCredential ($user, $secureString)" @@ -116,6 +123,7 @@ class Testm365PowerShell: credentials = M365Credentials( user="test@contoso.onmicrosoft.com", passwd="test_password", + encrypted_passwd="test_encrypted_password", client_id="test_client_id", client_secret="test_client_secret", tenant_id="test_tenant_id", @@ -130,11 +138,9 @@ class Testm365PowerShell: ) session = M365PowerShell(credentials, identity) - # Mock read_output to return the decrypted password - session.read_output = MagicMock(return_value="decrypted_password") - - # Mock execute to return the result of read_output - session.execute = MagicMock(side_effect=lambda _: session.read_output()) + # Mock encrypt_password to return a known value + session.encrypt_password = MagicMock(return_value="encrypted_password") + session.execute = MagicMock() # Execute the test result = session.test_credentials(credentials) @@ -142,14 +148,11 @@ class Testm365PowerShell: # Verify execute was called with the correct commands session.execute.assert_any_call( - f'$securePassword = "{credentials.passwd}" | ConvertTo-SecureString' + f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' ) session.execute.assert_any_call( f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)' ) - session.execute.assert_any_call( - 'Write-Output "$($credential.GetNetworkCredential().Password)"' - ) # Verify MSAL was called with the correct parameters mock_msal.assert_called_once_with( @@ -159,7 +162,7 @@ class Testm365PowerShell: ) mock_msal_instance.acquire_token_by_username_password.assert_called_once_with( username="test@contoso.onmicrosoft.com", - password="decrypted_password", + password="test_password", # Original password, not encrypted scopes=["https://graph.microsoft.com/.default"], ) session.close() @@ -229,6 +232,7 @@ class Testm365PowerShell: credentials = M365Credentials( user="test@contoso.onmicrosoft.com", passwd="test_password", + encrypted_passwd="test_encrypted_password", client_id="test_client_id", client_secret="test_client_secret", tenant_id="test_tenant_id", @@ -262,7 +266,7 @@ class Testm365PowerShell: ) mock_msal_instance.acquire_token_by_username_password.assert_called_once_with( username="test@contoso.onmicrosoft.com", - password="decrypted_password", + password="test_password", scopes=["https://graph.microsoft.com/.default"], ) @@ -561,3 +565,55 @@ class Testm365PowerShell: ) # Verify no info messages were logged mock_info.assert_not_called() + + @patch("subprocess.Popen") + def test_encrypt_password(self, mock_popen): + credentials = M365Credentials(user="test@example.com", passwd="test_password") + identity = M365IdentityInfo( + identity_id="test_id", + identity_type="User", + tenant_id="test_tenant", + tenant_domain="example.com", + tenant_domains=["example.com"], + location="test_location", + ) + session = M365PowerShell(credentials, identity) + + # Test non-Windows system (should use utf-16le hex encoding) + from unittest import mock + + with mock.patch("platform.system", return_value="Linux"): + result = session.encrypt_password("password123") + expected = "password123".encode("utf-16le").hex() + assert result == expected + + # Test Windows system with tuple return + with mock.patch("platform.system", return_value="Windows"): + import sys + + win32crypt_mock = mock.MagicMock() + win32crypt_mock.CryptProtectData.return_value = (None, b"encrypted_bytes") + sys.modules["win32crypt"] = win32crypt_mock + + result = session.encrypt_password("password123") + assert result == b"encrypted_bytes".hex() + + # Clean up mock + del sys.modules["win32crypt"] + + # Test error handling + with mock.patch("platform.system", return_value="Windows"): + import sys + + win32crypt_mock = mock.MagicMock() + win32crypt_mock.CryptProtectData.side_effect = Exception("Test error") + sys.modules["win32crypt"] = win32crypt_mock + + with pytest.raises(Exception) as exc_info: + session.encrypt_password("password123") + assert "Error encrypting password: Test error" in str(exc_info.value) + + # Clean up mock + del sys.modules["win32crypt"] + + session.close() diff --git a/tests/providers/m365/m365_provider_test.py b/tests/providers/m365/m365_provider_test.py index 2f30efabde..457173dc44 100644 --- a/tests/providers/m365/m365_provider_test.py +++ b/tests/providers/m365/m365_provider_test.py @@ -24,7 +24,7 @@ from prowler.providers.m365.exceptions.exceptions import ( M365NoAuthenticationMethodError, M365NotValidClientIdError, M365NotValidClientSecretError, - M365NotValidEncryptedPasswordError, + M365NotValidPasswordError, M365NotValidTenantIdError, M365NotValidUserError, M365UserNotBelongingToTenantError, @@ -365,7 +365,7 @@ class TestM365Provider: assert test_connection.is_connected assert test_connection.error is None - def test_test_connection_tenant_id_client_id_client_secret_no_user_encrypted_password( + def test_test_connection_tenant_id_client_id_client_secret_no_user_password( self, ): with patch( @@ -384,26 +384,24 @@ class TestM365Provider: client_id=str(uuid4()), client_secret=str(uuid4()), user=None, - encrypted_password="test_password", + password="test_password", ) assert exception.type == M365NotValidUserError assert "The provided M365 User is not valid." in str(exception.value) - def test_test_connection_tenant_id_client_id_client_secret_user_no_encrypted_password( + def test_test_connection_tenant_id_client_id_client_secret_user_no_password( self, ): with patch( "prowler.providers.m365.m365_provider.M365Provider.validate_static_credentials" ) as mock_validate_static_credentials: - mock_validate_static_credentials.side_effect = ( - M365NotValidEncryptedPasswordError( - file=os.path.basename(__file__), - message="The provided M365 Encrypted Password is not valid.", - ) + mock_validate_static_credentials.side_effect = M365NotValidPasswordError( + file=os.path.basename(__file__), + message="The provided M365 Password is not valid.", ) - with pytest.raises(M365NotValidEncryptedPasswordError) as exception: + with pytest.raises(M365NotValidPasswordError) as exception: M365Provider.test_connection( tenant_id=str(uuid4()), region="M365Global", @@ -411,13 +409,11 @@ class TestM365Provider: client_id=str(uuid4()), client_secret=str(uuid4()), user="test@example.com", - encrypted_password=None, + password=None, ) - assert exception.type == M365NotValidEncryptedPasswordError - assert "The provided M365 Encrypted Password is not valid." in str( - exception.value - ) + assert exception.type == M365NotValidPasswordError + assert "The provided M365 Password is not valid." in str(exception.value) def test_test_connection_with_httpresponseerror(self): with patch( @@ -467,7 +463,7 @@ class TestM365Provider: def test_setup_powershell_valid_credentials(self): credentials_dict = { "user": "test@example.com", - "encrypted_password": "test_password", + "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", @@ -492,7 +488,7 @@ class TestM365Provider: ), ) assert result.user == credentials_dict["user"] - assert result.passwd == credentials_dict["encrypted_password"] + assert result.passwd == credentials_dict["password"] def test_setup_powershell_invalid_env_credentials(self): credentials = None @@ -510,7 +506,7 @@ class TestM365Provider: ) assert ( - "Missing M365_USER or M365_ENCRYPTED_PASSWORD environment variables required for credentials authentication" + "Missing M365_USER or M365_PASSWORD environment variables required for credentials authentication" in str(exc_info.value) ) mock_session.test_credentials.assert_not_called() @@ -534,7 +530,7 @@ class TestM365Provider: client_id=str(uuid4()), client_secret=str(uuid4()), user="user@otherdomain.com", - encrypted_password="test_password", + password="test_password", ) assert exception.type == M365UserNotBelongingToTenantError @@ -550,7 +546,7 @@ class TestM365Provider: client_id="12345678-1234-5678-1234-567812345678", client_secret="test_secret", user="test@example.com", - encrypted_password="test_password", + password="test_password", ) assert "The provided Tenant ID is not valid." in str(exception.value) @@ -561,7 +557,7 @@ class TestM365Provider: client_id="", client_secret="test_secret", user="test@example.com", - encrypted_password="test_password", + password="test_password", ) assert "The provided Client ID is not valid." in str(exception.value) @@ -572,7 +568,7 @@ class TestM365Provider: client_id="12345678-1234-5678-1234-567812345678", client_secret="", user="test@example.com", - encrypted_password="test_password", + password="test_password", ) assert "The provided Client Secret is not valid." in str(exception.value) @@ -583,20 +579,20 @@ class TestM365Provider: client_id="12345678-1234-5678-1234-567812345678", client_secret="test_secret", user="", - encrypted_password="test_password", + password="test_password", ) assert "The provided User is not valid." in str(exception.value) - def test_validate_static_credentials_missing_encrypted_password(self): - with pytest.raises(M365NotValidEncryptedPasswordError) as exception: + def test_validate_static_credentials_missing_password(self): + with pytest.raises(M365NotValidPasswordError) as exception: M365Provider.validate_static_credentials( tenant_id="12345678-1234-5678-1234-567812345678", client_id="12345678-1234-5678-1234-567812345678", client_secret="test_secret", user="test@example.com", - encrypted_password="", + password="", ) - assert "The provided Encrypted Password is not valid." in str(exception.value) + assert "The provided Password is not valid." in str(exception.value) def test_validate_arguments_missing_env_credentials(self): with pytest.raises(M365MissingEnvironmentCredentialsError) as exception: @@ -609,11 +605,11 @@ class TestM365Provider: client_id="test_client_id", client_secret="test_secret", user=None, - encrypted_password=None, + password=None, ) assert ( - "M365 provider requires AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, M365_USER and M365_ENCRYPTED_PASSWORD environment variables to be set when using --env-auth" + "M365 provider requires AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, M365_USER and M365_PASSWORD environment variables to be set when using --env-auth" in str(exception.value) ) @@ -655,7 +651,7 @@ class TestM365Provider: client_id=str(uuid4()), client_secret=str(uuid4()), user=f"user@{user_domain}", - encrypted_password="test_password", + password="test_password", provider_id=provider_id, ) @@ -669,7 +665,7 @@ class TestM365Provider: """Test that initialize_m365_powershell_modules is not called when init_modules is False""" credentials_dict = { "user": "test@example.com", - "encrypted_password": "test_password", + "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", @@ -703,7 +699,7 @@ class TestM365Provider: """Test that initialize_m365_powershell_modules is called when init_modules is True""" credentials_dict = { "user": "test@example.com", - "encrypted_password": "test_password", + "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", @@ -737,7 +733,7 @@ class TestM365Provider: """Test that setup_powershell handles initialization failures correctly""" credentials_dict = { "user": "test@example.com", - "encrypted_password": "test_password", + "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", @@ -808,7 +804,7 @@ class TestM365Provider: client_id=str(uuid4()), client_secret=str(uuid4()), user="user@contoso.onmicrosoft.com", - encrypted_password="test_password", + password="test_password", provider_id=provider_id, ) diff --git a/tests/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility_test.py b/tests/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility_test.py index 81209d67a4..7391bd102e 100644 --- a/tests/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility_test.py +++ b/tests/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility_test.py @@ -114,3 +114,91 @@ class Test_admincenter_groups_not_public_visibility: assert result[0].resource_name == "Group1" assert result[0].resource_id == id_group1 assert result[0].location == "global" + + def test_admincenter_group_public_visibility(self): + admincenter_client = mock.MagicMock + admincenter_client.audited_tenant = "audited_tenant" + admincenter_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility.admincenter_client", + new=admincenter_client, + ), + ): + from prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility import ( + admincenter_groups_not_public_visibility, + ) + from prowler.providers.m365.services.admincenter.admincenter_service import ( + Group, + ) + + id_group1 = str(uuid4()) + + admincenter_client.groups = { + id_group1: Group(id=id_group1, name="Group1", visibility="Public"), + } + + check = admincenter_groups_not_public_visibility() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Group Group1 has Public visibility and should be Private." + ) + assert result[0].resource == admincenter_client.groups[id_group1].dict() + assert result[0].resource_name == "Group1" + assert result[0].resource_id == id_group1 + assert result[0].location == "global" + + def test_admincenter_group_none_visibility(self): + admincenter_client = mock.MagicMock + admincenter_client.audited_tenant = "audited_tenant" + admincenter_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility.admincenter_client", + new=admincenter_client, + ), + ): + from prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility import ( + admincenter_groups_not_public_visibility, + ) + from prowler.providers.m365.services.admincenter.admincenter_service import ( + Group, + ) + + id_group1 = str(uuid4()) + + admincenter_client.groups = { + id_group1: Group(id=id_group1, name="Group1", visibility=None), + } + + check = admincenter_groups_not_public_visibility() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Group Group1 has None visibility and should be Private." + ) + assert result[0].resource == admincenter_client.groups[id_group1].dict() + assert result[0].resource_name == "Group1" + assert result[0].resource_id == id_group1 + assert result[0].location == "global" diff --git a/tests/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled_test.py b/tests/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled_test.py index b251199af6..4336820faf 100644 --- a/tests/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled_test.py +++ b/tests/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled_test.py @@ -45,7 +45,7 @@ class Test_entra_identity_protection_user_risk_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "No Conditional Access Policy is an user risk based Identity Protection Policy." + == "No Conditional Access Policy is a user risk based Identity Protection Policy." ) assert result[0].resource == {} assert result[0].resource_name == "Conditional Access Policies" @@ -119,7 +119,7 @@ class Test_entra_identity_protection_user_risk_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "No Conditional Access Policy is an user risk based Identity Protection Policy." + == "No Conditional Access Policy is a user risk based Identity Protection Policy." ) assert result[0].resource == {} assert result[0].resource_name == "Conditional Access Policies" @@ -198,7 +198,7 @@ class Test_entra_identity_protection_user_risk_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Conditional Access Policy '{display_name}' is an user risk based Identity Protection Policy but does not protect against high risk potential account compromises." + == f"Conditional Access Policy '{display_name}' is a user risk based Identity Protection Policy but does not protect against high risk potential account compromises." ) assert ( result[0].resource @@ -280,7 +280,7 @@ class Test_entra_identity_protection_user_risk_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Conditional Access Policy '{display_name}' is an user risk based Identity Protection Policy and reports high risk potential account compromises, but does not protect against them." + == f"Conditional Access Policy '{display_name}' is a user risk based Identity Protection Policy and reports high risk potential account compromises, but does not protect against them." ) assert ( result[0].resource @@ -362,7 +362,7 @@ class Test_entra_identity_protection_user_risk_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Conditional Access Policy '{display_name}' is an user risk based Identity Protection Policy and does protect against high risk potential account compromises." + == f"Conditional Access Policy '{display_name}' is a user risk based Identity Protection Policy and does protect against high risk potential account compromises." ) assert ( result[0].resource diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index 46e92aa1f1..defe476e4b 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -68,7 +68,10 @@ async def mock_entra_get_conditional_access_policies(_): ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.BLOCK], + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ], operator=GrantControlOperator.OR, authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA, ), @@ -211,7 +214,10 @@ class Test_Entra_Service: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.BLOCK], + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ], operator=GrantControlOperator.OR, authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA, ), diff --git a/tests/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user_test_for_nhn.py b/tests/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user_test_for_nhn.py index 5e545d87c3..635347a077 100644 --- a/tests/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user_test_for_nhn.py +++ b/tests/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user_test_for_nhn.py @@ -67,7 +67,7 @@ class Test_compute_instance_login_user: assert len(result) == 1 assert result[0].status == "PASS" - assert "has a appropriate login user" in result[0].status_extended + assert "has an appropriate login user" in result[0].status_extended assert result[0].resource_name == instance_name assert result[0].resource_id == instance_id 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 595d678296..dba82a9d13 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,6 +7,25 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added - New profile page with details about the user and their roles. [(#7780)](https://github.com/prowler-cloud/prowler/pull/7780) +- 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) + +### 🔄 Changed + +- 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)] +- 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)] --- @@ -22,14 +41,13 @@ All notable changes to the **Prowler UI** are documented in this file. ## [v1.7.0] (Prowler v5.7.0) - ### 🚀 Added - Add a new chart to show the split between passed and failed findings. [(#7680)](https://github.com/prowler-cloud/prowler/pull/7680) - Added `Accordion` component. [(#7700)](https://github.com/prowler-cloud/prowler/pull/7700) - Improve `Provider UID` filter by adding more context and enhancing the UI/UX. [(#7741)](https://github.com/prowler-cloud/prowler/pull/7741) - Added an AWS CloudFormation Quick Link to the IAM Role credentials step [(#7735)](https://github.com/prowler-cloud/prowler/pull/7735) -– Use `getLatestFindings` on findings page when no scan or date filters are applied. [(#7756)](https://github.com/prowler-cloud/prowler/pull/7756) + – Use `getLatestFindings` on findings page when no scan or date filters are applied. [(#7756)](https://github.com/prowler-cloud/prowler/pull/7756) ### 🐞 Fixes @@ -102,7 +120,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Revalidate the page when a role is deleted. [(#6976)](https://github.com/prowler-cloud/prowler/pull/6976) - Allows removing group visibility when creating a role. [(#7088)](https://github.com/prowler-cloud/prowler/pull/7088) - Displays correct error messages when deleting a user. [(#7089)](https://github.com/prowler-cloud/prowler/pull/7089) -- Updated label: *"Select a scan job"* → *"Select a cloud provider"*. [(#7107)](https://github.com/prowler-cloud/prowler/pull/7107) +- Updated label: _"Select a scan job"_ → _"Select a cloud provider"_. [(#7107)](https://github.com/prowler-cloud/prowler/pull/7107) - Display uid if alias is missing when creating a group. [(#7137)](https://github.com/prowler-cloud/prowler/pull/7137) --- 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/actions/providers/providers.ts b/ui/actions/providers/providers.ts index 2a36f7ca05..9bf9826c47 100644 --- a/ui/actions/providers/providers.ts +++ b/ui/actions/providers/providers.ts @@ -10,6 +10,7 @@ import { parseStringify, wait, } from "@/lib"; +import { ProvidersApiResponse, ProviderType } from "@/types/providers"; export const getProviders = async ({ page = 1, @@ -17,7 +18,7 @@ export const getProviders = async ({ sort = "", filters = {}, pageSize = 10, -}) => { +}): Promise => { const headers = await getAuthHeaders({ contentType: false }); if (isNaN(Number(page)) || page < 1) redirect("/providers"); @@ -109,7 +110,7 @@ export const updateProvider = async (formData: FormData) => { export const addProvider = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); - const providerType = formData.get("providerType") as string; + const providerType = formData.get("providerType") as ProviderType; const providerUid = formData.get("providerUid") as string; const providerAlias = formData.get("providerAlias") as string; @@ -151,9 +152,10 @@ export const addCredentialsProvider = async (formData: FormData) => { const secretName = formData.get("secretName"); const providerId = formData.get("providerId"); - const providerType = formData.get("providerType"); + const providerType = formData.get("providerType") as ProviderType; const isRole = formData.get("role_arn") !== null; + const isServiceAccount = formData.get("service_account_key") !== null; let secret = {}; let secretType = "static"; // Default to static credentials @@ -195,22 +197,39 @@ export const addCredentialsProvider = async (formData: FormData) => { client_secret: formData.get("client_secret"), tenant_id: formData.get("tenant_id"), user: formData.get("user"), - encrypted_password: formData.get("encrypted_password"), + password: formData.get("password"), }; } else if (providerType === "gcp") { - // Static credentials configuration for GCP - secret = { - client_id: formData.get("client_id"), - client_secret: formData.get("client_secret"), - refresh_token: formData.get("refresh_token"), - }; + if (isServiceAccount) { + // Service account configuration for GCP + secretType = "service_account"; + const serviceAccountKeyRaw = formData.get( + "service_account_key", + ) as string; + + try { + const serviceAccountKey = JSON.parse(serviceAccountKeyRaw); + secret = { + service_account_key: serviceAccountKey, + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error("error", error); + } + } else { + // Static credentials configuration for GCP + secret = { + client_id: formData.get("client_id"), + client_secret: formData.get("client_secret"), + refresh_token: formData.get("refresh_token"), + }; + } } else if (providerType === "kubernetes") { // Static credentials configuration for Kubernetes secret = { kubeconfig_content: formData.get("kubeconfig_content"), }; } - const bodyData = { data: { type: "provider-secrets", @@ -256,9 +275,10 @@ export const updateCredentialsProvider = async ( const url = new URL(`${apiBaseUrl}/providers/secrets/${credentialsId}`); const secretName = formData.get("secretName"); - const providerType = formData.get("providerType"); + const providerType = formData.get("providerType") as ProviderType; const isRole = formData.get("role_arn") !== null; + const isServiceAccount = formData.get("service_account_key") !== null; let secret = {}; @@ -298,15 +318,33 @@ export const updateCredentialsProvider = async ( client_secret: formData.get("client_secret"), tenant_id: formData.get("tenant_id"), user: formData.get("user"), - encrypted_password: formData.get("encrypted_password"), + password: formData.get("password"), }; } else if (providerType === "gcp") { - // Static credentials configuration for GCP - secret = { - client_id: formData.get("client_id"), - client_secret: formData.get("client_secret"), - refresh_token: formData.get("refresh_token"), - }; + if (isServiceAccount) { + // Service account configuration for GCP + const serviceAccountKeyRaw = formData.get( + "service_account_key", + ) as string; + + try { + // Parse the service account key as JSON + const serviceAccountKey = JSON.parse(serviceAccountKeyRaw); + secret = { + service_account_key: serviceAccountKey, + }; + } catch (error) { + // eslint-disable-next-line no-console + console.error("error", error); + } + } else { + // Static credentials configuration for GCP + secret = { + client_id: formData.get("client_id"), + client_secret: formData.get("client_secret"), + refresh_token: formData.get("refresh_token"), + }; + } } else if (providerType === "kubernetes") { // Static credentials configuration for Kubernetes secret = { diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index e6813bc234..019fc58d9c 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -238,10 +238,23 @@ export const getExportsZip = async (scanId: string) => { headers, }); + if (response.status === 202) { + const json = await response.json(); + const taskId = json?.data?.id; + const state = json?.data?.attributes?.state; + return { + pending: true, + state, + taskId, + }; + } + if (!response.ok) { const errorData = await response.json(); + throw new Error( - errorData?.errors?.[0]?.detail || "Failed to fetch report", + errorData?.errors?.detail || + "Unable to fetch scan report. Contact support if the issue continues.", ); } @@ -273,20 +286,28 @@ export const getComplianceCsv = async ( ); try { - const response = await fetch(url.toString(), { - headers, - }); + const response = await fetch(url.toString(), { headers }); + + if (response.status === 202) { + const json = await response.json(); + const taskId = json?.data?.id; + const state = json?.data?.attributes?.state; + return { + pending: true, + state, + taskId, + }; + } if (!response.ok) { const errorData = await response.json(); throw new Error( - errorData?.errors?.[0]?.detail || "Failed to fetch compliance report", + errorData?.errors?.detail || + "Unable to retrieve compliance report. Contact support if the issue continues.", ); } - // Get the blob data as an array buffer const arrayBuffer = await response.arrayBuffer(); - // Convert to base64 const base64 = Buffer.from(arrayBuffer).toString("base64"); return { diff --git a/ui/actions/users/tenants.ts b/ui/actions/users/tenants.ts index 0901345852..9063fc9e1b 100644 --- a/ui/actions/users/tenants.ts +++ b/ui/actions/users/tenants.ts @@ -1,4 +1,7 @@ +"use server"; + import { revalidatePath } from "next/cache"; +import { z } from "zod"; import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib/helper"; @@ -26,3 +29,67 @@ export const getAllTenants = async () => { return undefined; } }; + +const editTenantFormSchema = z + .object({ + tenantId: z.string(), + name: z.string().trim().min(1, { message: "Name is required" }), + currentName: z.string(), + }) + .refine((data) => data.name !== data.currentName, { + message: "Name must be different from the current name", + path: ["name"], + }); + +export async function updateTenantName(prevState: any, formData: FormData) { + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = Object.fromEntries(formData); + const validatedData = editTenantFormSchema.safeParse(formDataObject); + + if (!validatedData.success) { + const formFieldErrors = validatedData.error.flatten().fieldErrors; + + return { + errors: { + name: formFieldErrors?.name?.[0], + }, + }; + } + + const { tenantId, name } = validatedData.data; + + const payload = { + data: { + type: "tenants", + id: tenantId, + attributes: { + name: name.trim(), + }, + }, + }; + + try { + const url = new URL(`${apiBaseUrl}/tenants/${tenantId}`); + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Failed to update tenant name: ${response.statusText}`); + } + + await response.json(); + revalidatePath("/profile"); + return { success: "Tenant name updated successfully!" }; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error updating tenant name:", error); + return { + errors: { + general: "Error updating tenant name. Please try again.", + }, + }; + } +} diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx new file mode 100644 index 0000000000..eeb3f1a588 --- /dev/null +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -0,0 +1,269 @@ +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 { ClientAccordionWrapper } from "@/components/compliance/compliance-accordion/client-accordion-wrapper"; +import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header"; +import { SkeletonAccordion } from "@/components/compliance/compliance-skeleton-accordion"; +import { FailedSectionsChart } from "@/components/compliance/failed-sections-chart"; +import { FailedSectionsChartSkeleton } from "@/components/compliance/failed-sections-chart-skeleton"; +import { RequirementsChart } from "@/components/compliance/requirements-chart"; +import { RequirementsChartSkeleton } from "@/components/compliance/requirements-chart-skeleton"; +import { ContentLayout } from "@/components/ui"; +import { mapComplianceData, toAccordionItems } from "@/lib/compliance/ens"; +import { ScanProps } from "@/types"; +import { + FailedSection, + MappedComplianceData, + RequirementsTotals, +} from "@/types/compliance"; + +interface ComplianceDetailSearchParams { + complianceId: string; + version?: string; + scanId?: string; + "filter[region__in]"?: string; +} + +const Logo = ({ logoPath }: { logoPath: string }) => { + return ( +
+ Compliance Logo +
+ ); +}; + +const ChartsWrapper = ({ + children, + logoPath, +}: { + children: React.ReactNode; + logoPath: string; +}) => { + return ( +
+
+ {children} +
+ {logoPath && } +
+ ); +}; + +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 logoPath = `/${compliancetitle.toLowerCase()}.png`; + + // 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 = 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 ( + + + + + + + + + + + } + > + + + + ); +} + +const getComplianceData = async ( + complianceId: string, + scanId: string, + region?: string, +): Promise => { + const [attributesData, requirementsData] = await Promise.all([ + getComplianceAttributes(complianceId), + getComplianceRequirements({ + complianceId, + scanId, + region, + }), + ]); + + const mappedData = mapComplianceData(attributesData, requirementsData); + return mappedData; +}; + +const getTopFailedSections = ( + mappedData: MappedComplianceData, +): 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; + sectionData.types[type] = (sectionData.types[type] || 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 +}; + +const SSRComplianceContent = async ({ + complianceId, + scanId, + region, + logoPath, +}: { + complianceId: string; + scanId: string; + region?: string; + logoPath: string; +}) => { + if (!scanId) { + return ( +
+ + + + + +
+ ); + } + + const data = await getComplianceData(complianceId, scanId, region); + const totalRequirements: RequirementsTotals = data.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 topFailedSections = getTopFailedSections(data); + const accordionItems = toAccordionItems(data, scanId); + const defaultKeys = accordionItems.slice(0, 2).map((item) => item.key); + + return ( +
+ + + + + + + +
+ ); +}; diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index e3dab9e762..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, @@ -33,6 +30,7 @@ export default async function Compliance({ filters: { "filter[state]": "completed", }, + pageSize: 50, }); if (!scansData?.data) { @@ -83,21 +81,10 @@ export default async function Compliance({ {selectedScanId ? ( <> - - - - - - }> @@ -132,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 (
@@ -154,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 f137b451cc..82bf6d6d18 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -23,8 +23,12 @@ import { extractSortAndKey, hasDateOrScanFilter, } from "@/lib"; -import { ProviderAccountProps, ProviderProps } from "@/types"; -import { FindingProps, ScanProps, SearchParamsProps } from "@/types/components"; +import { + createProviderDetailsMapping, + extractProviderUIDs, +} from "@/lib/provider-helpers"; +import { ScanProps } from "@/types"; +import { FindingProps, SearchParamsProps } from "@/types/components"; export default async function Findings({ searchParams, @@ -52,31 +56,24 @@ export default async function Findings({ const uniqueServices = metadataInfoData?.data?.attributes?.services || []; const uniqueResourceTypes = metadataInfoData?.data?.attributes?.resource_types || []; - // Get findings data - // Extract provider UIDs - const providerUIDs: string[] = Array.from( - new Set( - providersData?.data - ?.map((provider: ProviderProps) => provider.attributes?.uid) - .filter(Boolean), - ), - ); - - const providerDetails: Array<{ [uid: string]: ProviderAccountProps }> = - providerUIDs.map((uid) => { - const provider = providersData.data.find( - (p: { attributes: { uid: string } }) => p.attributes?.uid === uid, - ); + // Extract provider UIDs and details using helper functions + const providerUIDs = providersData ? extractProviderUIDs(providersData) : []; + const providerDetails = providersData + ? createProviderDetailsMapping(providerUIDs, providersData) + : []; + // Update the Provider UID filter + const updatedFilters = filterFindings.map((filter) => { + if (filter.key === "provider_uid__in") { return { - [uid]: { - provider: provider?.attributes?.provider || "", - uid: uid, - alias: provider?.attributes?.alias ?? null, - }, + ...filter, + values: providerUIDs, + valueLabelMapping: providerDetails, }; - }); + } + return filter; + }); // Extract scan UUIDs with "completed" state and more than one resource const completedScans = scansData?.data @@ -99,37 +96,36 @@ export default async function Findings({ + }> diff --git a/ui/app/(prowler)/manage-groups/page.tsx b/ui/app/(prowler)/manage-groups/page.tsx index c5e5be9932..8af34c74b8 100644 --- a/ui/app/(prowler)/manage-groups/page.tsx +++ b/ui/app/(prowler)/manage-groups/page.tsx @@ -115,7 +115,7 @@ const SSRDataEditGroup = async ({ const associatedProviders = relationships.providers?.data.map( (provider: ProviderProps) => { const matchingProvider = providersList.find( - (p: ProviderProps) => p.id === provider.id, + (p: { id: string; name: string }) => p.id === provider.id, ); return { id: provider.id, diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx index b78d9e8439..ff26a9db15 100644 --- a/ui/app/(prowler)/profile/page.tsx +++ b/ui/app/(prowler)/profile/page.tsx @@ -8,16 +8,15 @@ import { UserBasicInfoCard } from "@/components/users/profile"; import { MembershipsCard } from "@/components/users/profile/memberships-card"; import { RolesCard } from "@/components/users/profile/roles-card"; import { SkeletonUserInfo } from "@/components/users/profile/skeleton-user-info"; -import { RoleDetail, TenantDetailData } from "@/types/users/users"; +import { isUserOwnerAndHasManageAccount } from "@/lib/permissions"; +import { RoleDetail, TenantDetailData } from "@/types/users"; export default async function Profile() { return ( -
- }> - - -
+ }> + +
); } @@ -61,14 +60,27 @@ const SSRDataUser = async () => { ), ); + const isOwner = isUserOwnerAndHasManageAccount( + roleDetails, + memberships?.data || [], + userProfile.data.id, + ); + return ( -
+
- - +
+
+ +
+
+ +
+
); }; diff --git a/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx index fce06d1a88..7f579c67ff 100644 --- a/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx +++ b/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx @@ -4,10 +4,15 @@ import { ViaCredentialsForm, ViaRoleForm, } from "@/components/providers/workflow/forms"; -import { SelectViaAWS } from "@/components/providers/workflow/forms/select-via-aws/select-via-aws"; +import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws"; +import { + SelectViaGCP, + ViaServiceAccountForm, +} from "@/components/providers/workflow/forms/select-credentials-type/gcp"; +import { ProviderType } from "@/types/providers"; interface Props { - searchParams: { type: string; id: string; via?: string }; + searchParams: { type: ProviderType; id: string; via?: string }; } export default function AddCredentialsPage({ searchParams }: Props) { @@ -17,14 +22,24 @@ export default function AddCredentialsPage({ searchParams }: Props) { )} + {searchParams.type === "gcp" && !searchParams.via && ( + + )} + {((searchParams.type === "aws" && searchParams.via === "credentials") || - searchParams.type !== "aws") && ( + (searchParams.type === "gcp" && searchParams.via === "credentials") || + (searchParams.type !== "aws" && searchParams.type !== "gcp")) && ( )} {searchParams.type === "aws" && searchParams.via === "role" && ( )} + + {searchParams.type === "gcp" && + searchParams.via === "service-account" && ( + + )} ); } diff --git a/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx index 3a680c3be8..185a107244 100644 --- a/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx +++ b/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx @@ -1,53 +1,47 @@ import React from "react"; -import { InfoIcon } from "@/components/icons"; +import { CredentialsUpdateInfo } from "@/components/providers"; import { UpdateViaCredentialsForm, UpdateViaRoleForm, } from "@/components/providers/workflow/forms"; -import { SelectViaAWS } from "@/components/providers/workflow/forms/select-via-aws/select-via-aws"; +import { UpdateViaServiceAccountForm } from "@/components/providers/workflow/forms/update-via-service-account-key-form"; +import { ProviderType } from "@/types/providers"; interface Props { - searchParams: { type: string; id: string; via?: string }; + searchParams: { + type: ProviderType; + id: string; + via?: string; + secretId?: string; + }; } export default function UpdateCredentialsPage({ searchParams }: Props) { return ( <> - {searchParams.type === "aws" && !searchParams.via && ( - <> -
-

- To update provider credentials,{" "} - - the same type that was originally configured must be used. - -

-
- -

- If the provider was configured with static credentials, updates - must also use static credentials. If it was configured with a - role, updates must use a role. -

-
-

- To switch from static credentials to a role (or vice versa), the - provider must be deleted and set up again. -

- -
- - )} + {(searchParams.type === "aws" || searchParams.type === "gcp") && + !searchParams.via && ( + + )} {((searchParams.type === "aws" && searchParams.via === "credentials") || - searchParams.type !== "aws") && ( + (searchParams.type === "gcp" && searchParams.via === "credentials") || + (searchParams.type !== "aws" && searchParams.type !== "gcp")) && ( )} {searchParams.type === "aws" && searchParams.via === "role" && ( )} + + {searchParams.type === "gcp" && + searchParams.via === "service-account" && ( + + )} ); } diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index d9446674c1..383695c7e3 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -14,6 +14,10 @@ import { SkeletonTableScans } from "@/components/scans/table"; import { ColumnGetScans } from "@/components/scans/table/scans"; import { ContentLayout } from "@/components/ui"; import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; +import { + createProviderDetailsMapping, + extractProviderUIDs, +} from "@/lib/provider-helpers"; import { ProviderProps, ScanProps, SearchParamsProps } from "@/types"; export default async function Scans({ @@ -61,6 +65,24 @@ export default async function Scans({ scan.attributes.state === "available", ); + // Extract provider UIDs and create provider details mapping for filtering + const providerUIDs = providersData ? extractProviderUIDs(providersData) : []; + const providerDetails = providersData + ? createProviderDetailsMapping(providerUIDs, providersData) + : []; + + // Update the Provider UID filter + const updatedFilters = filterScans.map((filter) => { + if (filter.key === "provider_uid__in") { + return { + ...filter, + values: providerUIDs, + valueLabelMapping: providerDetails, + }; + } + return filter; + }); + return ( <> {thereIsNoProviders && ( @@ -89,7 +111,7 @@ export default async function Scans({
- +
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..06bb394fa0 --- /dev/null +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -0,0 +1,179 @@ +"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 { ComplianceId, Requirement } from "@/types/compliance"; +import { FindingProps, FindingsResponse } from "@/types/components"; + +import { ComplianceCustomDetails } from "../compliance-custom-details/ens-details"; + +interface ClientAccordionContentProps { + requirement: Requirement; + scanId: string; +} + +export const ClientAccordionContent = ({ + requirement, + scanId, +}: 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 ( + 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]); + + 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
; + }; + + const renderDetails = () => { + if (!complianceId) { + return null; + } + + switch (complianceId) { + case "ens_rd2022_aws": + return ( +
+ +
+ ); + default: + return null; + } + }; + + 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..92f79e032d --- /dev/null +++ b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx @@ -0,0 +1,26 @@ +import { FindingStatus, StatusFindingBadge } from "@/components/ui/table"; +import { translateType } from "@/lib/compliance/ens"; + +interface ComplianceAccordionRequirementTitleProps { + type: string; + name: string; + status: FindingStatus; +} + +export const ComplianceAccordionRequirementTitle = ({ + type, + name, + status, +}: ComplianceAccordionRequirementTitleProps) => { + return ( +
+
+ + {translateType(type)}: + + {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..e458cef948 --- /dev/null +++ b/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx @@ -0,0 +1,135 @@ +import { Tooltip } from "@nextui-org/react"; + +interface ComplianceAccordionTitleProps { + label: string; + pass: number; + fail: number; + manual?: number; +} + +export const ComplianceAccordionTitle = ({ + label, + pass, + fail, + manual = 0, +}: 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 && ( + + 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 56b6cec402..db7539401f 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -2,8 +2,8 @@ import { Card, CardBody, Progress } from "@nextui-org/react"; import Image from "next/image"; -import { useSearchParams } from "next/navigation"; -import React from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import React, { useState } from "react"; import { DownloadIconButton, toast } from "@/components/ui"; import { downloadComplianceCsv } from "@/lib/helper"; @@ -19,6 +19,7 @@ interface ComplianceCardProps { prevTotalRequirements: number; scanId: string; complianceId: string; + id: string; } export const ComplianceCard: React.FC = ({ @@ -28,9 +29,12 @@ 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); const formatTitle = (title: string) => { return title.split("-").join(" "); @@ -67,8 +71,39 @@ 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("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 { + await downloadComplianceCsv(scanId, complianceId, toast); + } finally { + setIsDownloading(false); + } + }; + return ( - +
= ({ - downloadComplianceCsv(scanId, complianceId, toast) - } + onDownload={handleDownload} textTooltip="Download compliance CSV report" isDisabled={hasRegionFilter} + isDownloading={isDownloading} /> {/* {getScanChange()} */}
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..db0652c770 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/ens-details.tsx @@ -0,0 +1,38 @@ +import { Requirement } from "@/types/compliance"; + +export const ComplianceCustomDetails = ({ + requirement, +}: { + requirement: Requirement; +}) => { + return ( +
+
+ {requirement.description} +
+
+
+ Level: + {requirement.nivel} +
+ {requirement.dimensiones && requirement.dimensiones.length > 0 && ( +
+ Dimensions: +
+ {requirement.dimensiones.map( + (dimension: string, index: number) => ( + + {dimension} + + ), + )} +
+
+ )} +
+
+ ); +}; 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..9e31f4de5d --- /dev/null +++ b/ui/components/compliance/compliance-header/compliance-header.tsx @@ -0,0 +1,47 @@ +"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; +} + +export const ComplianceHeader = ({ + scans, + uniqueRegions, + showSearch = true, + showRegionFilter = true, +}: ComplianceHeaderProps) => { + return ( + <> + {showSearch && } + + + {showRegionFilter && ( + <> + + + + )} + + + ); +}; 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/compliance-skeleton-accordion.tsx b/ui/components/compliance/compliance-skeleton-accordion.tsx new file mode 100644 index 0000000000..f1077b53ee --- /dev/null +++ b/ui/components/compliance/compliance-skeleton-accordion.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/failed-sections-chart-skeleton.tsx b/ui/components/compliance/failed-sections-chart-skeleton.tsx new file mode 100644 index 0000000000..24164ae55c --- /dev/null +++ b/ui/components/compliance/failed-sections-chart-skeleton.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Skeleton } from "@nextui-org/react"; + +export const FailedSectionsChartSkeleton = () => { + 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/failed-sections-chart.tsx b/ui/components/compliance/failed-sections-chart.tsx new file mode 100644 index 0000000000..32076f1b66 --- /dev/null +++ b/ui/components/compliance/failed-sections-chart.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { + Bar, + BarChart, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { translateType } from "@/lib/compliance/ens"; + +type FailedSectionItem = { + name: string; + total: number; + types: { + [key: string]: number; + }; +}; + +interface FailedSectionsListProps { + sections: FailedSectionItem[]; +} + +const title = ( +

+ Failed Sections (Top 5) +

+); + +export const FailedSectionsChart = ({ 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 "#868994"; + } + }; + + 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))), + ); + + // Check if there are no failed sections + if (!sections || sections.length === 0) { + return ( +
+ {title} +
+

There are no failed sections

+
+
+ ); + } + + return ( +
+ {title} + +
+ + + + + [ + value, + translateType(name), + ]} + cursor={false} + /> + translateType(value)} + wrapperStyle={{ + fontSize: "10px", + display: "flex", + justifyContent: "center", + width: "100%", + }} + iconType="circle" + layout="horizontal" + verticalAlign="bottom" + height={36} + /> + {allTypes.map((type, i) => ( + + ))} + + +
+
+ ); +}; diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts index d0ba1eb027..fc730a267d 100644 --- a/ui/components/compliance/index.ts +++ b/ui/components/compliance/index.ts @@ -1,4 +1,4 @@ export * from "./compliance-card"; -export * from "./compliance-scan-info"; +export * from "./compliance-header/compliance-scan-info"; export * from "./compliance-skeleton-grid"; export * from "./no-scans-available"; diff --git a/ui/components/compliance/requirements-chart-skeleton.tsx b/ui/components/compliance/requirements-chart-skeleton.tsx new file mode 100644 index 0000000000..eae6ff6e4e --- /dev/null +++ b/ui/components/compliance/requirements-chart-skeleton.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Skeleton } from "@nextui-org/react"; + +export const RequirementsChartSkeleton = () => { + return ( +
+ {/* Title skeleton */} + +
+ + + {/* Pie chart skeleton */} +
+ {/* Outer circle */} + +
+ + + {/* Inner circle (donut hole) */} +
+ + {/* Center text skeleton */} +
+ +
+ + +
+ +
+
+ + {/* Bottom stats skeleton */} +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+
+ ); +}; diff --git a/ui/components/compliance/requirements-chart.tsx b/ui/components/compliance/requirements-chart.tsx new file mode 100644 index 0000000000..eb40619a8b --- /dev/null +++ b/ui/components/compliance/requirements-chart.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Cell, Label, Pie, PieChart, Tooltip } from "recharts"; + +import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart"; + +interface RequirementsChartProps { + 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 RequirementsChart = ({ + pass, + fail, + manual, +}: RequirementsChartProps) => { + 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/filters/data-filters.ts b/ui/components/filters/data-filters.ts index 8d712bfb70..ec964bbc26 100644 --- a/ui/components/filters/data-filters.ts +++ b/ui/components/filters/data-filters.ts @@ -30,6 +30,11 @@ export const filterScans = [ labelCheckboxGroup: "Trigger", values: ["scheduled", "manual"], }, + { + key: "provider_uid__in", + labelCheckboxGroup: "Provider UID", + values: [], + }, // Add more filter categories as needed ]; @@ -38,21 +43,31 @@ export const filterFindings = [ key: "severity__in", labelCheckboxGroup: "Severity", values: ["critical", "high", "medium", "low", "informational"], + index: 1, }, { key: "status__in", labelCheckboxGroup: "Status", values: ["PASS", "FAIL", "MANUAL"], - }, - { - key: "delta__in", - labelCheckboxGroup: "Delta", - values: ["new", "changed"], + index: 2, }, { key: "provider_type__in", labelCheckboxGroup: "Cloud Provider", values: ["aws", "azure", "m365", "gcp", "kubernetes"], + index: 4, + }, + { + key: "provider_uid__in", + labelCheckboxGroup: "Provider UID", + values: [], + index: 8, + }, + { + key: "delta__in", + labelCheckboxGroup: "Delta", + values: ["new", "changed"], + index: 3, }, // Add more filter categories as needed ]; diff --git a/ui/components/findings/table/column-findings.tsx b/ui/components/findings/table/column-findings.tsx index 8bfeac0a7f..a8636ab750 100644 --- a/ui/components/findings/table/column-findings.tsx +++ b/ui/components/findings/table/column-findings.tsx @@ -1,11 +1,16 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import { Database } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { DataTableRowDetails } from "@/components/findings/table"; import { InfoIcon } from "@/components/icons"; -import { DateWithTime, EntityInfoShort } from "@/components/ui/entities"; +import { + DateWithTime, + EntityInfoShort, + SnippetChip, +} from "@/components/ui/entities"; import { TriggerSheet } from "@/components/ui/sheet"; import { DataTableColumnHeader, @@ -63,7 +68,7 @@ const FindingDetailsCell = ({ row }: { row: any }) => { }; return ( -
+
} title="Finding Details" @@ -105,8 +110,10 @@ export const ColumnFindings: ColumnDef[] = [ return (
- {(delta === "new" || delta === "changed") && ( + {delta === "new" || delta === "changed" ? ( + ) : ( +
)}

{checktitle} @@ -119,6 +126,21 @@ export const ColumnFindings: ColumnDef[] = [ ); }, }, + { + accessorKey: "resourceName", + header: "Resource name", + cell: ({ row }) => { + const resourceName = getResourceData(row, "name"); + + return ( + `...${value.slice(-10)}`} + icon={} + /> + ); + }, + }, { accessorKey: "severity", header: ({ column }) => ( 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/icons/compliance/IconCompliance.tsx b/ui/components/icons/compliance/IconCompliance.tsx index 543f6c68c8..c73d0d14ee 100644 --- a/ui/components/icons/compliance/IconCompliance.tsx +++ b/ui/components/icons/compliance/IconCompliance.tsx @@ -8,9 +8,12 @@ import GDPRLogo from "./gdpr.svg"; import GxPLogo from "./gxp-aws.svg"; import HIPAALogo from "./hipaa.svg"; import ISOLogo from "./iso-27001.svg"; +import KISALogo from "./kisa.svg"; import MITRELogo from "./mitre-attack.svg"; +import NIS2Logo from "./nis2.svg"; import NISTLogo from "./nist.svg"; import PCILogo from "./pci-dss.svg"; +import PROWLERTHREATLogo from "./prowlerThreat.svg"; import RBILogo from "./rbi.svg"; import SOC2Logo from "./soc2.svg"; @@ -60,4 +63,13 @@ export const getComplianceIcon = (complianceTitle: string) => { if (complianceTitle.toLowerCase().includes("soc2")) { return SOC2Logo; } + if (complianceTitle.toLowerCase().includes("kisa")) { + return KISALogo; + } + if (complianceTitle.toLowerCase().includes("prowlerthreatscore")) { + return PROWLERTHREATLogo; + } + if (complianceTitle.toLowerCase().includes("nis2")) { + return NIS2Logo; + } }; diff --git a/ui/components/icons/compliance/kisa.svg b/ui/components/icons/compliance/kisa.svg new file mode 100644 index 0000000000..8450a6566d --- /dev/null +++ b/ui/components/icons/compliance/kisa.svg @@ -0,0 +1,439 @@ + +Created with Fabric.js 5.2.4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/components/icons/compliance/nis2.svg b/ui/components/icons/compliance/nis2.svg new file mode 100644 index 0000000000..a8773aed8f --- /dev/null +++ b/ui/components/icons/compliance/nis2.svg @@ -0,0 +1,28966 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/components/icons/compliance/prowlerThreat.svg b/ui/components/icons/compliance/prowlerThreat.svg new file mode 100644 index 0000000000..f14d50c323 --- /dev/null +++ b/ui/components/icons/compliance/prowlerThreat.svg @@ -0,0 +1,16 @@ + + + + + + + + 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 row.original; }; @@ -81,8 +85,10 @@ export const ColumnNewFindingsToDate: ColumnDef[] = [ return (
- {(delta === "new" || delta === "changed") && ( + {delta === "new" || delta === "changed" ? ( + ) : ( +
)}

{checktitle} @@ -95,6 +101,21 @@ export const ColumnNewFindingsToDate: ColumnDef[] = [ ); }, }, + { + accessorKey: "resourceName", + header: "Resource name", + cell: ({ row }) => { + const resourceName = getResourceData(row, "name"); + + return ( + `...${value.slice(-10)}`} + icon={} + /> + ); + }, + }, { accessorKey: "severity", header: "Severity", diff --git a/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx b/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx index 6a24119681..c6ac03661b 100644 --- a/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx +++ b/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx @@ -1,65 +1,11 @@ -import { Card, Skeleton } from "@nextui-org/react"; import React from "react"; +import { SkeletonTable } from "@/components/ui/skeleton/skeleton"; + export const SkeletonTableNewFindings = () => { return ( - - {/* Table headers */} -

- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- - {/* Table body */} -
- {[...Array(3)].map((_, index) => ( -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- ))} -
- +
+ +
); }; diff --git a/ui/components/providers/credentials-update-info.tsx b/ui/components/providers/credentials-update-info.tsx new file mode 100644 index 0000000000..1b32e2391f --- /dev/null +++ b/ui/components/providers/credentials-update-info.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { InfoIcon } from "@/components/icons"; +import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws"; +import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp"; +import { ProviderType } from "@/types/providers"; + +interface UpdateCredentialsInfoProps { + providerType: ProviderType; + initialVia?: string; +} + +export const CredentialsUpdateInfo = ({ + providerType, + initialVia, +}: UpdateCredentialsInfoProps) => { + const renderSelectComponent = () => { + if (providerType === "aws") { + return ; + } + if (providerType === "gcp") { + return ; + } + return null; + }; + + return ( +
+

+ To update provider credentials,{" "} + + the same type that was originally configured must be used. + +

+
+ +

+ If the provider was configured with static credentials, updates must + also use static credentials. If it was configured with a role in AWS + (or service account in GCP),{" "} + updates must use the same type. +

+
+

+ To switch from one type to another, the provider must be deleted and set + up again. +

+ {renderSelectComponent()} +
+ ); +}; diff --git a/ui/components/providers/index.ts b/ui/components/providers/index.ts index 99dc5901b7..967b661073 100644 --- a/ui/components/providers/index.ts +++ b/ui/components/providers/index.ts @@ -1,4 +1,5 @@ export * from "./add-provider-button"; +export * from "./credentials-update-info"; export * from "./forms/delete-form"; export * from "./link-to-scans"; export * from "./provider-info"; diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index f16585e34b..8527bfd754 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -3,7 +3,7 @@ import { Chip } from "@nextui-org/react"; import { ColumnDef } from "@tanstack/react-table"; -import { DateWithTime, SnippetId } from "@/components/ui/entities"; +import { DateWithTime, SnippetChip } from "@/components/ui/entities"; import { DataTableColumnHeader } from "@/components/ui/table"; import { ProviderProps } from "@/types"; @@ -74,7 +74,7 @@ export const ColumnProviders: ColumnDef[] = [ const { attributes: { uid }, } = getProviderData(row); - return ; + return ; }, }, { diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index a33ebd786b..fa1d48abd4 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -10,7 +10,6 @@ import * as z from "zod"; import { useToast } from "@/components/ui"; import { CustomButton, CustomInput } from "@/components/ui/custom"; import { Form } from "@/components/ui/form"; -import { ProviderType } from "@/types"; import { addProvider } from "../../../../actions/providers/providers"; import { addProviderFormSchema, ApiError } from "../../../../types"; @@ -171,7 +170,7 @@ export const ConnectAccountForm = () => { {/* Step 2: UID, alias, and credentials (if AWS) */} {prevStep === 2 && ( <> - + ; diff --git a/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/index.ts b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/index.ts new file mode 100644 index 0000000000..39f365d1c7 --- /dev/null +++ b/ui/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/index.ts @@ -0,0 +1,2 @@ +export * from "./aws-role-credentials-form"; +export * from "./aws-static-credentials-form"; diff --git a/ui/components/providers/workflow/forms/select-credentials-type/aws/index.ts b/ui/components/providers/workflow/forms/select-credentials-type/aws/index.ts new file mode 100644 index 0000000000..102dc9031e --- /dev/null +++ b/ui/components/providers/workflow/forms/select-credentials-type/aws/index.ts @@ -0,0 +1,2 @@ +export * from "./radio-group-aws-via-credentials-type-form"; +export * from "./select-via-aws"; diff --git a/ui/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/aws/radio-group-aws-via-credentials-type-form.tsx similarity index 97% rename from ui/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx rename to ui/components/providers/workflow/forms/select-credentials-type/aws/radio-group-aws-via-credentials-type-form.tsx index bb53c5a5b9..8ce7eb3407 100644 --- a/ui/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/aws/radio-group-aws-via-credentials-type-form.tsx @@ -14,7 +14,7 @@ type RadioGroupAWSViaCredentialsFormProps = { onChange?: (value: string) => void; }; -export const RadioGroupAWSViaCredentialsForm = ({ +export const RadioGroupAWSViaCredentialsTypeForm = ({ control, isInvalid, errorMessage, diff --git a/ui/components/providers/workflow/forms/select-via-aws/select-via-aws.tsx b/ui/components/providers/workflow/forms/select-credentials-type/aws/select-via-aws.tsx similarity index 85% rename from ui/components/providers/workflow/forms/select-via-aws/select-via-aws.tsx rename to ui/components/providers/workflow/forms/select-credentials-type/aws/select-via-aws.tsx index 59197bb40e..01f7fe8e7c 100644 --- a/ui/components/providers/workflow/forms/select-via-aws/select-via-aws.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/aws/select-via-aws.tsx @@ -5,7 +5,7 @@ import { useForm } from "react-hook-form"; import { Form } from "@/components/ui/form"; -import { RadioGroupAWSViaCredentialsForm } from "../radio-group-aws-via-credentials-form"; +import { RadioGroupAWSViaCredentialsTypeForm } from "./radio-group-aws-via-credentials-type-form"; interface SelectViaAWSProps { initialVia?: string; @@ -27,7 +27,7 @@ export const SelectViaAWS = ({ initialVia }: SelectViaAWSProps) => { return (
- ; + control: Control; }) => { return ( <> diff --git a/ui/components/providers/workflow/forms/select-credentials-type/gcp/credentials-type/gcp-service-account-key-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/gcp/credentials-type/gcp-service-account-key-form.tsx new file mode 100644 index 0000000000..00779a7b22 --- /dev/null +++ b/ui/components/providers/workflow/forms/select-credentials-type/gcp/credentials-type/gcp-service-account-key-form.tsx @@ -0,0 +1,34 @@ +import { Control } from "react-hook-form"; + +import { CustomTextarea } from "@/components/ui/custom"; +import { GCPServiceAccountKey } from "@/types"; + +export const GCPServiceAccountKeyForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect via Service Account Key +
+
+ Please provide the service account key for your GCP credentials. +
+
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/select-credentials-type/gcp/credentials-type/index.ts b/ui/components/providers/workflow/forms/select-credentials-type/gcp/credentials-type/index.ts new file mode 100644 index 0000000000..407b1dc25b --- /dev/null +++ b/ui/components/providers/workflow/forms/select-credentials-type/gcp/credentials-type/index.ts @@ -0,0 +1,2 @@ +export * from "./gcp-default-credentials-form"; +export * from "./gcp-service-account-key-form"; diff --git a/ui/components/providers/workflow/forms/select-credentials-type/gcp/index.ts b/ui/components/providers/workflow/forms/select-credentials-type/gcp/index.ts new file mode 100644 index 0000000000..3c6c994a16 --- /dev/null +++ b/ui/components/providers/workflow/forms/select-credentials-type/gcp/index.ts @@ -0,0 +1,3 @@ +export * from "./radio-group-gcp-via-credentials-type-form"; +export * from "./select-via-gcp"; +export * from "./via-service-account-form"; diff --git a/ui/components/providers/workflow/forms/select-credentials-type/gcp/radio-group-gcp-via-credentials-type-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/gcp/radio-group-gcp-via-credentials-type-form.tsx new file mode 100644 index 0000000000..2990f95ddf --- /dev/null +++ b/ui/components/providers/workflow/forms/select-credentials-type/gcp/radio-group-gcp-via-credentials-type-form.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { RadioGroup } from "@nextui-org/react"; +import React from "react"; +import { Control, Controller } from "react-hook-form"; + +import { CustomRadio } from "@/components/ui/custom"; +import { FormMessage } from "@/components/ui/form"; + +type RadioGroupAWSViaCredentialsFormProps = { + control: Control; + isInvalid: boolean; + errorMessage?: string; + onChange?: (value: string) => void; +}; + +export const RadioGroupGCPViaCredentialsTypeForm = ({ + control, + isInvalid, + errorMessage, + onChange, +}: RadioGroupAWSViaCredentialsFormProps) => { + return ( + ( + <> + { + field.onChange(value); + if (onChange) { + onChange(value); + } + }} + > +
+ + Using Service Account + + +
+ Connect via Service Account Key +
+
+ + Using Application Default Credentials + + +
+ + Connect via Application Default Credentials + +
+
+
+
+ {errorMessage && ( + + {errorMessage} + + )} + + )} + /> + ); +}; diff --git a/ui/components/providers/workflow/forms/select-credentials-type/gcp/select-via-gcp.tsx b/ui/components/providers/workflow/forms/select-credentials-type/gcp/select-via-gcp.tsx new file mode 100644 index 0000000000..b998005c8b --- /dev/null +++ b/ui/components/providers/workflow/forms/select-credentials-type/gcp/select-via-gcp.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; + +import { Form } from "@/components/ui/form"; + +import { RadioGroupGCPViaCredentialsTypeForm } from "./radio-group-gcp-via-credentials-type-form"; + +interface SelectViaGCPProps { + initialVia?: string; +} + +export const SelectViaGCP = ({ initialVia }: SelectViaGCPProps) => { + const router = useRouter(); + const form = useForm({ + defaultValues: { + gcpCredentialsType: initialVia || "", + }, + }); + + const handleSelectionChange = (value: string) => { + const url = new URL(window.location.href); + url.searchParams.set("via", value); + router.push(url.toString()); + }; + + return ( + + + + ); +}; diff --git a/ui/components/providers/workflow/forms/select-credentials-type/gcp/via-service-account-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/gcp/via-service-account-form.tsx new file mode 100644 index 0000000000..1e174ad21d --- /dev/null +++ b/ui/components/providers/workflow/forms/select-credentials-type/gcp/via-service-account-form.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Divider } from "@nextui-org/react"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Control, useForm } from "react-hook-form"; +import * as z from "zod"; + +import { addCredentialsProvider } from "@/actions/providers/providers"; +import { ProviderTitleDocs } from "@/components/providers/workflow"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { + addCredentialsServiceAccountFormSchema, + ApiError, + GCPServiceAccountKey, + ProviderType, +} from "@/types"; + +import { GCPServiceAccountKeyForm } from "./credentials-type/gcp-service-account-key-form"; + +export const ViaServiceAccountForm = ({ + searchParams, +}: { + searchParams: { type: ProviderType; id: string }; +}) => { + const router = useRouter(); + const { toast } = useToast(); + const searchParamsObj = useSearchParams(); + + // Handler for back button + const handleBackStep = () => { + const currentParams = new URLSearchParams(window.location.search); + currentParams.delete("via"); + router.push(`?${currentParams.toString()}`); + }; + + const providerType = searchParams.type; + const providerId = searchParams.id; + + const formSchema = addCredentialsServiceAccountFormSchema(providerType); + type FormSchemaType = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId, + providerType, + ...(providerType === "gcp" + ? { + service_account_key: "", + secretName: "", + } + : {}), + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormSchemaType) => { + const formData = new FormData(); + + Object.entries(values).forEach(([key, value]) => { + if (value !== undefined && value !== "") { + formData.append(key, String(value)); + } + }); + + try { + const data = await addCredentialsProvider(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + + switch (error.source.pointer) { + case "/data/attributes/secret/service_account_key": + form.setError("service_account_key" as keyof FormSchemaType, { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + } else { + router.push( + `/providers/test-connection?type=${providerType}&id=${providerId}`, + ); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error during submission:", error); + toast({ + variant: "destructive", + title: "Submission failed", + description: "An error occurred while processing your request.", + }); + } + }; + + return ( +
+ + + + + + + + + {providerType === "gcp" && ( + } + /> + )} + +
+ {searchParamsObj.get("via") === "service-account" && ( + } + isDisabled={isLoading} + > + Back + + )} + } + > + {isLoading ? <>Loading : Next} + +
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/select-via-aws/index.ts b/ui/components/providers/workflow/forms/select-via-aws/index.ts deleted file mode 100644 index 8dd9822258..0000000000 --- a/ui/components/providers/workflow/forms/select-via-aws/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./select-via-aws"; diff --git a/ui/components/providers/workflow/forms/update-via-credentials-form.tsx b/ui/components/providers/workflow/forms/update-via-credentials-form.tsx index 9c751eb399..76da05efcb 100644 --- a/ui/components/providers/workflow/forms/update-via-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/update-via-credentials-form.tsx @@ -17,15 +17,15 @@ import { ApiError, AWSCredentials, AzureCredentials, - GCPCredentials, + GCPDefaultCredentials, KubernetesCredentials, M365Credentials, } from "@/types"; import { ProviderTitleDocs } from "../provider-title-docs"; -import { AWScredentialsForm } from "./via-credentials/aws-credentials-form"; +import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credentials-type"; +import { GCPDefaultCredentialsForm } from "./select-credentials-type/gcp/credentials-type"; import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form"; -import { GCPcredentialsForm } from "./via-credentials/gcp-credentials-form"; import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form"; import { M365CredentialsForm } from "./via-credentials/m365-credentials-form"; @@ -38,7 +38,7 @@ type FormType = CredentialsFormSchema & AWSCredentials & AzureCredentials & M365Credentials & - GCPCredentials & + GCPDefaultCredentials & KubernetesCredentials; export const UpdateViaCredentialsForm = ({ @@ -58,7 +58,7 @@ export const UpdateViaCredentialsForm = ({ router.push(`?${currentParams.toString()}`); }; - const providerType = searchParams.type; + const providerType = searchParams.type as ProviderType; const providerId = searchParams.id; const providerSecretId = searchParams.secretId || ""; const formSchema = addCredentialsFormSchema(providerType); @@ -86,7 +86,7 @@ export const UpdateViaCredentialsForm = ({ client_secret: "", tenant_id: "", user: "", - encrypted_password: "", + password: "", } : providerType === "gcp" ? { @@ -153,8 +153,8 @@ export const UpdateViaCredentialsForm = ({ message: errorMessage, }); break; - case "/data/attributes/secret/encrypted_password": - form.setError("encrypted_password", { + case "/data/attributes/secret/password": + form.setError("password", { type: "server", message: errorMessage, }); @@ -201,12 +201,12 @@ export const UpdateViaCredentialsForm = ({ - + {providerType === "aws" && ( - } /> )} @@ -221,8 +221,8 @@ export const UpdateViaCredentialsForm = ({ /> )} {providerType === "gcp" && ( - } + } /> )} {providerType === "kubernetes" && ( diff --git a/ui/components/providers/workflow/forms/update-via-role-form.tsx b/ui/components/providers/workflow/forms/update-via-role-form.tsx index c0d9c24917..b207a27bbc 100644 --- a/ui/components/providers/workflow/forms/update-via-role-form.tsx +++ b/ui/components/providers/workflow/forms/update-via-role-form.tsx @@ -17,7 +17,7 @@ import { AWSCredentialsRole, } from "@/types"; -import { AWSCredentialsRoleForm } from "./via-role/aws-role-form"; +import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type"; export const UpdateViaRoleForm = ({ searchParams, @@ -150,7 +150,7 @@ export const UpdateViaRoleForm = ({ {/* Conditional AWS Form */} {providerType === "aws" && ( - } setValue={ form.setValue as unknown as UseFormSetValue diff --git a/ui/components/providers/workflow/forms/update-via-service-account-key-form.tsx b/ui/components/providers/workflow/forms/update-via-service-account-key-form.tsx new file mode 100644 index 0000000000..17991cb90c --- /dev/null +++ b/ui/components/providers/workflow/forms/update-via-service-account-key-form.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Divider } from "@nextui-org/react"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Control, useForm } from "react-hook-form"; +import * as z from "zod"; + +import { updateCredentialsProvider } from "@/actions/providers/providers"; +import { ProviderTitleDocs } from "@/components/providers/workflow"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { + addCredentialsServiceAccountFormSchema, + ApiError, + GCPServiceAccountKey, + ProviderType, +} from "@/types"; + +import { GCPServiceAccountKeyForm } from "./select-credentials-type/gcp/credentials-type"; + +export const UpdateViaServiceAccountForm = ({ + searchParams, +}: { + searchParams: { type: string; id: string; secretId?: string }; +}) => { + const router = useRouter(); + const { toast } = useToast(); + const searchParamsObj = useSearchParams(); + + // Handler for back button + const handleBackStep = () => { + const currentParams = new URLSearchParams(window.location.search); + currentParams.delete("via"); + router.push(`?${currentParams.toString()}`); + }; + + const providerType = searchParams.type as ProviderType; + const providerId = searchParams.id; + const providerSecretId = searchParams.secretId || ""; + + const formSchema = addCredentialsServiceAccountFormSchema(providerType); + type FormSchemaType = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId, + providerType, + ...(providerType === "gcp" + ? { + service_account_key: "", + secretName: "", + } + : {}), + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormSchemaType) => { + if (!providerSecretId) { + toast({ + variant: "destructive", + title: "Missing Secret ID", + description: "Cannot update credentials without a valid secret ID.", + }); + return; + } + + const formData = new FormData(); + + Object.entries(values).forEach(([key, value]) => { + if (value !== undefined && value !== "") { + formData.append(key, String(value)); + } + }); + + try { + const data = await updateCredentialsProvider(providerSecretId, formData); + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + + switch (error.source.pointer) { + case "/data/attributes/secret/service_account_key": + form.setError("service_account_key" as keyof FormSchemaType, { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + } else { + router.push( + `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`, + ); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error during submission:", error); + toast({ + variant: "destructive", + title: "Submission failed", + description: "An error occurred while processing your request.", + }); + } + }; + + return ( +
+ + + + + + + + + {providerType === "gcp" && ( + } + /> + )} + +
+ {searchParamsObj.get("via") === "service-account" && ( + } + isDisabled={isLoading} + > + Back + + )} + } + > + {isLoading ? <>Loading : Next} + +
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/via-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials-form.tsx index 38b3a98a86..d73a875872 100644 --- a/ui/components/providers/workflow/forms/via-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/via-credentials-form.tsx @@ -11,21 +11,21 @@ import { addCredentialsProvider } from "@/actions/providers/providers"; import { useToast } from "@/components/ui"; import { CustomButton } from "@/components/ui/custom"; import { Form } from "@/components/ui/form"; -import { ProviderType } from "@/types"; import { addCredentialsFormSchema, ApiError, AWSCredentials, AzureCredentials, - GCPCredentials, + GCPDefaultCredentials, KubernetesCredentials, M365Credentials, + ProviderType, } from "@/types"; import { ProviderTitleDocs } from "../provider-title-docs"; -import { AWScredentialsForm } from "./via-credentials/aws-credentials-form"; +import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credentials-type"; +import { GCPDefaultCredentialsForm } from "./select-credentials-type/gcp/credentials-type"; import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form"; -import { GCPcredentialsForm } from "./via-credentials/gcp-credentials-form"; import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form"; import { M365CredentialsForm } from "./via-credentials/m365-credentials-form"; @@ -37,7 +37,7 @@ type CredentialsFormSchema = z.infer< type FormType = CredentialsFormSchema & AWSCredentials & AzureCredentials & - GCPCredentials & + GCPDefaultCredentials & KubernetesCredentials & M365Credentials; @@ -58,7 +58,7 @@ export const ViaCredentialsForm = ({ router.push(`?${currentParams.toString()}`); }; - const providerType = searchParams.type; + const providerType = searchParams.type as ProviderType; const providerId = searchParams.id; const formSchema = addCredentialsFormSchema(providerType); @@ -85,7 +85,7 @@ export const ViaCredentialsForm = ({ client_secret: "", tenant_id: "", user: "", - encrypted_password: "", + password: "", } : providerType === "gcp" ? { @@ -152,8 +152,8 @@ export const ViaCredentialsForm = ({ message: errorMessage, }); break; - case "/data/attributes/secret/encrypted_password": - form.setError("encrypted_password", { + case "/data/attributes/secret/password": + form.setError("password", { type: "server", message: errorMessage, }); @@ -200,12 +200,12 @@ export const ViaCredentialsForm = ({ - + {providerType === "aws" && ( - } /> )} @@ -220,8 +220,8 @@ export const ViaCredentialsForm = ({ /> )} {providerType === "gcp" && ( - } + } /> )} {providerType === "kubernetes" && ( diff --git a/ui/components/providers/workflow/forms/via-credentials/index.ts b/ui/components/providers/workflow/forms/via-credentials/index.ts index 88bf5587f0..9058fc7362 100644 --- a/ui/components/providers/workflow/forms/via-credentials/index.ts +++ b/ui/components/providers/workflow/forms/via-credentials/index.ts @@ -1,5 +1,3 @@ -export * from "./aws-credentials-form"; export * from "./azure-credentials-form"; -export * from "./gcp-credentials-form"; export * from "./k8s-credentials-form"; export * from "./m365-credentials-form"; diff --git a/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx index 1ba6024ffc..d7c2d0bef1 100644 --- a/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx @@ -64,14 +64,14 @@ export const M365CredentialsForm = ({ /> ); diff --git a/ui/components/providers/workflow/forms/via-role-form.tsx b/ui/components/providers/workflow/forms/via-role-form.tsx index 3bed8d3af7..84c6bfc3e7 100644 --- a/ui/components/providers/workflow/forms/via-role-form.tsx +++ b/ui/components/providers/workflow/forms/via-role-form.tsx @@ -17,7 +17,7 @@ import { AWSCredentialsRole, } from "@/types"; -import { AWSCredentialsRoleForm } from "./via-role/aws-role-form"; +import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type"; export const ViaRoleForm = ({ searchParams, @@ -149,7 +149,7 @@ export const ViaRoleForm = ({ {providerType === "aws" && ( - } setValue={ form.setValue as unknown as UseFormSetValue diff --git a/ui/components/providers/workflow/forms/via-role/index.ts b/ui/components/providers/workflow/forms/via-role/index.ts deleted file mode 100644 index 763c90c0c0..0000000000 --- a/ui/components/providers/workflow/forms/via-role/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./aws-role-form"; diff --git a/ui/components/scans/table/scans/column-get-scans.tsx b/ui/components/scans/table/scans/column-get-scans.tsx index 9082618eb8..4d6796b6bc 100644 --- a/ui/components/scans/table/scans/column-get-scans.tsx +++ b/ui/components/scans/table/scans/column-get-scans.tsx @@ -5,15 +5,14 @@ import { ColumnDef } from "@tanstack/react-table"; import { useSearchParams } from "next/navigation"; import { InfoIcon } from "@/components/icons"; -import { DownloadIconButton, toast } from "@/components/ui"; import { DateWithTime, EntityInfoShort } from "@/components/ui/entities"; import { TriggerSheet } from "@/components/ui/sheet"; import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table"; -import { downloadScanZip } from "@/lib/helper"; import { ProviderType, ScanProps } from "@/types"; import { LinkToFindingsFromScan } from "../../link-to-findings-from-scan"; import { TriggerIcon } from "../../trigger-icon"; +import { DataTableDownloadDetails } from "./data-table-download-details"; import { DataTableRowActions } from "./data-table-row-actions"; import { DataTableRowDetails } from "./data-table-row-details"; @@ -130,15 +129,10 @@ export const ColumnGetScans: ColumnDef[] = [
), cell: ({ row }) => { - const scanId = row.original.id; - const scanState = row.original.attributes?.state; - return ( - downloadScanZip(scanId, toast)} - isDisabled={scanState !== "completed"} - /> +
+ +
); }, }, diff --git a/ui/components/scans/table/scans/data-table-download-details.tsx b/ui/components/scans/table/scans/data-table-download-details.tsx new file mode 100644 index 0000000000..4579ef3c56 --- /dev/null +++ b/ui/components/scans/table/scans/data-table-download-details.tsx @@ -0,0 +1,34 @@ +import { Row } from "@tanstack/react-table"; +import { useState } from "react"; + +import { DownloadIconButton, useToast } from "@/components/ui"; +import { downloadScanZip } from "@/lib"; + +interface DataTableDownloadDetailsProps { + row: Row; +} + +export function DataTableDownloadDetails({ + row, +}: DataTableDownloadDetailsProps) { + const { toast } = useToast(); + const [isDownloading, setIsDownloading] = useState(false); + + const scanId = (row.original as { id: string }).id; + const scanState = (row.original as any).attributes?.state; + + const handleDownload = async () => { + setIsDownloading(true); + await downloadScanZip(scanId, toast); + setIsDownloading(false); + }; + + return ( + + ); +} diff --git a/ui/components/ui/accordion/Accordion.tsx b/ui/components/ui/accordion/Accordion.tsx index aa029a121c..e9257bf015 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,17 +67,54 @@ 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", + 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", + "p-2 rounded-lg data-[hover=true]:bg-gray-50 dark:data-[hover=true]:bg-gray-800/50 w-full flex items-center", content: "p-2", }} > - + ))} 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/custom/custom-alert-modal.tsx b/ui/components/ui/custom/custom-alert-modal.tsx index 2df428104e..3878bb686f 100644 --- a/ui/components/ui/custom/custom-alert-modal.tsx +++ b/ui/components/ui/custom/custom-alert-modal.tsx @@ -23,9 +23,10 @@ export const CustomAlertModal: React.FC = ({ size="xl" classNames={{ base: "dark:bg-prowler-blue-800", - closeButton: "right-0", + closeButton: "rounded-md", }} backdrop="blur" + placement="center" > {(_onClose) => ( diff --git a/ui/components/ui/custom/custom-dropdown-filter.tsx b/ui/components/ui/custom/custom-dropdown-filter.tsx index ee18bab9bc..254efa5024 100644 --- a/ui/components/ui/custom/custom-dropdown-filter.tsx +++ b/ui/components/ui/custom/custom-dropdown-filter.tsx @@ -10,167 +10,183 @@ import { PopoverTrigger, ScrollShadow, } from "@nextui-org/react"; -import { XCircle } from "lucide-react"; +import { ChevronDown, X } from "lucide-react"; import { useSearchParams } from "next/navigation"; import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { PlusCircleIcon } from "@/components/icons"; -import { useUrlFilters } from "@/hooks/use-url-filters"; import { CustomDropdownFilterProps } from "@/types"; import { EntityInfoShort } from "../entities"; -const filterSelectedClass = - "inline-flex items-center border py-1 text-xs transition-colors border-transparent bg-default-500 text-secondary-foreground hover:bg-default-500/80 rounded-md px-2 font-normal"; - -export const CustomDropdownFilter: React.FC = ({ +export const CustomDropdownFilter = ({ filter, onFilterChange, -}) => { +}: CustomDropdownFilterProps) => { const searchParams = useSearchParams(); - const { clearFilter } = useUrlFilters(); const [groupSelected, setGroupSelected] = useState(new Set()); - const [pendingClearFilter, setPendingClearFilter] = useState( - null, + const [isOpen, setIsOpen] = useState(false); + + const filterValues = useMemo(() => filter?.values || [], [filter?.values]); + const selectedValues = Array.from(groupSelected).filter( + (value) => value !== "all", ); + const isAllSelected = + selectedValues.length === filterValues.length && filterValues.length > 0; - const allFilterKeys = useMemo(() => filter?.values || [], [filter?.values]); - - const getActiveFilter = useMemo(() => { - const currentFilters: Record = {}; - Array.from(searchParams.entries()).forEach(([key, value]) => { - if (key.startsWith("filter[") && key.endsWith("]")) { - const filterKey = key.slice(7, -1); - if (filter && filter.key === filterKey) { - // eslint-disable-next-line security/detect-object-injection - currentFilters[filterKey] = value; - } - } - }); - return currentFilters; - }, [searchParams, filter]); - - const memoizedFilterValues = useMemo( - () => filter?.values || [], - [filter?.values], - ); + const activeFilterValue = useMemo(() => { + const filterParam = searchParams.get(`filter[${filter?.key}]`); + return filterParam ? filterParam.split(",") : []; + }, [searchParams, filter?.key]); + // Sync URL state with component state useEffect(() => { - if (filter && getActiveFilter[filter.key]) { - const activeValues = getActiveFilter[filter.key].split(","); - const newSelection = new Set(activeValues); - if (newSelection.size === memoizedFilterValues.length) { + if (activeFilterValue.length > 0) { + const newSelection = new Set(activeFilterValue); + if (newSelection.size === filterValues.length) { newSelection.add("all"); } setGroupSelected(newSelection); } else { setGroupSelected(new Set()); } - }, [getActiveFilter, filter?.key, memoizedFilterValues, filter]); + }, [activeFilterValue, filterValues.length]); + + const updateSelection = useCallback( + (newValues: string[]) => { + const actualValues = newValues.filter((key) => key !== "all"); + const newSelection = new Set(actualValues); + + // Auto-add "all" if all items are selected + if ( + actualValues.length === filterValues.length && + filterValues.length > 0 + ) { + newSelection.add("all"); + } + + setGroupSelected(newSelection); + + // Notify parent with actual values (excluding "all") + onFilterChange?.(filter.key, actualValues); + }, + [filterValues.length, onFilterChange, filter.key], + ); const onSelectionChange = useCallback( (keys: string[]) => { - setGroupSelected((prevGroupSelected) => { - const newSelection = new Set(keys); + const currentSelection = Array.from(groupSelected); + const newKeys = new Set(keys); + const oldKeys = new Set(currentSelection); - if ( - newSelection.size === allFilterKeys.length && - !newSelection.has("all") - ) { - return new Set(["all", ...allFilterKeys]); - } else if (prevGroupSelected.has("all")) { - newSelection.delete("all"); - return new Set(allFilterKeys.filter((key) => newSelection.has(key))); - } - return newSelection; - }); + // Check if "all" was just toggled + const allWasSelected = oldKeys.has("all"); + const allIsSelected = newKeys.has("all"); - if (onFilterChange && filter) { - const selectedValues = keys.filter((key) => key !== "all"); - onFilterChange(filter.key, selectedValues); + if (allIsSelected && !allWasSelected) { + // "all" was just selected - select all items + updateSelection(filterValues); + } else if (!allIsSelected && allWasSelected) { + // "all" was just deselected - deselect all items + updateSelection([]); + } else if (allIsSelected && allWasSelected) { + // "all" was already selected, but individual items changed + // Remove "all" and keep only the individual selections + const individualSelections = keys.filter((key) => key !== "all"); + updateSelection(individualSelections); + } else { + // Normal individual selection without "all" + updateSelection(keys); } }, - [allFilterKeys, onFilterChange, filter], + [groupSelected, updateSelection, filterValues], ); - const handleSelectAllClick = useCallback(() => { - setGroupSelected((prevGroupSelected: Set) => { - const newSelection: Set = prevGroupSelected.has("all") - ? new Set() - : new Set(["all", ...allFilterKeys]); + const handleClearAll = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + updateSelection([]); + }, + [updateSelection], + ); - if (onFilterChange && filter) { - const selectedValues = Array.from(newSelection).filter( - (key) => key !== "all", - ); - onFilterChange(filter.key, selectedValues); - } - - return newSelection; - }); - }, [allFilterKeys, onFilterChange, filter]); - - // Update the pending clear filter - const onClearFilter = useCallback((filterKey: string) => { - setPendingClearFilter(filterKey); - }, []); - - // Execute the update in the router after the render - useEffect(() => { - if (pendingClearFilter && filter) { - clearFilter(pendingClearFilter); - setPendingClearFilter(null); // Reset the state - } - }, [pendingClearFilter, clearFilter, filter]); + const getDisplayLabel = useCallback( + (value: string) => { + const entity = filter.valueLabelMapping?.find((entry) => entry[value])?.[ + value + ]; + return entity?.alias || entity?.uid || value; + }, + [filter.valueLabelMapping], + ); return ( -
- - -
+
= ({ wrapper: "checkbox-update", }} value="all" - isSelected={groupSelected.has("all")} - onClick={handleSelectAllClick} > Select All - {memoizedFilterValues.map((value) => { - // Find the corresponding entity from valueLabelMapping - const matchingEntry = filter.valueLabelMapping?.find( + {filterValues.map((value) => { + const entity = filter.valueLabelMapping?.find( (entry) => entry[value], - ); - const entity = matchingEntry?.[value]; + )?.[value]; return ( = ({ {entity ? ( diff --git a/ui/components/ui/custom/custom-server-input.tsx b/ui/components/ui/custom/custom-server-input.tsx new file mode 100644 index 0000000000..58318d96c9 --- /dev/null +++ b/ui/components/ui/custom/custom-server-input.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Input } from "@nextui-org/react"; +import React from "react"; + +interface CustomServerInputProps { + name: string; + label?: string; + labelPlacement?: "inside" | "outside"; + variant?: "flat" | "bordered" | "underlined" | "faded"; + type?: string; + placeholder?: string; + isRequired?: boolean; + isInvalid?: boolean; + errorMessage?: string; +} + +/** + * Custom input component that is used to display a server input without useForm hook. + */ +export const CustomServerInput = ({ + name, + type = "text", + label, + labelPlacement = "outside", + placeholder, + variant = "bordered", + isRequired = false, + isInvalid = false, + errorMessage, +}: CustomServerInputProps) => { + return ( +
+ +
+ ); +}; diff --git a/ui/components/ui/custom/index.ts b/ui/components/ui/custom/index.ts index d236bdc1d1..4e7a55b03f 100644 --- a/ui/components/ui/custom/index.ts +++ b/ui/components/ui/custom/index.ts @@ -6,4 +6,5 @@ export * from "./custom-dropdown-selection"; export * from "./custom-input"; export * from "./custom-loader"; export * from "./custom-radio"; +export * from "./custom-server-input"; export * from "./custom-textarea"; diff --git a/ui/components/ui/download-icon-button/download-icon-button.tsx b/ui/components/ui/download-icon-button/download-icon-button.tsx index 364ae0fe39..e84ecfa568 100644 --- a/ui/components/ui/download-icon-button/download-icon-button.tsx +++ b/ui/components/ui/download-icon-button/download-icon-button.tsx @@ -11,6 +11,7 @@ interface DownloadIconButtonProps { ariaLabel?: string; isDisabled?: boolean; textTooltip?: string; + isDownloading?: boolean; } export const DownloadIconButton = ({ @@ -19,20 +20,24 @@ export const DownloadIconButton = ({ ariaLabel = "Download report", isDisabled, textTooltip = "Download report", + isDownloading = false, }: DownloadIconButtonProps) => { return (
onDownload(paramId)} className="p-0 text-default-500 hover:text-primary disabled:opacity-30" isIconOnly ariaLabel={ariaLabel} size="sm" > - +
diff --git a/ui/components/ui/entities/entity-info-short.tsx b/ui/components/ui/entities/entity-info-short.tsx index c1c957642b..ecf7687e2f 100644 --- a/ui/components/ui/entities/entity-info-short.tsx +++ b/ui/components/ui/entities/entity-info-short.tsx @@ -1,9 +1,10 @@ import React from "react"; +import { IdIcon } from "@/components/icons"; import { ProviderType } from "@/types"; import { getProviderLogo } from "./get-provider-logo"; -import { SnippetId } from "./snippet-id"; +import { SnippetChip } from "./snippet-chip"; interface EntityInfoProps { cloudProvider: ProviderType; @@ -26,9 +27,10 @@ export const EntityInfoShort: React.FC = ({ {entityAlias && ( {entityAlias} )} - } />
diff --git a/ui/components/ui/entities/index.ts b/ui/components/ui/entities/index.ts index cb329a03e3..fb13c99282 100644 --- a/ui/components/ui/entities/index.ts +++ b/ui/components/ui/entities/index.ts @@ -3,5 +3,4 @@ export * from "./entity-info-short"; export * from "./get-provider-logo"; export * from "./info-field"; export * from "./scan-status"; -export * from "./snippet-id"; -export * from "./snippet-label"; +export * from "./snippet-chip"; diff --git a/ui/components/ui/entities/info-field.tsx b/ui/components/ui/entities/info-field.tsx index 0c2908b6e0..600231bda2 100644 --- a/ui/components/ui/entities/info-field.tsx +++ b/ui/components/ui/entities/info-field.tsx @@ -5,9 +5,10 @@ import { InfoIcon } from "lucide-react"; interface InfoFieldProps { label: string; children: React.ReactNode; - variant?: "default" | "simple"; + variant?: "default" | "simple" | "transparent"; className?: string; tooltipContent?: string; + inline?: boolean; } { + if (inline) { + return ( +
+ + + {label}: + {tooltipContent && ( + +
+ +
+
+ )} +
+
+
{children}
+
+ ); + } + return (
@@ -45,8 +67,10 @@ export const InfoField = ({
{children}
+ ) : variant === "transparent" ? ( +
{children}
) : ( -
+
{children}
)} diff --git a/ui/components/ui/entities/snippet-chip.tsx b/ui/components/ui/entities/snippet-chip.tsx new file mode 100644 index 0000000000..2652c2d76a --- /dev/null +++ b/ui/components/ui/entities/snippet-chip.tsx @@ -0,0 +1,47 @@ +import { cn, Snippet, Tooltip } from "@nextui-org/react"; +import React from "react"; + +import { CopyIcon, DoneIcon } from "@/components/icons"; + +interface SnippetChipProps { + value: string; + ariaLabel?: string; + icon?: React.ReactNode; + hideCopyButton?: boolean; + formatter?: (value: string) => string; + className?: string; +} +export const SnippetChip = ({ + value, + hideCopyButton = false, + ariaLabel = `Copy ${value} to clipboard`, + icon, + formatter, + className, + ...props +}: SnippetChipProps) => { + return ( + } + checkIcon={} + hideCopyButton={hideCopyButton} + codeString={value} + {...props} + > +
+ {icon} + + + {formatter ? formatter(value) : value} + + +
+
+ ); +}; diff --git a/ui/components/ui/entities/snippet-id.tsx b/ui/components/ui/entities/snippet-id.tsx deleted file mode 100644 index cb4b1a7e87..0000000000 --- a/ui/components/ui/entities/snippet-id.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Snippet, Tooltip } from "@nextui-org/react"; -import React from "react"; - -import { CopyIcon, DoneIcon, IdIcon } from "@/components/icons"; - -interface SnippetIdProps { - entityId: string; - hideCopyButton?: boolean; - [key: string]: any; -} -export const SnippetId: React.FC = ({ - entityId, - hideCopyButton = false, - ...props -}) => { - return ( - } - checkIcon={} - hideCopyButton={hideCopyButton} - {...props} - > -

- - - - {entityId} - - -

-
- ); -}; diff --git a/ui/components/ui/entities/snippet-label.tsx b/ui/components/ui/entities/snippet-label.tsx deleted file mode 100644 index 18525d6fe5..0000000000 --- a/ui/components/ui/entities/snippet-label.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Snippet } from "@nextui-org/react"; -import React from "react"; - -import { CopyIcon, DoneIcon } from "@/components/icons"; - -interface SnippetLabelProps { - label: string; - [key: string]: any; -} -export const SnippetLabel: React.FC = ({ - label, - ...props -}) => { - return ( - label !== "" && ( - } - checkIcon={} - {...props} - > -

- {label} -

-
- ) - ); -}; 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 655297659c..de2015619f 100644 --- a/ui/components/ui/table/data-table-filter-custom.tsx +++ b/ui/components/ui/table/data-table-filter-custom.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState } from "react"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { CustomFilterIcon } from "@/components/icons"; import { CustomButton, CustomDropdownFilter } from "@/components/ui/custom"; @@ -20,6 +20,21 @@ export const DataTableFilterCustom = ({ const { updateFilter } = useUrlFilters(); const [showFilters, setShowFilters] = useState(defaultOpen); + // Sort filters by index property, with fallback to original order for filters without index + const sortedFilters = useMemo(() => { + return [...filters].sort((a, b) => { + // If both have index, sort by index + if (a.index !== undefined && b.index !== undefined) { + return a.index - b.index; + } + // If only one has index, prioritize the one with index + if (a.index !== undefined) return -1; + if (b.index !== undefined) return 1; + // If neither has index, maintain original order + return 0; + }); + }, [filters]); + const pushDropdownFilter = useCallback( (key: string, values: string[]) => { updateFilter(key, values.length > 0 ? values : null); @@ -30,7 +45,7 @@ export const DataTableFilterCustom = ({ return (
4 ? "flex-col" : "flex-col md:flex-row" + sortedFilters.length > 4 ? "flex-col" : "flex-col md:flex-row" } gap-4`} > } onPress={() => setShowFilters(!showFilters)} - className="w-fit" + className="w-full max-w-fit" >

{showFilters ? "Hide Filters" : "Show Filters"} @@ -56,12 +71,12 @@ export const DataTableFilterCustom = ({ >
4 + sortedFilters.length >= 4 ? "grid-cols-1 md:grid-cols-4" : "grid-cols-1 md:grid-cols-3" }`} > - {filters.map((filter) => ( + {sortedFilters.map((filter) => ( { 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/forms/edit-tenant-form.tsx b/ui/components/users/forms/edit-tenant-form.tsx new file mode 100644 index 0000000000..1e4d83d523 --- /dev/null +++ b/ui/components/users/forms/edit-tenant-form.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { Dispatch, SetStateAction, useEffect } from "react"; +import { useFormState, useFormStatus } from "react-dom"; + +import { updateTenantName } from "@/actions/users/tenants"; +import { SaveIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomServerInput } from "@/components/ui/custom"; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + + return ( + } + > + {pending ? <>Loading : Save} + + ); +}; + +export const EditTenantForm = ({ + tenantId, + tenantName, + setIsOpen, +}: { + tenantId: string; + tenantName?: string; + setIsOpen: Dispatch>; +}) => { + const [state, formAction] = useFormState(updateTenantName, null); + const { toast } = useToast(); + + useEffect(() => { + if (state?.success) { + toast({ + title: "Changed successfully", + description: state.success, + }); + setIsOpen(false); + } else if (state?.errors?.general) { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: state.errors.general, + }); + } + }, [state, toast, setIsOpen]); + + return ( +
+
+ Current name: {tenantName} +
+ + + + {/* Hidden inputs for Server Action */} + + + +
+ setIsOpen(false)} + > + Cancel + + + +
+ + ); +}; diff --git a/ui/components/users/forms/index.ts b/ui/components/users/forms/index.ts index a081952ade..7d863e16c5 100644 --- a/ui/components/users/forms/index.ts +++ b/ui/components/users/forms/index.ts @@ -1,2 +1,3 @@ export * from "./delete-form"; export * from "./edit-form"; +export * from "./edit-tenant-form"; diff --git a/ui/components/users/profile/membership-item.tsx b/ui/components/users/profile/membership-item.tsx index f9d28a9f7d..a22f33dda0 100644 --- a/ui/components/users/profile/membership-item.tsx +++ b/ui/components/users/profile/membership-item.tsx @@ -1,27 +1,77 @@ -import { Chip } from "@nextui-org/react"; +"use client"; -import { DateWithTime } from "@/components/ui/entities"; -import { MembershipDetailData } from "@/types/users/users"; +import { Chip } from "@nextui-org/react"; +import { useState } from "react"; + +import { CustomAlertModal, CustomButton } from "@/components/ui/custom"; +import { DateWithTime, InfoField } from "@/components/ui/entities"; +import { MembershipDetailData } from "@/types/users"; + +import { EditTenantForm } from "../forms"; export const MembershipItem = ({ membership, tenantName, + tenantId, + isOwner, }: { membership: MembershipDetailData; tenantName: string; -}) => ( -
-
-
- - {membership.attributes.role} - -

{tenantName}

+ tenantId: string; + isOwner: boolean; +}) => { + const [isEditOpen, setIsEditOpen] = useState(false); + + return ( + <> + + + + +
+
+ + {membership.attributes.role} + + +
+ + + {tenantName} + + + + + +
+ + {isOwner && ( + setIsEditOpen(true)} + > + Change name + + )} +
-
-
- Joined on: - -
-
-); + + ); +}; diff --git a/ui/components/users/profile/memberships-card.tsx b/ui/components/users/profile/memberships-card.tsx index c752882156..0aba1b4834 100644 --- a/ui/components/users/profile/memberships-card.tsx +++ b/ui/components/users/profile/memberships-card.tsx @@ -1,15 +1,17 @@ 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"; export const MembershipsCard = ({ memberships, tenantsMap, + isOwner, }: { memberships: MembershipDetailData[]; tenantsMap: Record; + isOwner: boolean; }) => { return ( @@ -26,16 +28,18 @@ export const MembershipsCard = ({
No memberships found.
) : (
- {memberships.map((membership) => ( - - ))} + {memberships.map((membership) => { + const tenantId = membership.relationships.tenant.data.id; + return ( + + ); + })}
)} diff --git a/ui/components/users/profile/role-item.tsx b/ui/components/users/profile/role-item.tsx index 27c056843c..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; @@ -35,7 +35,7 @@ export const RoleItem = ({ role: RoleData; roleDetail?: RoleDetail; }) => { - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(true); if (!roleDetail) { return ( 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 5c4ea0bbbd..7085953062 100644 --- a/ui/components/users/profile/user-basic-info-card.tsx +++ b/ui/components/users/profile/user-basic-info-card.tsx @@ -1,48 +1,16 @@ "use client"; -import { Card, CardBody, Divider, Tooltip } from "@nextui-org/react"; -import { CircleUserRound } from "lucide-react"; -import { useState } from "react"; +import { Card, CardBody, Divider } from "@nextui-org/react"; -import { CopyIcon, DoneIcon } from "@/components/icons"; -import { CustomButton } from "@/components/ui/custom/custom-button"; -import { DateWithTime } from "@/components/ui/entities"; -import { UserDataWithRoles } from "@/types/users/users"; +import { DateWithTime, InfoField, SnippetChip } from "@/components/ui/entities"; +import { UserDataWithRoles } from "@/types/users"; + +import { ProwlerShort } from "../../icons"; const TenantIdCopy = ({ id }: { id: string }) => { - const [copied, setCopied] = useState(false); - - const handleCopyTenantId = () => { - navigator.clipboard.writeText(id); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - return ( -
-

- Active organization ID: -

-
- - - - {id} - - {copied ? ( - - ) : ( - - )} - - -
+
+
); }; @@ -59,30 +27,32 @@ export const UserBasicInfoCard = ({ return ( -
- -
-

Name:

- {name} +
+
+
-
-

Email:

- {email} -
-
-

Company:

- {company_name} -
-
-

- Date Joined: -

- - +
+ {name} + + {email} + {company_name && ` | ${company_name}`}
- - +
+ +
+
+
+ + + +
+
+
+ + + +
diff --git a/ui/lib/compliance/ens.tsx b/ui/lib/compliance/ens.tsx new file mode 100644 index 0000000000..bab025dd06 --- /dev/null +++ b/ui/lib/compliance/ens.tsx @@ -0,0 +1,242 @@ +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, + MappedComplianceData, + Requirement, + RequirementItemData, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +export const translateType = (type: string) => { + 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, +): MappedComplianceData => { + 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]; + 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, + type, + 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: MappedComplianceData, + 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: ( + + ), + items: [], + isDisabled: + requirement.check_ids.length === 0 && + requirement.manual === 0, + }; + }), + isDisabled: + control.pass === 0 && + control.fail === 0 && + control.manual === 0, + }; + }), + }; + }), + }; + }); +}; diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index db664e2a18..7a13fb266f 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -61,14 +61,22 @@ export const downloadScanZip = async ( ) => { const result = await getExportsZip(scanId); - if (result?.success && result?.data) { + if (result?.pending) { + toast({ + title: "The report is still being generated", + description: "Please try again in a few minutes.", + }); + return; + } + + if (result?.success && result.data) { const binaryString = window.atob(result.data); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } - const blob = new Blob([bytes], { type: "application/zip" }); + const blob = new Blob([bytes], { type: "application/zip" }); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -82,11 +90,11 @@ export const downloadScanZip = async ( title: "Download Complete", description: "Your scan report has been downloaded successfully.", }); - } else if (result?.error) { + } else { toast({ variant: "destructive", title: "Download Failed", - description: result.error, + description: result?.error || "An unknown error occurred.", }); } }; @@ -95,37 +103,64 @@ export const downloadComplianceCsv = async ( scanId: string, complianceId: string, toast: ReturnType["toast"], -) => { +): Promise => { const result = await getComplianceCsv(scanId, complianceId); - if (result?.success && result?.data) { - const binaryString = window.atob(result.data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - const blob = new Blob([bytes], { type: "text/csv" }); - - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = result.filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - + if (result?.pending) { toast({ - title: "Download Complete", - description: "The compliance report has been downloaded successfully.", + title: "The report is still being generated", + description: "Please try again in a few minutes.", }); - } else if (result?.error) { + return; + } + + if (result?.success && result.data) { + try { + const binaryString = window.atob(result.data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + const blob = new Blob([bytes], { type: "text/csv" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = result.filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + toast({ + title: "Download Complete", + description: "The compliance report has been downloaded successfully.", + }); + } catch (error) { + toast({ + variant: "destructive", + title: "Download Failed", + description: "An error occurred while processing the file.", + }); + } + return; + } + + if (result?.error) { toast({ variant: "destructive", title: "Download Failed", description: result.error, }); + return; } + + // Unexpected case + toast({ + variant: "destructive", + title: "Download Failed", + description: "Unexpected response. Please try again later.", + }); }; export const isGoogleOAuthEnabled = @@ -136,13 +171,12 @@ export const isGithubOAuthEnabled = !!process.env.SOCIAL_GITHUB_OAUTH_CLIENT_ID && !!process.env.SOCIAL_GITHUB_OAUTH_CLIENT_SECRET; -export async function checkTaskStatus( +export const checkTaskStatus = async ( taskId: string, -): Promise<{ completed: boolean; error?: string }> { - const MAX_RETRIES = 20; // Define the maximum number of attempts before stopping the polling - const RETRY_DELAY = 1000; // Delay time between each poll (in milliseconds) - - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + maxRetries: number = 20, + retryDelay: number = 1500, +): Promise<{ completed: boolean; error?: string }> => { + for (let attempt = 0; attempt < maxRetries; attempt++) { const task = await getTask(taskId); if (task.error) { @@ -162,7 +196,7 @@ export async function checkTaskStatus( case "scheduled": case "executing": // Continue waiting if the task is still in progress - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); + await new Promise((resolve) => setTimeout(resolve, retryDelay)); break; default: return { completed: false, error: "Unexpected task state" }; @@ -170,7 +204,7 @@ export async function checkTaskStatus( } return { completed: false, error: "Max retries exceeded" }; -} +}; export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/ui/lib/permissions.ts b/ui/lib/permissions.ts index 6e0862637d..7d1d4448e8 100644 --- a/ui/lib/permissions.ts +++ b/ui/lib/permissions.ts @@ -1,4 +1,24 @@ -import { RolePermissionAttributes } from "@/types/users/users"; +import { RolePermissionAttributes } from "@/types/users"; + +export const isUserOwnerAndHasManageAccount = ( + roles: any[], + memberships: any[], + userId: string, +): boolean => { + const isOwner = memberships.some( + (membership) => + membership.attributes.role === "owner" && + membership.relationships?.user?.data?.id === userId, + ); + + const hasManageAccount = roles.some( + (role) => + role.attributes.manage_account === true && + role.relationships?.users?.data?.some((user: any) => user.id === userId), + ); + + return isOwner && hasManageAccount; +}; /** * Get the permissions for a user role diff --git a/ui/lib/provider-helpers.ts b/ui/lib/provider-helpers.ts new file mode 100644 index 0000000000..ff6b9d0ef4 --- /dev/null +++ b/ui/lib/provider-helpers.ts @@ -0,0 +1,40 @@ +import { + ProviderAccountProps, + ProviderProps, + ProvidersApiResponse, +} from "@/types/providers"; + +export const extractProviderUIDs = ( + providersData: ProvidersApiResponse, +): string[] => { + if (!providersData?.data) return []; + + return Array.from( + new Set( + providersData.data + .map((provider: ProviderProps) => provider.attributes?.uid) + .filter(Boolean), + ), + ); +}; + +export const createProviderDetailsMapping = ( + providerUIDs: string[], + providersData: ProvidersApiResponse, +): Array<{ [uid: string]: ProviderAccountProps }> => { + if (!providersData?.data) return []; + + return providerUIDs.map((uid) => { + const provider = providersData.data.find( + (p: { attributes: { uid: string } }) => p.attributes?.uid === uid, + ); + + return { + [uid]: { + provider: provider?.attributes?.provider || "aws", + uid: uid, + alias: provider?.attributes?.alias ?? null, + }, + }; + }); +}; 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/styles/globals.css b/ui/styles/globals.css index 3d8d40186d..f3b0a3e4ae 100644 --- a/ui/styles/globals.css +++ b/ui/styles/globals.css @@ -32,6 +32,7 @@ } @layer utilities { + /* Hide scrollbar */ .no-scrollbar { scrollbar-width: none; @@ -41,3 +42,13 @@ @apply mr-2 bg-background; } } + +@layer components { + + .animate-download-icon polyline, + .animate-download-icon line { + @apply animate-drop-arrow; + transform-box: fill-box; + transform-origin: center; + } +} \ No newline at end of file diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js index 8245f1971f..664e360167 100644 --- a/ui/tailwind.config.js +++ b/ui/tailwind.config.js @@ -170,10 +170,16 @@ module.exports = { "50%": { left: "20%", width: "80%" }, "100%": { left: "100%", width: "100%" }, }, + dropArrow: { + '0%': { transform: 'translateY(-8px)', opacity: '0' }, + '50%': { opacity: '1' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, }, animation: { "collapsible-down": "collapsible-down 0.2s ease-out", "collapsible-up": "collapsible-up 0.2s ease-out", + "drop-arrow": "dropArrow 0.6s ease-out infinite", }, screens: { "3xl": "1920px", // Add breakpoint to optimize layouts for large screens. diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts new file mode 100644 index 0000000000..beeafcd836 --- /dev/null +++ b/ui/types/compliance.ts @@ -0,0 +1,119 @@ +export type RequirementStatus = "PASS" | "FAIL" | "MANUAL" | "No findings"; + +export type ComplianceId = "ens_rd2022_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; + type: string; + pass: number; + fail: number; + manual: number; + check_ids: string[]; + // ENS + nivel?: string; + dimensiones?: string[]; +} + +export interface Control { + label: string; + type: 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 type MappedComplianceData = Framework[]; + +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 AttributesMetadata { + IdGrupoControl: string; + Marco: string; + Categoria: string; + DescripcionControl: string; + Tipo: string; + Nivel: string; + Dimensiones: string[]; + ModoEjecucion: string; + Dependencias: any[]; +} + +export interface AttributesItemData { + type: "compliance-requirements-attributes"; + id: string; + attributes: { + framework: string; + version: string; + description: string; + attributes: { + metadata: AttributesMetadata[]; + 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[]; +} diff --git a/ui/types/components.ts b/ui/types/components.ts index e45913efc7..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" @@ -224,12 +210,12 @@ export type M365Credentials = { client_secret: string; tenant_id: string; user: string; - encrypted_password: string; + password: string; secretName: string; providerId: string; }; -export type GCPCredentials = { +export type GCPDefaultCredentials = { client_id: string; client_secret: string; refresh_token: string; @@ -237,6 +223,12 @@ export type GCPCredentials = { providerId: string; }; +export type GCPServiceAccountKey = { + service_account_key: string; + secretName: string; + providerId: string; +}; + export type KubernetesCredentials = { kubeconfig_content: string; secretName: string; @@ -246,7 +238,8 @@ export type KubernetesCredentials = { export type CredentialsFormSchema = | AWSCredentials | AzureCredentials - | GCPCredentials + | GCPDefaultCredentials + | GCPServiceAccountKey | KubernetesCredentials | M365Credentials; @@ -262,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"; @@ -490,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 a8fb78dde9..e6f364794e 100644 --- a/ui/types/filters.ts +++ b/ui/types/filters.ts @@ -5,6 +5,7 @@ export interface FilterOption { labelCheckboxGroup: string; values: string[]; valueLabelMapping?: Array<{ [uid: string]: ProviderAccountProps }>; + index?: number; } export interface CustomDropdownFilterProps { diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index b0c64dfa24..ef627bcea3 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -142,9 +142,7 @@ export const addCredentialsFormSchema = (providerType: string) => .nonempty("Client Secret is required"), tenant_id: z.string().nonempty("Tenant ID is required"), user: z.string().nonempty("User is required"), - encrypted_password: z - .string() - .nonempty("Encrypted Password is required"), + password: z.string().nonempty("Password is required"), } : {}), }); @@ -178,6 +176,35 @@ export const addCredentialsRoleFormSchema = (providerType: string) => providerType: z.string(), }); +export const addCredentialsServiceAccountFormSchema = (providerType: string) => + providerType === "gcp" + ? z.object({ + providerId: z.string(), + providerType: z.string(), + service_account_key: z.string().refine( + (val) => { + try { + const parsed = JSON.parse(val); + return ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ); + } catch { + return false; + } + }, + { + message: "Invalid JSON format. Please provide a valid JSON object.", + }, + ), + secretName: z.string().optional(), + }) + : z.object({ + providerId: z.string(), + providerType: z.string(), + }); + export const testConnectionFormSchema = z.object({ providerId: z.string(), runOnce: z.boolean().default(false), diff --git a/ui/types/index.ts b/ui/types/index.ts index 40d4d9a1ef..4307efd3ed 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -3,4 +3,5 @@ export * from "./components"; export * from "./filters"; export * from "./formSchemas"; export * from "./providers"; +export * from "./scans"; export * from "./resources"; diff --git a/ui/types/providers.ts b/ui/types/providers.ts index 6bac15262c..f12fd25944 100644 --- a/ui/types/providers.ts +++ b/ui/types/providers.ts @@ -48,7 +48,7 @@ export interface ProviderProps { export interface ProviderAccountProps { provider: ProviderType; uid: string; - alias: string; + alias: string | null; } export interface ProviderOverviewProps { @@ -71,3 +71,27 @@ export interface ProviderOverviewProps { version: string; }; } + +export interface ProvidersApiResponse { + links: { + first: string; + last: string; + next: string | null; + prev: string | null; + }; + data: ProviderProps[]; + included?: Array<{ + type: string; + id: string; + attributes: any; + relationships?: any; + }>; + meta: { + pagination: { + page: number; + pages: number; + count: number; + }; + version: string; + }; +} 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";