From c0ae8b9739da2d7eaad5ed1385a166aeb6d34023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Thu, 18 Jun 2026 08:56:04 +0200 Subject: [PATCH 01/55] feat(compliance): add DORA compliance framework for Azure (#11551) --- api/src/backend/api/compliance.py | 4 +- api/src/backend/api/v1/views.py | 4 +- api/src/backend/tasks/tasks.py | 4 +- .../security-compliance-framework.mdx | 12 +- prowler/CHANGELOG.md | 2 + .../{dora.json => dora_2022_2554.json} | 245 ++++++++++++++++++ ui/.prettierignore | 1 + ui/CHANGELOG.md | 1 + .../compliance-download-container.test.tsx | 4 +- .../icons/compliance/IconCompliance.tsx | 5 +- ui/dependency-log.json | 12 +- ui/lib/compliance/compliance-mapper.ts | 2 +- .../compliance-report-types.test.ts | 2 +- ui/lib/compliance/compliance-report-types.ts | 2 +- ui/types/compliance.ts | 2 +- 15 files changed, 276 insertions(+), 26 deletions(-) rename prowler/compliance/{dora.json => dora_2022_2554.json} (69%) diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 202825185b..77c45cbffd 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -112,14 +112,14 @@ def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[s """List compliance framework identifiers available for `provider_type`. Includes both per-provider frameworks and universal top-level frameworks - (e.g. ``dora``, ``csa_ccm_4.0``). + (e.g. ``dora_2022_2554``, ``csa_ccm_4.0``). Args: provider_type (Provider.ProviderChoices): The cloud provider type (e.g., "aws", "azure", "gcp", "m365"). Returns: - list[str]: Framework identifiers (e.g., "cis_1.4_aws", "dora"). + list[str]: Framework identifiers (e.g., "cis_1.4_aws", "dora_2022_2554"). """ global AVAILABLE_COMPLIANCE_FRAMEWORKS if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS: diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 7e9aae5b94..4cf39cdce1 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -1884,7 +1884,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): description=( "Download a specific compliance report as an OCSF JSON file. " "Only universal frameworks that declare an output configuration " - "produce this artifact (currently 'dora' and 'csa_ccm_4.0'); any " + "produce this artifact (currently 'dora_2022_2554' and 'csa_ccm_4.0'); any " "other framework returns 404." ), parameters=[ @@ -1893,7 +1893,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): type=str, location=OpenApiParameter.PATH, required=True, - description="The compliance report name, like 'dora'", + description="The compliance report name, like 'dora_2022_2554'", ), ], responses={ diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index e617339973..e1e7100ae6 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -560,7 +560,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): # Per-framework exporters in `COMPLIANCE_CLASS_MAP` consume the legacy bulk. frameworks_bulk = Compliance.get_bulk(provider_type) - # Universal-only frameworks (top-level JSONs like `dora.json`) are emitted + # Universal-only frameworks (top-level JSONs like `dora_2022_2554.json`) are emitted # via `process_universal_compliance_frameworks` below. universal_bulk = get_prowler_provider_compliance(provider_type) universal_only_names = { @@ -650,7 +650,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): writer.batch_write_data_to_file(**extra) writer._data.clear() - # Universal-only frameworks (e.g. `dora.json`). + # Universal-only frameworks (e.g. `dora_2022_2554.json`). if universal_only_names: process_universal_compliance_frameworks( input_compliance_frameworks=universal_only_names, diff --git a/docs/developer-guide/security-compliance-framework.mdx b/docs/developer-guide/security-compliance-framework.mdx index 030d876aab..cf756c4da0 100644 --- a/docs/developer-guide/security-compliance-framework.mdx +++ b/docs/developer-guide/security-compliance-framework.mdx @@ -38,7 +38,7 @@ Before adding a new framework, complete the following checks: - **Verify the framework is not already supported.** Inspect `prowler/compliance/` and every `prowler/compliance//` for an existing JSON file matching the name and version. - **Confirm the required checks exist.** Every requirement that can be automated must point to one or more existing Prowler checks. For each missing check, implement it first by following the [Prowler Checks](/developer-guide/checks) guide. - **Review a reference framework.** Use an existing framework with a similar structure as your template: - - Universal: `prowler/compliance/dora.json`, `prowler/compliance/csa_ccm_4.0.json`. + - Universal: `prowler/compliance/dora_2022_2554.json`, `prowler/compliance/csa_ccm_4.0.json`. - Legacy: `prowler/compliance/aws/cis_2.0_aws.json` (canonical CIS shape), `prowler/compliance/aws/ccc_aws.json`, `prowler/compliance/aws/ens_rd2022_aws.json`, `prowler/compliance/aws/nist_800_53_revision_5_aws.json`. ## Universal Compliance Framework @@ -51,9 +51,9 @@ Place the file at the top level of the compliance directory: prowler/compliance/.json ``` -Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/compliance/dora.json`. +Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/compliance/dora_2022_2554.json`. -The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora.json` → `dora`). +The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora_2022_2554.json` → `dora_2022_2554`). ### Top-level structure @@ -70,7 +70,7 @@ The file is auto-discovered — there is **no** need to register it in any `__in } ``` -A `provider` field at the top level is **optional**. The framework's effective provider list is derived by `ComplianceFramework.get_providers()` (`compliance_models.py:739`) from the union of all keys appearing in `requirement.checks` across all requirements; the explicit `provider` field is used **only as a fallback** when no requirement carries any `checks` key. This is what enables a single file (e.g. `dora.json`) to cover AWS today and add Azure / GCP / etc. tomorrow without restructuring. +A `provider` field at the top level is **optional**. The framework's effective provider list is derived by `ComplianceFramework.get_providers()` (`compliance_models.py:739`) from the union of all keys appearing in `requirement.checks` across all requirements; the explicit `provider` field is used **only as a fallback** when no requirement carries any `checks` key. This is what enables a single file (e.g. `dora_2022_2554.json`) to cover AWS today and add Azure / GCP / etc. tomorrow without restructuring. Provider keys inside `requirement.checks` must match the directory names under `prowler/providers/`. The valid keys at present are: `aws`, `azure`, `gcp`, `m365`, `kubernetes`, `iac`, `github`, `googleworkspace`, `alibabacloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `oraclecloud`, `llm`. Comparison in `supports_provider()` is case-insensitive, but lowercase is the convention used everywhere in the repository. @@ -493,7 +493,7 @@ Before opening a PR, validate the JSON loads cleanly against the model and that ### 1. Schema validation -For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora.json` that key is `dora`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`. +For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora_2022_2554.json` that key is `dora_2022_2554`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`. ```python from prowler.lib.check.compliance_models import ( @@ -619,7 +619,7 @@ The following issues are the most common when contributing a compliance framewor Use the following files as templates when modeling a new contribution. -- `prowler/compliance/dora.json` — universal schema, single-provider populated (AWS), ready to extend with more providers. +- `prowler/compliance/dora_2022_2554.json` — universal schema, single-provider populated (AWS), ready to extend with more providers. - `prowler/compliance/csa_ccm_4.0.json` — universal schema, multi-provider populated (AWS, Azure, GCP, AlibabaCloud, OracleCloud). - `prowler/compliance/aws/cis_2.0_aws.json` — legacy CIS attribute shape. - `prowler/compliance/aws/nist_800_53_revision_5_aws.json` — legacy generic attribute shape. diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index ada7536290..13203e2fde 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -22,6 +22,8 @@ All notable changes to the **Prowler SDK** are documented in this file. - Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398) - Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602) - TLS certificate verification in the `codepipeline_project_repo_private` check, which previously used an unverified SSL context, leaving the repository-visibility probe open to MITM tampering [(#11603)](https://github.com/prowler-cloud/prowler/pull/11603) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the Azure provider, mapping existing Azure checks across the five DORA pillars [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) +- Rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) - `entra_directory_sync_object_takeover_blocked` check for the M365 provider, verifying that hybrid Entra tenants block cloud object takeover through both soft-match and hard-match directory synchronization [(#11098)](https://github.com/prowler-cloud/prowler/pull/11098) - `entra_conditional_access_policy_no_deleted_object_references` check for M365 provider [(#11236)](https://github.com/prowler-cloud/prowler/pull/11236) diff --git a/prowler/compliance/dora.json b/prowler/compliance/dora_2022_2554.json similarity index 69% rename from prowler/compliance/dora.json rename to prowler/compliance/dora_2022_2554.json index 2378a307c6..1bc4ed41da 100644 --- a/prowler/compliance/dora.json +++ b/prowler/compliance/dora_2022_2554.json @@ -114,6 +114,22 @@ "organizations_account_part_of_organizations", "iam_user_mfa_enabled_console_access", "iam_user_hardware_mfa_enabled" + ], + "azure": [ + "entra_global_admin_in_less_than_five_users", + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa", + "entra_security_defaults_enabled", + "entra_conditional_access_policy_require_mfa_for_admin_portals", + "entra_conditional_access_policy_require_mfa_for_management_api", + "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_users_cannot_create_microsoft_365_groups", + "iam_subscription_roles_owner_custom_not_created", + "iam_role_user_access_admin_restricted", + "iam_custom_role_has_permissions_to_administer_resource_locks" ] } }, @@ -136,6 +152,24 @@ "organizations_delegated_administrators", "guardduty_centrally_managed", "guardduty_delegated_admin_enabled_all_regions" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_os_relational_databases_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_cosmosdb_is_on", + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "policy_ensure_asc_enforcement_enabled" ] } }, @@ -167,6 +201,22 @@ "cloudfront_distributions_using_deprecated_ssl_protocols", "cloudfront_distributions_https_enabled", "rds_instance_transport_encrypted" + ], + "azure": [ + "storage_ensure_minimum_tls_version_12", + "storage_secure_transfer_required_is_enabled", + "storage_smb_channel_encryption_with_secure_algorithm", + "storage_smb_protocol_version_is_latest", + "app_minimum_tls_version_12", + "app_ensure_http_is_redirected_to_https", + "app_ensure_using_http20", + "sqlserver_recommended_minimal_tls_version", + "mysql_flexible_server_minimum_tls_version_12", + "mysql_flexible_server_ssl_connection_enabled", + "postgresql_flexible_server_enforce_ssl_enabled", + "keyvault_key_rotation_enabled", + "storage_key_rotation_90_days", + "aks_network_policy_enabled" ] } }, @@ -189,6 +239,12 @@ "ec2_elastic_ip_unassigned", "ec2_networkacl_unused", "secretsmanager_secret_unused" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "network_watcher_enabled", + "network_public_ip_shodan", + "vm_scaleset_not_empty" ] } }, @@ -236,6 +292,43 @@ "ec2_instance_account_imdsv2_enabled", "efs_encryption_at_rest_enabled", "awslambda_function_not_publicly_accessible" + ], + "azure": [ + "storage_account_public_network_access_disabled", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied", + "storage_ensure_private_endpoints_in_storage_accounts", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "storage_account_key_access_disabled", + "storage_default_to_entra_authorization_enabled", + "containerregistry_not_publicly_accessible", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "cosmosdb_account_firewall_use_selected_networks", + "keyvault_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "keyvault_rbac_enabled", + "app_function_not_publicly_accessible", + "aisearch_service_not_publicly_accessible", + "aks_clusters_public_access_disabled", + "aks_clusters_created_with_private_nodes", + "sqlserver_unrestricted_inbound_access", + "postgresql_flexible_server_allow_access_services_disabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk", + "vm_ensure_using_managed_disks", + "vm_trusted_launch_enabled", + "vm_linux_enforce_ssh_authentication", + "vm_jit_access_enabled", + "sqlserver_tde_encryption_enabled", + "sqlserver_tde_encrypted_with_cmk", + "databricks_workspace_cmk_encryption_enabled", + "network_ssh_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_http_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_bastion_host_exists" ] } }, @@ -266,6 +359,22 @@ "inspector2_is_enabled", "inspector2_active_findings_exist", "ec2_elastic_ip_shodan" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_mcas_is_enabled", + "defender_container_images_scan_enabled", + "defender_container_images_resolved_vulnerabilities", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" ] } }, @@ -288,6 +397,17 @@ "backup_vaults_exist", "rds_instance_critical_event_subscription", "rds_cluster_critical_event_subscription" + ], + "azure": [ + "monitor_alert_service_health_exists", + "monitor_alert_create_update_security_solution", + "monitor_alert_delete_security_solution", + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_attack_path_notifications_properly_configured", + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" ] } }, @@ -329,6 +449,16 @@ "elbv2_is_in_multiple_az", "cloudfront_distributions_multiple_origin_failover_configured", "dynamodb_table_protected_by_backup_plan" + ], + "azure": [ + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period", + "vm_ensure_using_managed_disks", + "storage_ensure_soft_delete_is_enabled", + "storage_ensure_file_shares_soft_delete_is_enabled", + "storage_blob_versioning_is_enabled", + "storage_geo_redundant_enabled", + "keyvault_recoverable" ] } }, @@ -348,6 +478,16 @@ "inspector2_active_findings_exist", "accessanalyzer_enabled_without_findings", "cloudtrail_insights_exist" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_assessments_vm_endpoint_protection_installed", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_system_updates_are_applied", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured" ] } }, @@ -370,6 +510,13 @@ "eventbridge_schema_registry_cross_account_access", "cloudwatch_alarm_actions_enabled", "cloudwatch_alarm_actions_alarm_state_configured" + ], + "azure": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "defender_ensure_notify_alerts_severity_is_high", + "defender_attack_path_notifications_properly_configured", + "monitor_alert_service_health_exists" ] } }, @@ -406,6 +553,26 @@ "elbv2_logging_enabled", "cloudfront_distributions_logging_enabled", "s3_bucket_server_access_logging_enabled" + ], + "azure": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_storage_account_with_activity_logs_is_private", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "keyvault_logging_enabled", + "sqlserver_auditing_enabled", + "sqlserver_auditing_retention_90_days", + "mysql_flexible_server_audit_log_enabled", + "mysql_flexible_server_audit_log_connection_activated", + "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", + "app_http_logs_enabled", + "app_function_application_insights_enabled", + "appinsights_ensure_is_configured" ] } }, @@ -429,6 +596,15 @@ "cloudtrail_threat_detection_enumeration", "cloudtrail_threat_detection_llm_jacking", "cloudtrail_threat_detection_privilege_escalation" + ], + "azure": [ + "defender_ensure_notify_alerts_severity_is_high", + "defender_attack_path_notifications_properly_configured", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_mcas_is_enabled", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" ] } }, @@ -459,6 +635,20 @@ "cloudwatch_changes_to_network_route_tables_alarm_configured", "cloudwatch_changes_to_vpcs_alarm_configured", "sns_subscription_not_using_http_endpoints" + ], + "azure": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_delete_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_delete_nsg", + "monitor_alert_create_update_security_solution", + "monitor_alert_delete_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_sqlserver_fr", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_service_health_exists", + "defender_additional_email_configured_with_a_security_contact" ] } }, @@ -479,6 +669,16 @@ "ec2_instance_managed_by_ssm", "ec2_instance_with_outdated_ami", "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_ensure_system_updates_are_applied", + "defender_container_images_scan_enabled", + "defender_assessments_vm_endpoint_protection_installed", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "vm_ensure_using_approved_images", + "vm_desired_sku_size" ] } }, @@ -506,6 +706,23 @@ "rds_instance_certificate_expiration", "iam_no_expired_server_certificates_stored", "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured", + "sqlserver_va_emails_notifications_admins_enabled", + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_rbac_key_expiration_set", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_rbac_secret_expiration_set", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_function_latest_runtime_version", + "storage_smb_protocol_version_is_latest" ] } }, @@ -537,6 +754,17 @@ "vpc_endpoint_services_allowed_principals_trust_boundaries", "vpc_peering_routing_tables_with_least_privilege", "awslambda_function_using_cross_account_layers" + ], + "azure": [ + "entra_policy_guest_users_access_restrictions", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "storage_cross_tenant_replication_disabled", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "aks_clusters_created_with_private_nodes" ] } }, @@ -567,6 +795,15 @@ "iam_administrator_access_with_mfa", "iam_policy_attached_only_to_group_or_roles", "accessanalyzer_enabled" + ], + "azure": [ + "iam_subscription_roles_owner_custom_not_created", + "iam_role_user_access_admin_restricted", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "entra_global_admin_in_less_than_five_users", + "app_function_identity_without_admin_privileges", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps" ] } }, @@ -590,6 +827,14 @@ "cloudtrail_threat_detection_llm_jacking", "cloudtrail_threat_detection_privilege_escalation", "accessanalyzer_enabled_without_findings" + ], + "azure": [ + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_defender_for_server_is_on", + "defender_attack_path_notifications_properly_configured", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" ] } } diff --git a/ui/.prettierignore b/ui/.prettierignore index 5377d96f2d..8b679ff005 100644 --- a/ui/.prettierignore +++ b/ui/.prettierignore @@ -8,6 +8,7 @@ build/ coverage/ dist/ esm/ +CHANGELOG.md # Generated files next-env.d.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 1baddf169a..a5b56ab23c 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed - Radio button no longer shifts vertically when selected [(#11608)](https://github.com/prowler-cloud/prowler/pull/11608) +- Handle rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) ### 🔐 Security diff --git a/ui/components/compliance/compliance-download-container.test.tsx b/ui/components/compliance/compliance-download-container.test.tsx index 70ff11c1c2..31c68fb1fc 100644 --- a/ui/components/compliance/compliance-download-container.test.tsx +++ b/ui/components/compliance/compliance-download-container.test.tsx @@ -163,7 +163,7 @@ describe("ComplianceDownloadContainer", () => { compact presentation="dropdown" scanId="scan-1" - complianceId="dora" + complianceId="dora_2022_2554" />, ); @@ -178,7 +178,7 @@ describe("ComplianceDownloadContainer", () => { expect(downloadComplianceOcsfMock).toHaveBeenCalledWith( "scan-1", - "dora", + "dora_2022_2554", {}, ); }); diff --git a/ui/components/icons/compliance/IconCompliance.tsx b/ui/components/icons/compliance/IconCompliance.tsx index ab0fb9a36a..6723fe8a95 100644 --- a/ui/components/icons/compliance/IconCompliance.tsx +++ b/ui/components/icons/compliance/IconCompliance.tsx @@ -69,8 +69,9 @@ const COMPLIANCE_LOGOS = [ ["c5", C5Logo], ["ccc", CCCLogo], ["csa", CSALogo], - // DORA — universal framework (`prowler/compliance/dora.json`). The - // compliance_id is just `dora`, no provider suffix. + // DORA — universal framework (`prowler/compliance/dora_2022_2554.json`). + // The compliance_id is `dora_2022_2554`; the `dora` keyword still matches + // it via `includes`, with no provider suffix. ["dora", DORALogo], ["secnumcloud", ANSSILogo], ["okta", OktaLogo], diff --git a/ui/dependency-log.json b/ui/dependency-log.json index c851552b08..b200139313 100644 --- a/ui/dependency-log.json +++ b/ui/dependency-log.json @@ -2,10 +2,10 @@ { "section": "dependencies", "name": "@ai-sdk/react", - "from": "2.0.111", - "to": "3.0.207", + "from": "3.0.207", + "to": "3.0.205", "strategy": "installed", - "generatedAt": "2026-06-15T07:44:51.977Z" + "generatedAt": "2026-06-17T11:28:12.866Z" }, { "section": "dependencies", @@ -362,10 +362,10 @@ { "section": "dependencies", "name": "ai", - "from": "5.0.109", - "to": "6.0.205", + "from": "6.0.205", + "to": "6.0.203", "strategy": "installed", - "generatedAt": "2026-06-15T07:44:51.977Z" + "generatedAt": "2026-06-17T11:28:12.866Z" }, { "section": "dependencies", diff --git a/ui/lib/compliance/compliance-mapper.ts b/ui/lib/compliance/compliance-mapper.ts index 2e44958c73..bc0995ad87 100644 --- a/ui/lib/compliance/compliance-mapper.ts +++ b/ui/lib/compliance/compliance-mapper.ts @@ -228,7 +228,7 @@ const getComplianceMappers = (): Record => ({ createElement(OktaIDaaSStigCustomDetails, { requirement }), }, // DORA (Regulation (EU) 2022/2554) — universal framework keyed by the - // `framework` field of `prowler/compliance/dora.json` ("DORA"). Groups by + // `framework` field of `prowler/compliance/dora_2022_2554.json` ("DORA"). Groups by // Pillar (5 enum values) and surfaces Pillar / Article / ArticleTitle in // the requirement detail drawer. DORA: { diff --git a/ui/lib/compliance/compliance-report-types.test.ts b/ui/lib/compliance/compliance-report-types.test.ts index 82c6cd49f9..d8d41f08ec 100644 --- a/ui/lib/compliance/compliance-report-types.test.ts +++ b/ui/lib/compliance/compliance-report-types.test.ts @@ -37,7 +37,7 @@ describe("getReportTypeForFramework", () => { describe("isOcsfSupported", () => { it("returns true for universal frameworks shipping an OCSF artifact", () => { - expect(isOcsfSupported("dora")).toBe(true); + expect(isOcsfSupported("dora_2022_2554")).toBe(true); expect(isOcsfSupported("csa_ccm_4.0")).toBe(true); }); diff --git a/ui/lib/compliance/compliance-report-types.ts b/ui/lib/compliance/compliance-report-types.ts index a9a160e09c..f8f23a67e1 100644 --- a/ui/lib/compliance/compliance-report-types.ts +++ b/ui/lib/compliance/compliance-report-types.ts @@ -178,7 +178,7 @@ export const pickLatestCisPerProvider = ( * ``isOcsfSupported``. */ const OCSF_SUPPORTED_COMPLIANCE_IDS: ReadonlySet = new Set([ - "dora", + "dora_2022_2554", "csa_ccm_4.0", ]); diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index fde9d260e9..3a46375a75 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -373,7 +373,7 @@ export interface OktaIDaaSStigRequirement extends Requirement { // DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554). // Universal framework — flat attributes dict with Pillar/Article/ArticleTitle. // `Pillar` is the canonical grouping key for tables and PDF; the enum mirrors -// the five DORA pillars declared in `prowler/compliance/dora.json`. +// the five DORA pillars declared in `prowler/compliance/dora_2022_2554.json`. export const DORA_PILLAR = { ICT_RISK_MANAGEMENT: "ICT Risk Management", INCIDENT_REPORTING: "ICT-Related Incident Reporting", From 3c68a121e54cc94dc3974a5accdd719957b71961 Mon Sep 17 00:00:00 2001 From: s1ns3nz0 Date: Thu, 18 Jun 2026 16:06:25 +0900 Subject: [PATCH 02/55] feat(azure): add databricks_workspace_no_public_ip_enabled check (#11036) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + .../compliance/azure/iso27001_2022_azure.json | 1 + .../services/databricks/databricks_service.py | 10 + .../__init__.py | 0 ...rkspace_no_public_ip_enabled.metadata.json | 38 ++++ ...tabricks_workspace_no_public_ip_enabled.py | 41 +++++ .../databricks/databricks_service_test.py | 2 + ...cks_workspace_no_public_ip_enabled_test.py | 174 ++++++++++++++++++ 8 files changed, 267 insertions(+) create mode 100644 prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/__init__.py create mode 100644 prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json create mode 100644 prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py create mode 100644 tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 13203e2fde..7ba3afbe3d 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `cosmosdb_account_minimum_tls_version` check for Azure provider, verifying Cosmos DB accounts enforce TLS 1.2 or higher for client connections [(#11033)](https://github.com/prowler-cloud/prowler/pull/11033) - `cosmosdb_account_public_network_access_disabled` check for Azure provider, verifying Cosmos DB accounts have public network access disabled so connectivity is restricted to private endpoints or VNet service endpoints [(#11034)](https://github.com/prowler-cloud/prowler/pull/11034) - `databricks_workspace_public_network_access_disabled` check for Azure provider, verifying Databricks workspaces have public network access disabled so connectivity is restricted to Azure Private Link private endpoints [(#11035)](https://github.com/prowler-cloud/prowler/pull/11035) +- `databricks_workspace_no_public_ip_enabled` check for Azure provider, verifying Databricks workspaces use secure cluster connectivity (no public IP) so compute nodes are not assigned public IP addresses [(#11036)](https://github.com/prowler-cloud/prowler/pull/11036) - `aks_cluster_auto_upgrade_enabled` check for Azure provider [(#11027)](https://github.com/prowler-cloud/prowler/pull/11027) - Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398) - Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602) diff --git a/prowler/compliance/azure/iso27001_2022_azure.json b/prowler/compliance/azure/iso27001_2022_azure.json index 1eb0f75792..4172a9449a 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -1411,6 +1411,7 @@ "containerregistry_not_publicly_accessible", "cosmosdb_account_firewall_use_selected_networks", "cosmosdb_account_public_network_access_disabled", + "databricks_workspace_no_public_ip_enabled", "databricks_workspace_public_network_access_disabled", "network_bastion_host_exists", "network_flow_log_captured_sent", diff --git a/prowler/providers/azure/services/databricks/databricks_service.py b/prowler/providers/azure/services/databricks/databricks_service.py index 1e30452294..b7367d3cbb 100644 --- a/prowler/providers/azure/services/databricks/databricks_service.py +++ b/prowler/providers/azure/services/databricks/databricks_service.py @@ -62,6 +62,9 @@ class Databricks(AzureService): else: managed_disk_encryption = None + enable_no_public_ip = getattr( + workspace_parameters, "enable_no_public_ip", None + ) workspaces[subscription][workspace.id] = DatabricksWorkspace( id=workspace.id, name=workspace.name, @@ -69,6 +72,11 @@ class Databricks(AzureService): public_network_access=getattr( workspace, "public_network_access", None ), + no_public_ip_enabled=( + getattr(enable_no_public_ip, "value", None) + if enable_no_public_ip + else None + ), custom_managed_vnet_id=( getattr( workspace_parameters, "custom_virtual_network_id", None @@ -111,6 +119,7 @@ class DatabricksWorkspace(BaseModel): name: The name of the workspace. location: The Azure region where the workspace is deployed. public_network_access: Whether public network access is "Enabled" or "Disabled", if configured. + no_public_ip_enabled: Whether secure cluster connectivity (no public IP) is enabled. None when the workspace does not expose this classic-compute setting (e.g. serverless workspaces). custom_managed_vnet_id: The ID of the custom managed virtual network, if configured. managed_disk_encryption: The encryption settings for the workspace's managed disks. """ @@ -119,5 +128,6 @@ class DatabricksWorkspace(BaseModel): name: str location: str public_network_access: Optional[str] = None + no_public_ip_enabled: Optional[bool] = None custom_managed_vnet_id: Optional[str] = None managed_disk_encryption: Optional[ManagedDiskEncryption] = None diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/__init__.py b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json new file mode 100644 index 0000000000..4062199474 --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "databricks_workspace_no_public_ip_enabled", + "CheckTitle": "Databricks workspace has secure cluster connectivity (no public IP)", + "CheckType": [], + "ServiceName": "databricks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.databricks/workspaces", + "ResourceGroup": "ai_ml", + "Description": "**Azure Databricks workspaces** are evaluated for **secure cluster connectivity** (No Public IP / NPIP). When enabled, compute (cluster) nodes are deployed without public IP addresses and communicate with the control plane through a secure relay, reducing the workspace's exposure to the internet.", + "Risk": "Without **secure cluster connectivity**, Databricks compute nodes are assigned **public IP addresses** and are directly reachable from the internet. This enables **port scanning**, **lateral movement** from a compromised node, and **data exfiltration** that bypasses private-network controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/secure-cluster-connectivity", + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/vnet-inject", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.databricks/workspaces" + ], + "Remediation": { + "Code": { + "CLI": "az databricks workspace create --name --resource-group --location --sku premium --enable-no-public-ip", + "NativeIaC": "```bicep\n// Bicep: Deploy a Databricks workspace with secure cluster connectivity (No Public IP)\nresource workspace 'Microsoft.Databricks/workspaces@2023-02-01' = {\n name: ''\n location: resourceGroup().location\n sku: {\n name: 'premium'\n }\n properties: {\n managedResourceGroupId: '/subscriptions//resourceGroups/'\n parameters: {\n enableNoPublicIp: {\n value: true // CRITICAL: Deploys cluster nodes without public IP addresses\n }\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and start creating a new Azure Databricks workspace (No Public IP must be set at creation; it cannot be enabled on an existing workspace)\n2. On the Networking tab, set Deploy Azure Databricks workspace with Secure Cluster Connectivity (No Public IP) to Yes\n3. Complete the VNet injection configuration if required\n4. Review + create the workspace\n5. Migrate workloads to the new workspace and decommission the old one", + "Terraform": "```hcl\n# Terraform: Databricks workspace with secure cluster connectivity (No Public IP)\nresource \"azurerm_databricks_workspace\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"premium\"\n\n custom_parameters {\n no_public_ip = true # CRITICAL: Deploys cluster nodes without public IP addresses\n }\n}\n```" + }, + "Recommendation": { + "Text": "Deploy Databricks workspaces with **secure cluster connectivity (No Public IP)** so compute nodes have no public IP addresses. Because this is set at creation, plan a migration for existing workspaces, and pair it with **VNet injection**, **private endpoints / disabled public network access**, and least-privilege NSG rules to uphold **defense in depth**.", + "Url": "https://hub.prowler.com/check/databricks_workspace_no_public_ip_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Secure cluster connectivity (No Public IP) applies to classic compute and is set at workspace creation. Serverless workspaces do not expose this setting (they have no customer-managed cluster nodes with public IPs) and are reported as MANUAL for verification." +} diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py new file mode 100644 index 0000000000..13b7742ffa --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.databricks.databricks_client import ( + databricks_client, +) + + +class databricks_workspace_no_public_ip_enabled(Check): + """ + Ensure Azure Databricks workspaces have secure cluster connectivity (no public IP) enabled. + + This check evaluates whether each Azure Databricks workspace in the subscription is deployed with secure cluster connectivity (No Public IP / NPIP), so cluster nodes are not assigned public IP addresses. + + Secure cluster connectivity is a classic-compute setting. Serverless workspaces do not expose it (they have no customer-managed cluster nodes with public IPs), so the workspace is reported as MANUAL for verification rather than failed. + + - PASS: The workspace has secure cluster connectivity enabled (no_public_ip_enabled is True). + - FAIL: The workspace has secure cluster connectivity disabled (no_public_ip_enabled is False). + - MANUAL: The workspace does not expose the setting (no_public_ip_enabled is None, e.g. serverless workspaces). + """ + + def execute(self): + findings = [] + for subscription, workspaces in databricks_client.workspaces.items(): + subscription_name = databricks_client.subscriptions.get( + subscription, subscription + ) + for workspace in workspaces.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=workspace + ) + report.subscription = subscription + if workspace.no_public_ip_enabled is None: + report.status = "MANUAL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) does not expose secure cluster connectivity (no public IP) settings (for example, serverless workspaces have no public-IP cluster nodes); verify the network configuration manually." + elif workspace.no_public_ip_enabled: + report.status = "PASS" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) has secure cluster connectivity (no public IP) enabled." + else: + report.status = "FAIL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) does not have secure cluster connectivity (no public IP) enabled." + findings.append(report) + return findings diff --git a/tests/providers/azure/services/databricks/databricks_service_test.py b/tests/providers/azure/services/databricks/databricks_service_test.py index 5c743d1891..f663d81fe2 100644 --- a/tests/providers/azure/services/databricks/databricks_service_test.py +++ b/tests/providers/azure/services/databricks/databricks_service_test.py @@ -19,6 +19,7 @@ def mock_databricks_get_workspaces(_): name="test-workspace", location="eastus", public_network_access="Disabled", + no_public_ip_enabled=True, custom_managed_vnet_id="test-vnet-id", managed_disk_encryption=ManagedDiskEncryption( key_name="test-key", @@ -55,6 +56,7 @@ class Test_Databricks_Service: assert workspace.name == "test-workspace" assert workspace.location == "eastus" assert workspace.public_network_access == "Disabled" + assert workspace.no_public_ip_enabled is True assert workspace.custom_managed_vnet_id == "test-vnet-id" assert ( workspace.managed_disk_encryption.__class__.__name__ diff --git a/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py b/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py new file mode 100644 index 0000000000..a7903770c6 --- /dev/null +++ b/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py @@ -0,0 +1,174 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.databricks.databricks_service import ( + DatabricksWorkspace, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_databricks_workspace_no_public_ip_enabled: + def test_databricks_no_workspaces(self): + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 0 + + def test_databricks_workspace_no_public_ip_disabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=False, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have secure cluster connectivity (no public IP) enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_no_public_ip_enabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=True, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has secure cluster connectivity (no public IP) enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_no_public_ip_not_reported(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not expose secure cluster connectivity (no public IP) settings (for example, serverless workspaces have no public-IP cluster nodes); verify the network configuration manually." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" From 5a761f341b681c682f797defcc5fe78a756ea521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:07:43 +0200 Subject: [PATCH 03/55] ci: remove redundant compliance CODEOWNERS rule (#11634) --- .github/CODEOWNERS | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3300394d83..ed97ff5a1a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,6 @@ # SDK /* @prowler-cloud/detection-remediation /prowler/ @prowler-cloud/detection-remediation -/prowler/compliance/ @prowler-cloud/compliance /tests/ @prowler-cloud/detection-remediation /dashboard/ @prowler-cloud/detection-remediation /docs/ @prowler-cloud/detection-remediation From 2293cab72c10f0e0d973cc373a69836a6f8df367 Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:03:35 +0200 Subject: [PATCH 04/55] fix(ui): adaptive Attack Paths messages for waiting states (#11512) Co-authored-by: Pablo F.G Co-authored-by: Claude Opus 4.8 --- ui/CHANGELOG.md | 1 + .../attack-paths-status-panel.test.tsx | 64 ++++++++ .../_components/attack-paths-status-panel.tsx | 87 ++++++++++ .../_components/query-execution-error.tsx | 28 ++-- .../_hooks/use-attack-path-scans.ts | 61 ++++--- .../_lib/get-attack-paths-view-state.test.ts | 152 ++++++++++++++++++ .../_lib/get-attack-paths-view-state.ts | 65 ++++++++ .../attack-paths-page.browser.test.tsx | 25 +++ .../attack-paths-page.fixtures.ts | 49 ++++++ .../query-builder/attack-paths-page.tsx | 101 ++++++------ ui/components/shared/status-alert.test.tsx | 45 ++++++ ui/components/shared/status-alert.tsx | 41 +++++ 12 files changed, 636 insertions(+), 83 deletions(-) create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts create mode 100644 ui/components/shared/status-alert.test.tsx create mode 100644 ui/components/shared/status-alert.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index a5b56ab23c..3de8c46d56 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed +- Attack Paths now shows distinct messages while a scan is queued, running, or building its graph — plus a separate "couldn't load scans" error — instead of always showing "No scans available" [(#11512)](https://github.com/prowler-cloud/prowler/pull/11512) - Radio button no longer shifts vertically when selected [(#11608)](https://github.com/prowler-cloud/prowler/pull/11608) - Handle rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx new file mode 100644 index 0000000000..20210a3126 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ATTACK_PATHS_VIEW_STATES } from "../_lib/get-attack-paths-view-state"; +import { AttackPathsStatusPanel } from "./attack-paths-status-panel"; + +describe("AttackPathsStatusPanel", () => { + it("renders the no-scans message with a link to Scan Jobs", () => { + render( + , + ); + expect(screen.getByText(/no scans available/i)).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /go to scan jobs/i }), + ).toHaveAttribute("href", "/scans"); + }); + + it("renders the scan-pending message", () => { + render( + , + ); + expect(screen.getByText(/scan in progress/i)).toBeInTheDocument(); + }); + + it("renders the graph-building message with progress", () => { + render( + , + ); + expect( + screen.getByText(/preparing attack paths data/i), + ).toBeInTheDocument(); + expect(screen.getByText(/45%/)).toBeInTheDocument(); + }); + + it("renders the no-graph-data message", () => { + render( + , + ); + expect(screen.getByText(/no attack paths data/i)).toBeInTheDocument(); + }); + + it("renders the error message and calls onRetry when Retry is clicked", () => { + const onRetry = vi.fn(); + render( + , + ); + expect(screen.getByText(/couldn.t load scans/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it("renders nothing for the ready state", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx new file mode 100644 index 0000000000..89db0dbfd2 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx @@ -0,0 +1,87 @@ +import Link from "next/link"; + +import { Button } from "@/components/shadcn"; +import { StatusAlert } from "@/components/shared/status-alert"; + +import { + ATTACK_PATHS_VIEW_STATES, + type AttackPathsViewState, +} from "../_lib/get-attack-paths-view-state"; + +interface AttackPathsStatusPanelProps { + state: AttackPathsViewState; + progress?: number; + onRetry?: () => void; +} + +/** + * Full-page status message shown whenever the Attack Paths graph is not yet + * queryable. The page renders the normal workflow instead once `state` is + * `READY` (this component renders nothing for `READY`/`LOADING`). + */ +export const AttackPathsStatusPanel = ({ + state, + progress = 0, + onRetry, +}: AttackPathsStatusPanelProps) => { + if (state === ATTACK_PATHS_VIEW_STATES.ERROR) { + return ( + + Something went wrong loading your scans. + {onRetry ? ( + + ) : null} + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.NO_SCANS) { + return ( + + + You need to run a scan before you can analyze attack paths.{" "} + + Go to Scan Jobs + + + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.SCAN_PENDING) { + return ( + + + Your scan is queued. Attack Paths will be available once it completes. + + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING) { + return ( + + + We're building the graph from your latest scan ({progress}%). + This will be ready shortly. + + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA) { + return ( + + This scan didn't produce Attack Paths data. + + ); + } + + return null; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx index 084bcf6e16..b451cf58f6 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx @@ -1,6 +1,4 @@ -import { CircleAlert } from "lucide-react"; - -import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn"; +import { StatusAlert } from "@/components/shared/status-alert"; interface QueryExecutionErrorProps { error: string; @@ -14,17 +12,17 @@ export const QueryExecutionError = ({ description, }: QueryExecutionErrorProps) => { return ( - - - {title} - - {description ?

{description}

: null} -
-
-            {error}
-          
-
-
-
+ + {description ?

{description}

: null} +
+
+          {error}
+        
+
+
); }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts index 39515698a1..5957e175d7 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { getAttackPathScans } from "@/actions/attack-paths"; import { useMountEffect } from "@/hooks/use-mount-effect"; @@ -19,7 +19,9 @@ export interface UseAttackPathScansOptions { export interface UseAttackPathScansResult { scans: AttackPathScan[]; scansLoading: boolean; + loadError: boolean; refreshScans: () => Promise; + retryLoadScans: () => Promise; } /** @@ -35,7 +37,11 @@ export function useAttackPathScans( const [scans, setScans] = useState([]); const [scansLoading, setScansLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const mountedRef = useRef(true); + // Silent background refresh for auto-refresh: never flips loading/error, so it + // can't disrupt the visible view if it fails. const refreshScans = async () => { try { const scansData = await getAttackPathScans(); @@ -47,35 +53,50 @@ export function useAttackPathScans( } }; - useMountEffect(() => { - let active = true; - - const loadScans = async () => { - setScansLoading(true); - try { - const scansData = await getAttackPathScans(); - const nextScans = scansData?.data ?? []; - if (!active) return; - setScans(nextScans); - if (!nextScans.some((scan) => scan.attributes.graph_data_ready)) { + // Full (re)load: drives loading + error state. Runs on mount and is reused by + // the error view's Retry action. A successful empty result (`{ data: [] }`) is + // not an error; only a missing payload or a thrown request is. + const loadScans = async () => { + setScansLoading(true); + setLoadError(false); + try { + const scansData = await getAttackPathScans(); + if (!mountedRef.current) return; + if (scansData?.data) { + setScans(scansData.data); + if (!scansData.data.some((scan) => scan.attributes.graph_data_ready)) { onNoReadyScan?.(); } - } catch (error) { - if (!active) return; - console.error("Failed to load scans:", error); + } else { setScans([]); + setLoadError(true); onNoReadyScan?.(); - } finally { - if (active) setScansLoading(false); } - }; + } catch (error) { + if (!mountedRef.current) return; + console.error("Failed to load scans:", error); + setScans([]); + setLoadError(true); + onNoReadyScan?.(); + } finally { + if (mountedRef.current) setScansLoading(false); + } + }; + useMountEffect(() => { + mountedRef.current = true; void loadScans(); return () => { - active = false; + mountedRef.current = false; }; }); - return { scans, scansLoading, refreshScans }; + return { + scans, + scansLoading, + loadError, + refreshScans, + retryLoadScans: loadScans, + }; } diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts new file mode 100644 index 0000000000..c6722021ed --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; + +import type { AttackPathScan, ScanState } from "@/types/attack-paths"; + +import { + ATTACK_PATHS_VIEW_STATES, + getAttackPathsViewState, + getGraphBuildingProgress, + isScanInFlight, +} from "./get-attack-paths-view-state"; + +const scan = ( + state: ScanState, + graph_data_ready: boolean, + progress = 0, +): AttackPathScan => ({ + type: "attack-paths-scans", + id: `${state}-${String(graph_data_ready)}-${progress}`, + attributes: { + state, + progress, + graph_data_ready, + provider_alias: "Provider", + provider_type: "aws", + provider_uid: "123456789012", + inserted_at: "2026-04-21T10:00:00Z", + started_at: "2026-04-21T10:00:00Z", + completed_at: null, + duration: null, + }, + relationships: { + provider: { data: { type: "providers", id: "p" } }, + scan: { data: { type: "scans", id: "s" } }, + task: { data: { type: "tasks", id: "t" } }, + }, +}); + +describe("getAttackPathsViewState", () => { + it("returns loading while scans are loading, regardless of other inputs", () => { + expect( + getAttackPathsViewState({ + scansLoading: true, + loadError: true, + scans: [], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.LOADING); + }); + + it("returns error on load failure (error wins over empty scans)", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: true, + scans: [], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.ERROR); + }); + + it("returns no-scans for an empty list", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.NO_SCANS); + }); + + it("returns ready when any provider has a queryable graph", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("executing", false, 50), scan("completed", true, 100)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.READY); + }); + + it("returns graph-building when none ready and some scan is executing (wins over scheduled)", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("scheduled", false), scan("executing", false, 30)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING); + }); + + it("returns scan-pending when none ready and some scan is scheduled/available", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("scheduled", false)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.SCAN_PENDING); + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("available", false)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.SCAN_PENDING); + }); + + it("returns no-graph-data when none ready and all scans are terminal", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("completed", false), scan("failed", false)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA); + }); +}); + +describe("isScanInFlight", () => { + it("is true for an available scan (created, not yet scheduled)", () => { + expect(isScanInFlight([scan("available", false)])).toBe(true); + }); + + it("is true for scheduled and executing scans", () => { + expect(isScanInFlight([scan("scheduled", false)])).toBe(true); + expect(isScanInFlight([scan("executing", false, 40)])).toBe(true); + }); + + it("is false when every scan is in a terminal state", () => { + expect( + isScanInFlight([scan("completed", true), scan("failed", false)]), + ).toBe(false); + }); + + it("is false for an empty list", () => { + expect(isScanInFlight([])).toBe(false); + }); +}); + +describe("getGraphBuildingProgress", () => { + it("returns the max progress among executing scans", () => { + expect( + getGraphBuildingProgress([ + scan("executing", false, 30), + scan("executing", false, 70), + scan("scheduled", false, 99), + ]), + ).toBe(70); + }); + + it("returns 0 when no scan is executing", () => { + expect(getGraphBuildingProgress([scan("scheduled", false, 50)])).toBe(0); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts new file mode 100644 index 0000000000..0e734304f8 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts @@ -0,0 +1,65 @@ +import type { AttackPathScan, ScanState } from "@/types/attack-paths"; +import { SCAN_STATES } from "@/types/attack-paths"; + +// In-flight = scan still progressing toward a graph. AVAILABLE is the default +// state of a new scan. Shared by the deriver and polling so they can't diverge. +const IN_FLIGHT_SCAN_STATES: ScanState[] = [ + SCAN_STATES.AVAILABLE, + SCAN_STATES.SCHEDULED, + SCAN_STATES.EXECUTING, +]; + +export const isScanInFlight = (scans: AttackPathScan[]): boolean => + scans.some((s) => IN_FLIGHT_SCAN_STATES.includes(s.attributes.state)); + +export const ATTACK_PATHS_VIEW_STATES = { + LOADING: "loading", + ERROR: "error", + NO_SCANS: "no-scans", + SCAN_PENDING: "scan-pending", + GRAPH_BUILDING: "graph-building", + NO_GRAPH_DATA: "no-graph-data", + READY: "ready", +} as const; + +export type AttackPathsViewState = + (typeof ATTACK_PATHS_VIEW_STATES)[keyof typeof ATTACK_PATHS_VIEW_STATES]; + +interface GetAttackPathsViewStateInput { + scansLoading: boolean; + loadError: boolean; + scans: AttackPathScan[]; +} + +/** + * Single source of truth for what the Attack Paths page shows. The full-page + * message owns every "not queryable yet" state; the workflow renders only once + * at least one provider's graph is ready. + */ +export const getAttackPathsViewState = ({ + scansLoading, + loadError, + scans, +}: GetAttackPathsViewStateInput): AttackPathsViewState => { + if (scansLoading) return ATTACK_PATHS_VIEW_STATES.LOADING; + if (loadError) return ATTACK_PATHS_VIEW_STATES.ERROR; + if (scans.length === 0) return ATTACK_PATHS_VIEW_STATES.NO_SCANS; + + if (scans.some((s) => s.attributes.graph_data_ready)) { + return ATTACK_PATHS_VIEW_STATES.READY; + } + if (scans.some((s) => s.attributes.state === SCAN_STATES.EXECUTING)) { + return ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING; + } + // EXECUTING returned above; an in-flight scan here is AVAILABLE/SCHEDULED. + if (isScanInFlight(scans)) { + return ATTACK_PATHS_VIEW_STATES.SCAN_PENDING; + } + return ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA; +}; + +/** Highest progress among scans whose graph is actively building. */ +export const getGraphBuildingProgress = (scans: AttackPathScan[]): number => + scans + .filter((s) => s.attributes.state === SCAN_STATES.EXECUTING) + .reduce((max, s) => Math.max(max, s.attributes.progress), 0); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx index 9e455a4b6d..bc2cf36331 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx @@ -75,6 +75,31 @@ describe("loading the page", () => { }); }); +describe("waiting states", () => { + test("a pending scan shows the scan-in-progress message", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.scanPending()); + expect(await graph.emptyStateMessage()).toMatch(/scan in progress/i); + }); + + test("a building graph shows the preparing message with progress", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.graphBuilding()); + const message = await graph.emptyStateMessage(); + expect(message).toMatch(/preparing attack paths data/i); + expect(message).toMatch(/45%/); + }); + + test("a completed scan with no graph shows the no-data message", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.noGraphData()); + expect(await graph.emptyStateMessage()).toMatch(/no attack paths data/i); + }); +}); + describe("running a query", () => { test("the graph renders with a background, a minimap, and a viewport", async ({ mountWith, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts index 37e2170355..41089af527 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts @@ -143,6 +143,52 @@ export const emptyScans = (): PageFixture => ({ queryResult: null, }); +export const scanPending = (): PageFixture => ({ + scans: [ + buildScan(TYPICAL_SCAN_ID, { + state: "scheduled", + progress: 0, + graph_data_ready: false, + completed_at: null, + duration: null, + }), + ], + scanId: TYPICAL_SCAN_ID, + queries: [], + queryId: DEFAULT_QUERY_ID, + queryResult: null, +}); + +export const graphBuilding = (): PageFixture => ({ + scans: [ + buildScan(TYPICAL_SCAN_ID, { + state: "executing", + progress: 45, + graph_data_ready: false, + completed_at: null, + duration: null, + }), + ], + scanId: TYPICAL_SCAN_ID, + queries: [], + queryId: DEFAULT_QUERY_ID, + queryResult: null, +}); + +export const noGraphData = (): PageFixture => ({ + scans: [ + buildScan(TYPICAL_SCAN_ID, { + state: "completed", + progress: 100, + graph_data_ready: false, + }), + ], + scanId: TYPICAL_SCAN_ID, + queries: [], + queryId: DEFAULT_QUERY_ID, + queryResult: null, +}); + export const emptyGraph = (): PageFixture => ({ scans: [buildScan(TYPICAL_SCAN_ID)], scanId: TYPICAL_SCAN_ID, @@ -269,6 +315,9 @@ export const edgeCases = (): PageFixture => { export const fixtures = { typical, emptyScans, + scanPending, + graphBuilding, + noGraphData, emptyGraph, singleNode, findingsOnly, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index 07f36912e4..6bcf03d082 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -1,7 +1,6 @@ "use client"; -import { ArrowLeft, Info, Maximize2 } from "lucide-react"; -import Link from "next/link"; +import { ArrowLeft, Maximize2 } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef, useState } from "react"; import { FormProvider } from "react-hook-form"; @@ -17,12 +16,7 @@ import { FindingDetailDrawer } from "@/components/findings/table"; import { PageReady } from "@/components/onboarding"; import { useFindingDetails } from "@/components/resources/table/use-finding-details"; import { AutoRefresh } from "@/components/scans"; -import { - Alert, - AlertDescription, - AlertTitle, - Button, -} from "@/components/shadcn"; +import { Button } from "@/components/shadcn"; import { Dialog, DialogContent, @@ -31,6 +25,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/shadcn/dialog"; +import { StatusAlert } from "@/components/shared/status-alert"; import { useToast } from "@/components/ui"; import { useMountEffect } from "@/hooks/use-mount-effect"; import { isCloud } from "@/lib/shared/env"; @@ -61,11 +56,21 @@ import { QuerySelector, ScanListTable, } from "./_components"; +import { AttackPathsStatusPanel } from "./_components/attack-paths-status-panel"; import type { GraphHandle } from "./_components/graph/attack-path-graph"; import { useAttackPathScans } from "./_hooks/use-attack-path-scans"; import { useGraphState } from "./_hooks/use-graph-state"; import { useQueryBuilder } from "./_hooks/use-query-builder"; import { exportGraphAsPNG } from "./_lib"; +import { + ATTACK_PATHS_VIEW_STATES, + getAttackPathsViewState, + getGraphBuildingProgress, + isScanInFlight, +} from "./_lib/get-attack-paths-view-state"; + +const SCROLL_CONTAINER_CLASS = + "minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4"; export default function AttackPathsPage() { const searchParams = useSearchParams(); @@ -80,11 +85,12 @@ export default function AttackPathsPage() { const finding = useFindingDetails(); const { toast } = useToast(); - const { scans, scansLoading, refreshScans } = useAttackPathScans({ - onNoReadyScan: isAttackPathsReplay - ? () => router.push("/scans?onboarding=view-first-scan") - : undefined, - }); + const { scans, scansLoading, loadError, refreshScans, retryLoadScans } = + useAttackPathScans({ + onNoReadyScan: isAttackPathsReplay + ? () => router.push("/scans?onboarding=view-first-scan") + : undefined, + }); const [queriesLoading, setQueriesLoading] = useState(true); const [queriesError, setQueriesError] = useState(null); @@ -103,7 +109,9 @@ export default function AttackPathsPage() { const hasNoScans = scans.length === 0; useDriverTour(attackPathsEmptyTour, { - enabled: onboardingEnabled && !scansLoading && hasNoScans, + // Gate on !loadError: the empty-scans CTA anchor only renders in the + // NO_SCANS view-state, not in the ERROR state (which also has scans === []). + enabled: onboardingEnabled && !scansLoading && !loadError && hasNoScans, }); const { start: startAttackPathsTour } = useDriverTour( @@ -164,12 +172,12 @@ export default function AttackPathsPage() { graphState.resetGraph(); }, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only - const hasExecutingScan = scans.some( - (scan) => - scan.attributes.state === SCAN_STATES.EXECUTING || - scan.attributes.state === SCAN_STATES.SCHEDULED, - ); + // Poll while a scan is in flight so the page auto-advances when the graph is ready. + const hasScanInFlight = isScanInFlight(scans); + const viewState = getAttackPathsViewState({ scansLoading, loadError, scans }); + + // Detect if the selected scan is showing data from a previous cycle const selectedScan = scans.find((scan) => scan.id === scanId); const isViewingPreviousCycleData = selectedScan && @@ -396,7 +404,7 @@ export default function AttackPathsPage() { return (
@@ -418,25 +426,23 @@ export default function AttackPathsPage() {

- {scansLoading ? ( -
+ {viewState === ATTACK_PATHS_VIEW_STATES.LOADING ? ( +

Loading scans...

- ) : hasNoScans ? ( + ) : viewState === ATTACK_PATHS_VIEW_STATES.NO_SCANS ? ( + // Keep the empty-scans tour anchor: attackPathsEmptyTour targets + // data-tour-id="attack-paths-empty-scans-cta". The panel's NO_SCANS + // render is the same "No scans available" + Go to Scan Jobs CTA.
- - - No scans available - - - You need to run a scan before you can analyze attack paths.{" "} - - Go to Scan Jobs - - - - +
+ ) : viewState !== ATTACK_PATHS_VIEW_STATES.READY ? ( + ) : ( <> Loading scans...
}> @@ -444,21 +450,20 @@ export default function AttackPathsPage() { {isViewingPreviousCycleData && ( - - - Viewing data from a previous scan - - This scan is currently{" "} - {selectedScan.attributes.state === SCAN_STATES.EXECUTING - ? `running (${selectedScan.attributes.progress}%)` - : selectedScan.attributes.state} - . The graph data shown is from the last completed cycle. - - + + This scan is currently{" "} + {selectedScan.attributes.state === SCAN_STATES.EXECUTING + ? `running (${selectedScan.attributes.progress}%)` + : selectedScan.attributes.state} + . The graph data shown is from the last completed cycle. + )} {scanId && ( -
+
{queriesLoading ? (

Loading queries...

) : queriesError ? ( @@ -516,7 +521,7 @@ export default function AttackPathsPage() { (graphState.data && graphState.data.nodes && graphState.data.nodes.length > 0)) && ( -
+
{graphState.loading ? ( ) : graphState.data && diff --git a/ui/components/shared/status-alert.test.tsx b/ui/components/shared/status-alert.test.tsx new file mode 100644 index 0000000000..c99500e57e --- /dev/null +++ b/ui/components/shared/status-alert.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { StatusAlert } from "./status-alert"; + +describe("StatusAlert", () => { + it("renders the info variant with title and children", () => { + render( + + Something to know. + , + ); + + expect(screen.getByText("Heads up")).toBeInTheDocument(); + expect(screen.getByText("Something to know.")).toBeInTheDocument(); + }); + + it("renders the error variant with title and children", () => { + render( + + Try again. + , + ); + + expect(screen.getByText("It broke")).toBeInTheDocument(); + expect(screen.getByText("Try again.")).toBeInTheDocument(); + }); + + it("applies descriptionClassName to the description element", () => { + render( + + Body + , + ); + + const description = screen + .getByText("Body") + .closest("[data-slot='alert-description']"); + expect(description).toHaveClass("w-full", "gap-3"); + }); +}); diff --git a/ui/components/shared/status-alert.tsx b/ui/components/shared/status-alert.tsx new file mode 100644 index 0000000000..286e7e6b9a --- /dev/null +++ b/ui/components/shared/status-alert.tsx @@ -0,0 +1,41 @@ +import { CircleAlert, Info } from "lucide-react"; +import type { ReactNode } from "react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert"; + +const STATUS_ALERT_ICONS = { + info: Info, + error: CircleAlert, +} as const; + +type StatusAlertVariant = keyof typeof STATUS_ALERT_ICONS; + +interface StatusAlertProps { + variant: StatusAlertVariant; + title: string; + descriptionClassName?: string; + children: ReactNode; +} + +/** + * Shared status banner: a shadcn `Alert` with a variant-driven icon, title, and + * description. Use for full-width info/error messages (waiting states, load + * failures, inline notices). + */ +export const StatusAlert = ({ + variant, + title, + descriptionClassName, + children, +}: StatusAlertProps) => { + const Icon = STATUS_ALERT_ICONS[variant]; + return ( + + + {title} + + {children} + + + ); +}; From ddbf3405a03316be862540c97a8721a389de3aee Mon Sep 17 00:00:00 2001 From: s1ns3nz0 Date: Thu, 18 Jun 2026 17:05:02 +0900 Subject: [PATCH 05/55] feat(azure): add defender_ensure_defender_cspm_is_on check (#11037) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + prowler/compliance/azure/cis_5.0_azure.json | 4 +- .../compliance/azure/iso27001_2022_azure.json | 1 + .../__init__.py | 0 ...r_ensure_defender_cspm_is_on.metadata.json | 37 ++++++ .../defender_ensure_defender_cspm_is_on.py | 35 ++++++ ...efender_ensure_defender_cspm_is_on_test.py | 117 ++++++++++++++++++ 7 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/__init__.py create mode 100644 prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json create mode 100644 prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py create mode 100644 tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 7ba3afbe3d..db0987dea6 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `cosmosdb_account_public_network_access_disabled` check for Azure provider, verifying Cosmos DB accounts have public network access disabled so connectivity is restricted to private endpoints or VNet service endpoints [(#11034)](https://github.com/prowler-cloud/prowler/pull/11034) - `databricks_workspace_public_network_access_disabled` check for Azure provider, verifying Databricks workspaces have public network access disabled so connectivity is restricted to Azure Private Link private endpoints [(#11035)](https://github.com/prowler-cloud/prowler/pull/11035) - `databricks_workspace_no_public_ip_enabled` check for Azure provider, verifying Databricks workspaces use secure cluster connectivity (no public IP) so compute nodes are not assigned public IP addresses [(#11036)](https://github.com/prowler-cloud/prowler/pull/11036) +- `defender_ensure_defender_cspm_is_on` check for Azure provider, verifying Microsoft Defender Cloud Security Posture Management (CSPM) is enabled on the Standard tier [(#11037)](https://github.com/prowler-cloud/prowler/pull/11037) - `aks_cluster_auto_upgrade_enabled` check for Azure provider [(#11027)](https://github.com/prowler-cloud/prowler/pull/11027) - Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398) - Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602) diff --git a/prowler/compliance/azure/cis_5.0_azure.json b/prowler/compliance/azure/cis_5.0_azure.json index 21785d92b6..086320fecd 100644 --- a/prowler/compliance/azure/cis_5.0_azure.json +++ b/prowler/compliance/azure/cis_5.0_azure.json @@ -2094,7 +2094,9 @@ { "Id": "8.1.1.1", "Description": "Ensure Microsoft Defender CSPM is set to 'On'", - "Checks": [], + "Checks": [ + "defender_ensure_defender_cspm_is_on" + ], "Attributes": [ { "Section": "8 Security Services", diff --git a/prowler/compliance/azure/iso27001_2022_azure.json b/prowler/compliance/azure/iso27001_2022_azure.json index 4172a9449a..719f358de5 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -1340,6 +1340,7 @@ } ], "Checks": [ + "defender_ensure_defender_cspm_is_on", "monitor_alert_create_policy_assignment", "monitor_alert_create_update_nsg", "monitor_alert_create_update_public_ip_address_rule", diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/__init__.py b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json new file mode 100644 index 0000000000..cae92490af --- /dev/null +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "defender_ensure_defender_cspm_is_on", + "CheckTitle": "Microsoft Defender CSPM is set to On", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.security/pricings", + "ResourceGroup": "security", + "Description": "**Microsoft Defender for Cloud** Cloud Security Posture Management (CSPM) plan is evaluated for **standard tier** activation. Defender CSPM provides advanced posture management capabilities including attack path analysis, cloud security explorer, agentless scanning, and governance rules.", + "Risk": "Without Defender CSPM, the subscription relies on **foundational CSPM** (free tier) which lacks attack path analysis, agentless vulnerability scanning, and security governance. Advanced threats exploiting misconfiguration chains go undetected.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-cloud-security-posture-management", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-enhanced-security" + ], + "Remediation": { + "Code": { + "CLI": "az security pricing create -n CloudPosture --tier Standard", + "NativeIaC": "```bicep\ntargetScope = 'subscription'\n\nresource defenderCSPM 'Microsoft.Security/pricings@2024-01-01' = {\n name: 'CloudPosture'\n properties: {\n pricingTier: 'Standard' // Critical: enables Defender CSPM\n }\n}\n```", + "Other": "1. Sign in to Azure portal\n2. Go to Microsoft Defender for Cloud\n3. Select Environment Settings\n4. Click on the subscription\n5. Set Cloud Security Posture Management (CSPM) to On\n6. Click Save", + "Terraform": "```hcl\nresource \"azurerm_security_center_subscription_pricing\" \"\" {\n tier = \"Standard\" # Critical: enables Defender CSPM\n resource_type = \"CloudPosture\"\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Defender CSPM** standard tier for advanced cloud security posture management. Evaluate the cost against the security benefits \u2014 CSPM provides attack path analysis and agentless scanning.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_cspm_is_on" + } + }, + "Categories": [ + "threat-detection" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py new file mode 100644 index 0000000000..cf7957400c --- /dev/null +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.defender.defender_client import defender_client + + +class defender_ensure_defender_cspm_is_on(Check): + """ + Ensure Microsoft Defender Cloud Security Posture Management (CSPM) is set to On. + + This check evaluates whether the Defender CSPM plan (CloudPosture pricing) is enabled with the Standard tier for each subscription. + + - PASS: The CloudPosture pricing tier is "Standard" (Defender CSPM is on). + - FAIL: The CloudPosture pricing tier is not "Standard" (Defender CSPM is off). + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) + if "CloudPosture" in pricings: + report = Check_Report_Azure( + metadata=self.metadata(), + resource=pricings["CloudPosture"], + ) + report.subscription = subscription + report.resource_name = "Defender plan CSPM" + report.status = "PASS" + report.status_extended = f"Defender plan CSPM from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." + if pricings["CloudPosture"].pricing_tier != "Standard": + report.status = "FAIL" + report.status_extended = f"Defender plan CSPM from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." + + findings.append(report) + return findings diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py new file mode 100644 index 0000000000..b81d10d31d --- /dev/null +++ b/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py @@ -0,0 +1,117 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.defender.defender_service import Pricing +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_defender_ensure_defender_cspm_is_on: + def test_defender_no_cspm(self): + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = {} + + 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_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 0 + + def test_defender_cspm_pricing_tier_not_standard(self): + resource_id = str(uuid4()) + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = { + AZURE_SUBSCRIPTION_ID: { + "CloudPosture": Pricing( + resource_id=resource_id, + resource_name="Defender plan CSPM", + pricing_tier="Free", + free_trial_remaining_time=0, + ) + } + } + + 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_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Defender plan CSPM from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "Defender plan CSPM" + assert result[0].resource_id == resource_id + + def test_defender_cspm_pricing_tier_standard(self): + resource_id = str(uuid4()) + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = { + AZURE_SUBSCRIPTION_ID: { + "CloudPosture": Pricing( + resource_id=resource_id, + resource_name="Defender plan CSPM", + pricing_tier="Standard", + free_trial_remaining_time=0, + ) + } + } + + 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_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Defender plan CSPM from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "Defender plan CSPM" + assert result[0].resource_id == resource_id From aee3b392a7caf5347c2c4eb4f79dd3c989d1eb9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Thu, 18 Jun 2026 10:30:27 +0200 Subject: [PATCH 06/55] fix(compliance): multi-section undercount & leaked provider tab (#11567) --- prowler/CHANGELOG.md | 8 + .../asd_essential_eight.py | 27 +- prowler/lib/outputs/compliance/c5/c5.py | 26 +- prowler/lib/outputs/compliance/ccc/ccc.py | 26 +- prowler/lib/outputs/compliance/cis/cis.py | 28 +- prowler/lib/outputs/compliance/ens/ens.py | 21 +- .../compliance/kisa_ismsp/kisa_ismsp.py | 27 +- .../compliance/mitre_attack/mitre_attack.py | 31 +- .../okta_idaas_stig/okta_idaas_stig.py | 26 +- .../prowler_threatscore.py | 45 ++- .../compliance/universal/universal_table.py | 56 ++- .../asd_essential_eight_table_test.py | 132 +++++++ tests/lib/outputs/compliance/c5/__init__.py | 0 .../outputs/compliance/c5/c5_table_test.py | 130 +++++++ .../outputs/compliance/ccc/ccc_table_test.py | 130 +++++++ .../outputs/compliance/cis/cis_table_test.py | 162 +++++++++ .../outputs/compliance/ens/ens_table_test.py | 235 +++++++++++++ .../outputs/compliance/kisa_ismsp/__init__.py | 0 .../kisa_ismsp/kisa_ismsp_table_test.py | 137 ++++++++ .../compliance/mitre_attack/__init__.py | 0 .../mitre_attack/mitre_attack_table_test.py | 140 ++++++++ .../compliance/okta_idaas_stig/__init__.py | 0 .../okta_idaas_stig_table_test.py | 136 ++++++++ .../prowler_threatscore/__init__.py | 0 .../prowler_threatscore_table_test.py | 147 ++++++++ .../universal/universal_table_test.py | 321 +++++++++++++++++- 26 files changed, 1904 insertions(+), 87 deletions(-) create mode 100644 tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py create mode 100644 tests/lib/outputs/compliance/c5/__init__.py create mode 100644 tests/lib/outputs/compliance/c5/c5_table_test.py create mode 100644 tests/lib/outputs/compliance/ccc/ccc_table_test.py create mode 100644 tests/lib/outputs/compliance/cis/cis_table_test.py create mode 100644 tests/lib/outputs/compliance/ens/ens_table_test.py create mode 100644 tests/lib/outputs/compliance/kisa_ismsp/__init__.py create mode 100644 tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py create mode 100644 tests/lib/outputs/compliance/mitre_attack/__init__.py create mode 100644 tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py create mode 100644 tests/lib/outputs/compliance/okta_idaas_stig/__init__.py create mode 100644 tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py create mode 100644 tests/lib/outputs/compliance/prowler_threatscore/__init__.py create mode 100644 tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index db0987dea6..08d892155c 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -42,6 +42,14 @@ All notable changes to the **Prowler SDK** are documented in this file. --- +## [5.30.3] (Prowler UNRELEASED) + +### 🐞 Fixed + +- CLI compliance summary tables no longer undercount findings mapped to multiple sections nor double-count a single finding mapped to several requirements within the same group/split, and the Provider column no longer leaks a value from another framework [(#11567)](https://github.com/prowler-cloud/prowler/pull/11567) + +--- + ## [5.30.2] (Prowler v5.30.2) ### 🐞 Fixed diff --git a/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py index 75481fb6aa..df23aeb1d1 100644 --- a/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py +++ b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py @@ -22,11 +22,14 @@ def get_asd_essential_eight_table( pass_count = [] fail_count = [] muted_count = [] + section_seen = {} + provider = "" for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ASD-Essential-Eight": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section @@ -36,21 +39,33 @@ def get_asd_essential_eight_table( "PASS": 0, "Muted": 0, } + section_seen[section] = set() + + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - asd_essential_eight_compliance_table["Provider"].append(compliance.Provider) + asd_essential_eight_compliance_table["Provider"].append(provider) asd_essential_eight_compliance_table["Section"].append(section) if sections[section]["FAIL"] > 0: asd_essential_eight_compliance_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/c5/c5.py b/prowler/lib/outputs/compliance/c5/c5.py index 32eb7e0f5a..b48260b5a2 100644 --- a/prowler/lib/outputs/compliance/c5/c5.py +++ b/prowler/lib/outputs/compliance/c5/c5.py @@ -22,33 +22,47 @@ def get_c5_table( fail_count = [] muted_count = [] sections = {} + section_seen = {} + provider = "" for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "C5": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/ccc/ccc.py b/prowler/lib/outputs/compliance/ccc/ccc.py index 99a6c91cd9..d8d76ad2d9 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc.py +++ b/prowler/lib/outputs/compliance/ccc/ccc.py @@ -22,33 +22,47 @@ def get_ccc_table( fail_count = [] muted_count = [] sections = {} + section_seen = {} + provider = "" for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "CCC": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/cis/cis.py b/prowler/lib/outputs/compliance/cis/cis.py index 7f161f34c2..4acbd7fe58 100644 --- a/prowler/lib/outputs/compliance/cis/cis.py +++ b/prowler/lib/outputs/compliance/cis/cis.py @@ -13,6 +13,9 @@ def get_cis_table( compliance_overview: bool, ): sections = {} + section_muted_seen = {} + section_split_seen = {} + provider = "" cis_compliance_table = { "Provider": [], "Section": [], @@ -29,6 +32,7 @@ def get_cis_table( for compliance in check_compliances: version_in_name = compliance_framework.split("_")[1] if compliance.Framework == "CIS" and version_in_name in compliance.Version: + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section @@ -40,9 +44,19 @@ def get_cis_table( "Level 2": {"FAIL": 0, "PASS": 0}, "Muted": 0, } + section_muted_seen[section] = set() + section_split_seen[section] = { + "Level 1": set(), + "Level 2": set(), + } if finding.muted: + # Overview total: count each finding once per framework if index not in muted_count: muted_count.append(index) + # Per-section Muted: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_muted_seen[section]: + section_muted_seen[section].add(index) sections[section]["Muted"] += 1 else: if finding.status == "FAIL" and index not in fail_count: @@ -50,13 +64,21 @@ def get_cis_table( elif finding.status == "PASS" and index not in pass_count: pass_count.append(index) if "Level 1" in attribute.Profile: - if not finding.muted: + if ( + not finding.muted + and index not in section_split_seen[section]["Level 1"] + ): + section_split_seen[section]["Level 1"].add(index) if finding.status == "FAIL": sections[section]["Level 1"]["FAIL"] += 1 else: sections[section]["Level 1"]["PASS"] += 1 elif "Level 2" in attribute.Profile: - if not finding.muted: + if ( + not finding.muted + and index not in section_split_seen[section]["Level 2"] + ): + section_split_seen[section]["Level 2"].add(index) if finding.status == "FAIL": sections[section]["Level 2"]["FAIL"] += 1 else: @@ -65,7 +87,7 @@ def get_cis_table( # Add results to table sections = dict(sorted(sections.items())) for section in sections: - cis_compliance_table["Provider"].append(compliance.Provider) + cis_compliance_table["Provider"].append(provider) cis_compliance_table["Section"].append(section) if sections[section]["Level 1"]["FAIL"] > 0: cis_compliance_table["Level 1"].append( diff --git a/prowler/lib/outputs/compliance/ens/ens.py b/prowler/lib/outputs/compliance/ens/ens.py index a414a0206d..c39abe0a6a 100644 --- a/prowler/lib/outputs/compliance/ens/ens.py +++ b/prowler/lib/outputs/compliance/ens/ens.py @@ -13,6 +13,8 @@ def get_ens_table( compliance_overview: bool, ): marcos = {} + marco_muted_seen = {} + provider = "" ens_compliance_table = { "Proveedor": [], "Marco/Categoria": [], @@ -31,6 +33,7 @@ def get_ens_table( check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ENS": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: marco_categoria = f"{attribute.Marco}/{attribute.Categoria}" @@ -44,17 +47,23 @@ def get_ens_table( "Bajo": 0, "Muted": 0, } + marco_muted_seen[marco_categoria] = set() if finding.muted: + # Overview total: count each finding once per framework if index not in muted_count: muted_count.append(index) + # Per-marco Muted: count each finding once per marco + # it belongs to (a finding can map to several marcos). + if index not in marco_muted_seen[marco_categoria]: + marco_muted_seen[marco_categoria].add(index) marcos[marco_categoria]["Muted"] += 1 else: if finding.status == "FAIL": - if ( - attribute.Tipo != "recomendacion" - and index not in fail_count - ): - fail_count.append(index) + if attribute.Tipo != "recomendacion": + if index not in fail_count: + fail_count.append(index) + # Mark every marco the finding belongs to as + # NO CUMPLE, not just the first one seen. marcos[marco_categoria][ "Estado" ] = f"{Fore.RED}NO CUMPLE{Style.RESET_ALL}" @@ -71,7 +80,7 @@ def get_ens_table( # Add results to table for marco in sorted(marcos): - ens_compliance_table["Proveedor"].append(compliance.Provider) + ens_compliance_table["Proveedor"].append(provider) ens_compliance_table["Marco/Categoria"].append(marco) ens_compliance_table["Estado"].append(marcos[marco]["Estado"]) ens_compliance_table["Opcional"].append( diff --git a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py index 93b925a5ff..e7c00b188d 100644 --- a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py +++ b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py @@ -13,7 +13,9 @@ def get_kisa_ismsp_table( compliance_overview: bool, ): sections = {} + section_seen = {} sections_status = {} + provider = "" kisa_ismsp_compliance_table = { "Provider": [], "Section": [], @@ -31,6 +33,7 @@ def get_kisa_ismsp_table( compliance.Framework.startswith("KISA") and compliance.Version in compliance_framework ): + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section @@ -43,16 +46,28 @@ def get_kisa_ismsp_table( }, "Muted": 0, } + section_seen[section] = set() + + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["Status"]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["Status"]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["Status"]["PASS"] += 1 # Add results to table @@ -70,7 +85,7 @@ def get_kisa_ismsp_table( else: sections_status[section] = f"{Fore.GREEN}PASS{Style.RESET_ALL}" for section in sections: - kisa_ismsp_compliance_table["Provider"].append(compliance.Provider) + kisa_ismsp_compliance_table["Provider"].append(provider) kisa_ismsp_compliance_table["Section"].append(section) kisa_ismsp_compliance_table["Status"].append(sections_status[section]) kisa_ismsp_compliance_table["Muted"].append( diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py index bab3e4e31a..7492624aff 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py @@ -13,6 +13,8 @@ def get_mitre_attack_table( compliance_overview: bool, ): tactics = {} + tactic_seen = {} + provider = "" mitre_compliance_table = { "Provider": [], "Tactic": [], @@ -30,27 +32,38 @@ def get_mitre_attack_table( "MITRE-ATTACK" in compliance.Framework and compliance.Version in compliance_framework ): + provider = compliance.Provider for requirement in compliance.Requirements: for tactic in requirement.Tactics: if tactic not in tactics: tactics[tactic] = {"FAIL": 0, "PASS": 0, "Muted": 0} + tactic_seen[tactic] = set() + + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) + elif finding.status == "FAIL": + if index not in fail_count: + fail_count.append(index) + elif finding.status == "PASS": + if index not in pass_count: + pass_count.append(index) + + # Per-tactic counts: count each finding once per tactic + # it belongs to (a finding can map to several tactics). + if index not in tactic_seen[tactic]: + tactic_seen[tactic].add(index) + if finding.muted: tactics[tactic]["Muted"] += 1 - else: - if finding.status == "FAIL": - if index not in fail_count: - fail_count.append(index) - tactics[tactic]["FAIL"] += 1 + elif finding.status == "FAIL": + tactics[tactic]["FAIL"] += 1 elif finding.status == "PASS": - if index not in pass_count: - pass_count.append(index) - tactics[tactic]["PASS"] += 1 + tactics[tactic]["PASS"] += 1 # Add results to table tactics = dict(sorted(tactics.items())) for tactic in tactics: - mitre_compliance_table["Provider"].append(compliance.Provider) + mitre_compliance_table["Provider"].append(provider) mitre_compliance_table["Tactic"].append(tactic) if tactics[tactic]["FAIL"] > 0: mitre_compliance_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py index 5c76055a06..1febe02f60 100644 --- a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py @@ -22,33 +22,47 @@ def get_okta_idaas_stig_table( fail_count = [] muted_count = [] sections = {} + section_seen = {} + provider = "" for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "Okta-IDaaS-STIG": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py index cfcd4a006e..b17307f04a 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py @@ -24,6 +24,8 @@ def get_prowler_threatscore_table( fail_count = [] muted_count = [] pillars = {} + pillar_seen = {} + provider = "" generic_score = 0 max_generic_score = 0 counted_findings_generic = [] @@ -35,6 +37,7 @@ def get_prowler_threatscore_table( check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ProwlerThreatScore": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: pillar = attribute.Section @@ -65,17 +68,28 @@ def get_prowler_threatscore_table( if pillar not in pillars: pillars[pillar] = {"FAIL": 0, "PASS": 0, "Muted": 0} + pillar_seen[pillar] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - pillars[pillar]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - pillars[pillar]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-pillar counts: count each finding once per pillar + # it belongs to (a finding can map to several pillars). + if index not in pillar_seen[pillar]: + pillar_seen[pillar].add(index) + if finding.muted: + pillars[pillar]["Muted"] += 1 + elif finding.status == "FAIL": + pillars[pillar]["FAIL"] += 1 + elif finding.status == "PASS": pillars[pillar]["PASS"] += 1 # Generic score @@ -90,18 +104,21 @@ def get_prowler_threatscore_table( counted_findings_generic.append(index) no_findings_pillars = [] - bulk_compliance = Compliance.get_bulk(provider=compliance.Provider.lower()).get( - compliance_framework + bulk_compliance = ( + Compliance.get_bulk(provider=provider.lower()).get(compliance_framework) + if provider + else None ) - for requirement in bulk_compliance.Requirements: - for attribute in requirement.Attributes: - pillar = attribute.Section - if pillar not in pillars.keys() and pillar not in no_findings_pillars: - no_findings_pillars.append(pillar) + if bulk_compliance: + for requirement in bulk_compliance.Requirements: + for attribute in requirement.Attributes: + pillar = attribute.Section + if pillar not in pillars.keys() and pillar not in no_findings_pillars: + no_findings_pillars.append(pillar) pillars = dict(sorted(pillars.items())) for pillar in pillars: - pillar_table["Provider"].append(compliance.Provider) + pillar_table["Provider"].append(provider) pillar_table["Pillar"].append(pillar) if max_score_per_pillar[pillar] == 0: pillar_score = 100.0 @@ -127,7 +144,7 @@ def get_prowler_threatscore_table( ) for pillar in no_findings_pillars: - pillar_table["Provider"].append(compliance.Provider) + pillar_table["Provider"].append(provider) pillar_table["Pillar"].append(pillar) pillar_table["Score"].append(f"{Style.BRIGHT}{Fore.GREEN}100%{Style.RESET_ALL}") pillar_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}") diff --git a/prowler/lib/outputs/compliance/universal/universal_table.py b/prowler/lib/outputs/compliance/universal/universal_table.py index e838c5e9cf..e54ad5155c 100644 --- a/prowler/lib/outputs/compliance/universal/universal_table.py +++ b/prowler/lib/outputs/compliance/universal/universal_table.py @@ -163,6 +163,7 @@ def _render_grouped( """Grouped mode: one row per group with pass/fail counts.""" check_map = _build_requirement_check_map(framework, provider) groups = {} + group_seen = {} pass_count = [] fail_count = [] muted_count = [] @@ -176,17 +177,28 @@ def _render_grouped( for group_key in _get_group_key(req, group_by): if group_key not in groups: groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} + group_seen[group_key] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - groups[group_key]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - groups[group_key]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-group counts: count each finding once per group it belongs + # to (a finding can map to several groups via several requirements). + if index not in group_seen[group_key]: + group_seen[group_key].add(index) + if finding.muted: + groups[group_key]["Muted"] += 1 + elif finding.status == "FAIL": + groups[group_key]["FAIL"] += 1 + elif finding.status == "PASS": groups[group_key]["PASS"] += 1 if not _print_overview( @@ -258,6 +270,8 @@ def _render_split( split_field = split_by.field split_values = split_by.values groups = {} + group_muted_seen = {} + group_split_seen = {} pass_count = [] fail_count = [] muted_count = [] @@ -274,12 +288,19 @@ def _render_split( sv: {"FAIL": 0, "PASS": 0} for sv in split_values } groups[group_key]["Muted"] = 0 + group_muted_seen[group_key] = set() + group_split_seen[group_key] = {sv: set() for sv in split_values} split_val = req.attributes.get(split_field, "") if finding.muted: + # Overview total: count each finding once per framework if index not in muted_count: muted_count.append(index) + # Per-group Muted: count each finding once per group it + # belongs to (a finding can map to several groups). + if index not in group_muted_seen[group_key]: + group_muted_seen[group_key].add(index) groups[group_key]["Muted"] += 1 else: if finding.status == "FAIL" and index not in fail_count: @@ -289,7 +310,8 @@ def _render_split( for sv in split_values: if sv in str(split_val): - if not finding.muted: + if index not in group_split_seen[group_key][sv]: + group_split_seen[group_key][sv].add(index) if finding.status == "FAIL": groups[group_key][sv]["FAIL"] += 1 else: @@ -364,6 +386,7 @@ def _render_scored( risk_field = scoring.risk_field weight_field = scoring.weight_field groups = {} + group_seen = {} pass_count = [] fail_count = [] muted_count = [] @@ -388,6 +411,7 @@ def _render_scored( if group_key not in groups: groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} + group_seen[group_key] = set() score_per_group[group_key] = 0 max_score_per_group[group_key] = 0 counted_per_group[group_key] = [] @@ -398,16 +422,26 @@ def _render_scored( max_score_per_group[group_key] += risk * weight counted_per_group[group_key].append(index) + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - groups[group_key]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - groups[group_key]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-group counts: count each finding once per group it belongs + # to (a finding can map to several groups via several requirements). + if index not in group_seen[group_key]: + group_seen[group_key].add(index) + if finding.muted: + groups[group_key]["Muted"] += 1 + elif finding.status == "FAIL": + groups[group_key]["FAIL"] += 1 + elif finding.status == "PASS": groups[group_key]["PASS"] += 1 if index not in counted_generic and not finding.muted: diff --git a/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py b/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py new file mode 100644 index 0000000000..6382dc1d2c --- /dev/null +++ b/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py @@ -0,0 +1,132 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight import ( + get_asd_essential_eight_table, +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="ASD-Essential-Eight"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestASDEssentialEightTable: + """Test cases verifying multi-section counting and provider-column attribution for the ASD Essential Eight compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched ASD-Essential-Eight + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/c5/__init__.py b/tests/lib/outputs/compliance/c5/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/c5/c5_table_test.py b/tests/lib/outputs/compliance/c5/c5_table_test.py new file mode 100644 index 0000000000..c423f5af0b --- /dev/null +++ b/tests/lib/outputs/compliance/c5/c5_table_test.py @@ -0,0 +1,130 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.c5.c5 import get_c5_table + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="C5"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestC5Table: + """Verify multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched C5 compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/ccc/ccc_table_test.py b/tests/lib/outputs/compliance/ccc/ccc_table_test.py new file mode 100644 index 0000000000..f647aff3d0 --- /dev/null +++ b/tests/lib/outputs/compliance/ccc/ccc_table_test.py @@ -0,0 +1,130 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.ccc.ccc import get_ccc_table + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="CCC"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestCCCTable: + """Test cases verifying multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched CCC compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/cis/cis_table_test.py b/tests/lib/outputs/compliance/cis/cis_table_test.py new file mode 100644 index 0000000000..c47e1387a0 --- /dev/null +++ b/tests/lib/outputs/compliance/cis/cis_table_test.py @@ -0,0 +1,162 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.cis.cis import get_cis_table + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _attr(section, profile="Level 1"): + return SimpleNamespace(Section=section, Profile=profile) + + +def _make_compliance(provider, attributes, version="1.4", framework="CIS"): + """Build a per-check CIS compliance with the given (section, profile) attrs.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=attributes)], + ) + + +def _make_compliance_multi_req(provider, attributes, version="1.4", framework="CIS"): + """Build a per-check CIS compliance where each attr is its own requirement, + simulating a check that appears in several requirements.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=[attr]) for attr in attributes], + ) + + +class TestCISTable: + """Verify multi-section counting and provider-column attribution for the CIS compliance table.""" + + def test_muted_multi_section_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several sections must increment the + per-section Muted column for every section, not only the first seen. + + CIS counts FAIL/PASS through Level 1/Level 2 buckets, so only the Muted + per-section count was affected by the undercount bug. + """ + bulk_metadata = { + # check_a is muted and belongs to two sections at once. + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM"), _attr("2 Logging")]) + ] + ), + # A real (non-muted) finding so the table is rendered. + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", [_attr("1 IAM")])] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both section rows must carry a Muted count of 1 in their last cell. + # Before the fix only the first section seen got incremented. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_same_section_level_not_double_counted(self, capsys, tmp_path): + """A single finding whose check maps to several requirements that share + the same section and profile must count once for that section/level, + not once per requirement (FAIL(1), never FAIL(2)).""" + bulk_metadata = { + # check_a is a single FAIL mapped to two requirements, both in the + # same section "1 IAM" and the same profile "Level 1". + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance_multi_req("aws", [_attr("1 IAM"), _attr("1 IAM")]) + ] + ), + # A second finding in another section so the table renders. + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", [_attr("2 Logging")])] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The "1 IAM" row must show FAIL(1) for Level 1, never FAIL(2). + assert "FAIL(1)" in plain + assert "FAIL(2)" not in plain + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched CIS compliance, not + from a different framework that trails it in the compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM")]), + _make_compliance( + "gcp", [_attr("Other")], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM")]), + _make_compliance( + "gcp", [_attr("Other")], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The trailing unrelated framework's provider must not leak in. + assert "gcp" not in captured.out diff --git a/tests/lib/outputs/compliance/ens/ens_table_test.py b/tests/lib/outputs/compliance/ens/ens_table_test.py new file mode 100644 index 0000000000..2373885dbc --- /dev/null +++ b/tests/lib/outputs/compliance/ens/ens_table_test.py @@ -0,0 +1,235 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.ens.ens import get_ens_table + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _attr(marco, categoria, tipo="requisito", nivel="alto"): + return SimpleNamespace(Marco=marco, Categoria=categoria, Tipo=tipo, Nivel=nivel) + + +def _make_compliance(provider, attributes, framework="ENS"): + """Build a per-check ENS compliance with the given marco/categoria attrs.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=attributes)], + ) + + +class TestENSTable: + """Test cases for ENS compliance table rendering. + + Verify multi-marco counting and provider-column attribution for the + compliance table. + """ + + def test_no_cumple_marked_in_every_marco(self, capsys, tmp_path): + """A single failing finding mapped to several marcos must mark every + one of them as NO CUMPLE, not only the first marco seen.""" + bulk_metadata = { + # check_a fails and belongs to two distinct marcos/categorias. + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr("operacional", "control de acceso"), + _attr("organizativo", "politica de seguridad"), + ], + ) + ] + ), + # A passing finding so the overview total reaches 2. + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "control de acceso")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both marco rows the failing finding maps to must read NO CUMPLE. + # Before the fix only the first marco was marked, the second stayed + # CUMPLE. Anchor the assertion to the actual marco rows (not the + # overview header line which also mentions NO CUMPLE). + op_row = [ + line + for line in plain.splitlines() + if "operacional/control de acceso" in line + ] + org_row = [ + line + for line in plain.splitlines() + if "organizativo/politica de seguridad" in line + ] + assert len(op_row) == 1 and "NO CUMPLE" in op_row[0] + assert len(org_row) == 1 and "NO CUMPLE" in org_row[0] + + def test_recomendacion_does_not_set_no_cumple(self, capsys, tmp_path): + """A FAIL on a 'recomendacion' attribute must not flip a marco to + NO CUMPLE (this path is intentionally excluded from the fix).""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr( + "operacional", "control de acceso", tipo="recomendacion" + ) + ], + ) + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("organizativo", "politica")]) + ] + ), + # A regular (non-recomendacion) check so the results table renders + # at least one marco row and the assertion below is not vacuous. + "check_c": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "continuidad")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + _make_finding("check_c", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The recomendacion FAIL must not appear as a NO CUMPLE marco row in the + # results table (the overview header line is allowed to mention it). + marco_rows = [ + line + for line in plain.splitlines() + if "operacional" in line or "organizativo" in line + ] + # Guard against a vacuous pass: the table must actually render rows. + assert marco_rows + assert all("NO CUMPLE" not in line for line in marco_rows) + + def test_muted_multi_marco_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several marcos must increment the + per-marco Muted column for every marco, not only the first seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr("operacional", "control de acceso"), + _attr("organizativo", "politica de seguridad"), + ], + ) + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "control de acceso")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both marco rows the muted finding maps to must report a Muted count of + # 1 in their last cell. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Proveedor column must come from the matched ENS compliance, not + from a different framework that trails it in the compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", [_attr("operacional", "control de acceso")] + ), + _make_compliance( + "gcp", [_attr("x", "y")], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", [_attr("operacional", "control de acceso")] + ), + _make_compliance( + "gcp", [_attr("x", "y")], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + assert "gcp" not in captured.out diff --git a/tests/lib/outputs/compliance/kisa_ismsp/__init__.py b/tests/lib/outputs/compliance/kisa_ismsp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py new file mode 100644 index 0000000000..be0fe25ad5 --- /dev/null +++ b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py @@ -0,0 +1,137 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp import get_kisa_ismsp_table + +# The generator matches a compliance when its Framework starts with "KISA" and +# its Version is contained in the compliance_framework argument. +COMPLIANCE_FRAMEWORK = "kisa-isms-p-2023_aws" + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance( + provider, sections, framework="KISA-ISMS-P", version="kisa-isms-p-2023" +): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestKISAISMSPTable: + """Verify multi-section counting and provider-column attribution for the KISA ISMS-P compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched KISA compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/mitre_attack/__init__.py b/tests/lib/outputs/compliance/mitre_attack/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py new file mode 100644 index 0000000000..3bd69b44e8 --- /dev/null +++ b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py @@ -0,0 +1,140 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.mitre_attack.mitre_attack import ( + get_mitre_attack_table, +) + +# The generator matches a compliance when "MITRE-ATTACK" is in its Framework and +# its Version is contained in the compliance_framework argument. +COMPLIANCE_FRAMEWORK = "mitre_attack_aws" + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance( + provider, tactics, framework="MITRE-ATTACK", version="mitre_attack" +): + """Build a per-check compliance covering the given tactics.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Tactics=tactics)], + ) + + +class TestMitreAttackTable: + """Test multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_tactic_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several tactics must show FAIL(1) in + every tactic, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence", "Execution"])] + ), + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence"])] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both Persistence and Execution must report FAIL(1); before the fix + # Execution was undercounted because the per-tactic count was gated by + # the global dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_tactic_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several tactics must increase the + per-tactic Muted count in every tactic, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence", "Execution"])] + ), + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence"])] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A second finding is needed so the table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both Persistence and Execution, so the + # Muted column must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched MITRE-ATTACK + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["Persistence"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["Persistence"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/__init__.py b/tests/lib/outputs/compliance/okta_idaas_stig/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py new file mode 100644 index 0000000000..3017ea4f92 --- /dev/null +++ b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py @@ -0,0 +1,136 @@ +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import ( + get_okta_idaas_stig_table, +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="Okta-IDaaS-STIG"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestOktaIDaaSSTIGTable: + """Test cases for Okta IDaaS STIG compliance table rendering.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + # check_a belongs to two sections at once. + "check_a": SimpleNamespace( + Compliance=[_make_compliance("okta", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("okta", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging + # was undercounted and rendered as plain PASS. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("okta", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("okta", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. Before the fix only the first section seen + # was incremented, leaving the second at 0. + # Strip ANSI color codes before counting the bare values per row. + import re + + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # Each section row ends with its Muted value in its own cell; both rows + # must carry a Muted count of 1. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched Okta-IDaaS-STIG + compliance, never from a different framework that happens to be the + last entry in the check's compliance list.""" + # check_a maps to Okta-IDaaS-STIG (provider "okta") but its compliance + # list ends with a *different* framework whose provider is "aws". With + # the bug the leaked loop variable made the table render "aws". + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("okta", ["IAM"]), + _make_compliance("aws", ["Other"], framework="OtherFramework"), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("okta", ["IAM"]), + _make_compliance("aws", ["Other"], framework="OtherFramework"), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "okta" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "aws" not in captured.out diff --git a/tests/lib/outputs/compliance/prowler_threatscore/__init__.py b/tests/lib/outputs/compliance/prowler_threatscore/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py new file mode 100644 index 0000000000..d754395dc3 --- /dev/null +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py @@ -0,0 +1,147 @@ +import re +from types import SimpleNamespace +from unittest import mock + +from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore import ( + get_prowler_threatscore_table, +) + +# Patch target for the Compliance.get_bulk lookup used to render pillars without +# findings; the tests don't exercise that path so it returns nothing. +COMPLIANCE_PATH = ( + "prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore.Compliance" +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, pillars, framework="ProwlerThreatScore"): + """Build a per-check compliance covering the given pillars (Section).""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace( + Attributes=[SimpleNamespace(Section=pillar, LevelOfRisk=5, Weight=100)] + ) + for pillar in pillars + ], + ) + + +class TestProwlerThreatScoreTable: + """Verify multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_pillar_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several pillars must show FAIL(1) in + every pillar, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Encryption"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Encryption must report FAIL(1); before the fix Encryption + # was undercounted because the per-pillar count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_pillar_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several pillars must increase the + per-pillar Muted count in every pillar, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Encryption"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Encryption, so the Muted + # column must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched ProwlerThreatScore + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/universal/universal_table_test.py b/tests/lib/outputs/compliance/universal/universal_table_test.py index 7598c43c8b..abe3d0c3d0 100644 --- a/tests/lib/outputs/compliance/universal/universal_table_test.py +++ b/tests/lib/outputs/compliance/universal/universal_table_test.py @@ -1,3 +1,4 @@ +import re from types import SimpleNamespace from unittest.mock import MagicMock @@ -26,6 +27,10 @@ def _make_finding(check_id, status="PASS", muted=False): return finding +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + def _make_framework(requirements, table_config, provider="AWS"): return ComplianceFramework( framework="TestFW", @@ -39,6 +44,8 @@ def _make_framework(requirements, table_config, provider="AWS"): class TestBuildRequirementCheckMap: + """Test cases for building the requirement-to-check map of a framework.""" + def test_basic(self): reqs = [ UniversalComplianceRequirement( @@ -103,6 +110,8 @@ class TestBuildRequirementCheckMap: class TestGetGroupKey: + """Test cases for resolving the group key of a requirement.""" + def test_normal_field(self): req = UniversalComplianceRequirement( id="1.1", @@ -124,7 +133,9 @@ class TestGetGroupKey: class TestGroupedMode: - def test_grouped_rendering(self, capsys): + """Test cases for grouped-mode universal compliance table rendering.""" + + def test_grouped_rendering(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -156,7 +167,7 @@ class TestGroupedMode: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -167,9 +178,118 @@ class TestGroupedMode: assert "PASS" in captured.out assert "FAIL" in captured.out + def test_grouped_multi_section_no_undercount(self, capsys, tmp_path): + """A single check mapped to several sections must be counted in + every section it belongs to, not only the first one seen.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = _make_framework(reqs, tc) + + # check_a (FAIL) belongs to both IAM and Logging sections; check_b + # (PASS, IAM only) is added so the overview total reaches 2 and the + # results table is rendered. + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both the IAM and Logging rows must report FAIL(1). Before the fix the + # second section seen (Logging) was undercounted to FAIL(0) and rendered + # as PASS. Anchor each occurrence to its own table row so an unrelated + # "FAIL(1)" elsewhere cannot mask an undercount. + iam_row = [ + line for line in plain.splitlines() if "IAM" in line and "FAIL(1)" in line + ] + logging_row = [ + line + for line in plain.splitlines() + if "Logging" in line and "FAIL(1)" in line + ] + assert len(iam_row) == 1 + assert len(logging_row) == 1 + + def test_grouped_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several groups must be counted in + the per-group Muted column of every group it belongs to.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = _make_framework(reqs, tc) + + # check_a is MUTED and belongs to both IAM and Logging; check_b is a + # plain FAIL so the overview total reaches 2 and the table is rendered. + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The muted finding belongs to both sections, so both the IAM row and + # the Logging row must carry a Muted count of 1 in their last cell. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + class TestSplitMode: - def test_split_rendering(self, capsys): + """Test cases for split-mode universal compliance table rendering.""" + + def test_split_rendering(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -204,7 +324,7 @@ class TestSplitMode: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -214,9 +334,119 @@ class TestSplitMode: assert "Level 1" in captured.out assert "Level 2" in captured.out + def test_split_muted_multi_section_not_undercounted(self, capsys, tmp_path): + """In split mode a single MUTED finding mapped to several groups must + be counted in the Muted column of every group it belongs to.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + fw = _make_framework(reqs, tc) + + # check_a is MUTED and belongs to both Storage and Logging; check_b is a + # plain FAIL so the table is rendered. + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both section rows must carry a Muted count of 1 (last cell). Before the + # fix only the first group seen incremented Muted, leaving the other 0. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_split_same_group_value_not_double_counted(self, capsys, tmp_path): + """A single finding whose check maps to several requirements that share + the same group and split value must count once for that group/split, + not once per requirement (FAIL(1), never FAIL(2)).""" + reqs = [ + # check_a appears in two requirements, both Storage / Level 1. + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + # A second group so the table renders with more than one finding. + UniversalComplianceRequirement( + id="2.1", + description="test3", + attributes={"Section": "Logging", "Profile": "Level 1"}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + fw = _make_framework(reqs, tc) + + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The Storage row must show FAIL(1) for Level 1, never FAIL(2). + assert "FAIL(1)" in plain + assert "FAIL(2)" not in plain + class TestScoredMode: - def test_scored_rendering(self, capsys): + """Test cases for scored-mode universal compliance table rendering.""" + + def test_scored_rendering(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -251,7 +481,7 @@ class TestScoredMode: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -261,9 +491,68 @@ class TestScoredMode: assert "Score" in captured.out assert "Threat Score" in captured.out + def test_scored_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """In scored mode a single FAIL finding mapped to several groups must + show FAIL(1) in every group it belongs to, not only the first one.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM", "LevelOfRisk": 5, "Weight": 100}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging", "LevelOfRisk": 3, "Weight": 50}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig( + group_by="Section", + scoring=ScoringConfig(risk_field="LevelOfRisk", weight_field="Weight"), + ) + fw = _make_framework(reqs, tc) + + # check_a (FAIL) belongs to both IAM and Logging; check_b (PASS, IAM + # only) raises the overview total to 2 so the table is rendered. + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + iam_row = [ + line for line in plain.splitlines() if "IAM" in line and "FAIL(1)" in line + ] + logging_row = [ + line + for line in plain.splitlines() + if "Logging" in line and "FAIL(1)" in line + ] + assert len(iam_row) == 1 + assert len(logging_row) == 1 + class TestCustomLabels: - def test_ens_spanish_labels(self, capsys): + """Test cases for custom-label universal compliance table rendering.""" + + def test_ens_spanish_labels(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -300,7 +589,7 @@ class TestCustomLabels: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -311,7 +600,9 @@ class TestCustomLabels: class TestMultiProviderDictChecks: - def test_only_aws_checks_matched(self, capsys): + """Test cases for multi-provider dict checks in the universal table.""" + + def test_only_aws_checks_matched(self, capsys, tmp_path): """With dict checks and provider='aws', only AWS checks match findings.""" reqs = [ UniversalComplianceRequirement( @@ -352,7 +643,7 @@ class TestMultiProviderDictChecks: bulk_metadata, "multi_cloud", "output", - "/tmp", + str(tmp_path), False, framework=fw, provider="aws", @@ -366,7 +657,9 @@ class TestMultiProviderDictChecks: class TestNoTableConfig: - def test_returns_early_without_table_config(self, capsys): + """Test cases for the universal table when no table config is present.""" + + def test_returns_early_without_table_config(self, capsys, tmp_path): fw = ComplianceFramework( framework="TestFW", name="Test", @@ -374,11 +667,11 @@ class TestNoTableConfig: description="Test", requirements=[], ) - get_universal_table([], {}, "test", "out", "/tmp", False, framework=fw) + get_universal_table([], {}, "test", "out", str(tmp_path), False, framework=fw) captured = capsys.readouterr() assert captured.out == "" - def test_returns_early_without_framework(self, capsys): - get_universal_table([], {}, "test", "out", "/tmp", False, framework=None) + def test_returns_early_without_framework(self, capsys, tmp_path): + get_universal_table([], {}, "test", "out", str(tmp_path), False, framework=None) captured = capsys.readouterr() assert captured.out == "" From 82d37c4978be9882626711076b08e4d0a3547ad6 Mon Sep 17 00:00:00 2001 From: s1ns3nz0 Date: Thu, 18 Jun 2026 18:22:51 +0900 Subject: [PATCH 07/55] feat(azure): add aks_cluster_defender_enabled check (#11028) Co-authored-by: Hugo P.Brito --- prowler/CHANGELOG.md | 1 + .../aks_cluster_defender_enabled/__init__.py | 0 ...aks_cluster_defender_enabled.metadata.json | 39 ++++++ .../aks_cluster_defender_enabled.py | 29 +++++ .../aks_cluster_defender_enabled_test.py | 122 ++++++++++++++++++ 5 files changed, 191 insertions(+) create mode 100644 prowler/providers/azure/services/aks/aks_cluster_defender_enabled/__init__.py create mode 100644 prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json create mode 100644 prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py create mode 100644 tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 08d892155c..bd0d0bb956 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) - `entra_directory_sync_object_takeover_blocked` check for the M365 provider, verifying that hybrid Entra tenants block cloud object takeover through both soft-match and hard-match directory synchronization [(#11098)](https://github.com/prowler-cloud/prowler/pull/11098) - `entra_conditional_access_policy_no_deleted_object_references` check for M365 provider [(#11236)](https://github.com/prowler-cloud/prowler/pull/11236) +- `aks_cluster_defender_enabled` check for Azure provider, verifying that AKS clusters have Microsoft Defender security monitoring enabled [(#11028)](https://github.com/prowler-cloud/prowler/pull/11028) ### 🔄 Changed diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json new file mode 100644 index 0000000000..90bbd45f3b --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_defender_enabled", + "CheckTitle": "AKS cluster has Microsoft Defender security profile enabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**AKS clusters** are evaluated for the **Microsoft Defender security profile** (`securityProfile.defender.securityMonitoring.enabled=true`). Defender for Containers extends security monitoring, threat detection, vulnerability assessment, and security posture insights to Azure Kubernetes Service workloads.", + "Risk": "Without the Microsoft Defender security profile, AKS runtime threats such as **cryptomining**, suspicious Kubernetes API activity, vulnerable container images, and malicious workload behavior may go undetected. Compromised containers can pivot to cluster resources and cloud APIs, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-containers-deployment-overview", + "https://learn.microsoft.com/en-us/cli/azure/aks?view=azure-cli-latest#az-aks-update", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.containerservice/managedclusters" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --enable-defender", + "NativeIaC": "```bicep\n// AKS cluster with Microsoft Defender security monitoring enabled\nresource aks 'Microsoft.ContainerService/managedClusters@2024-05-01' = {\n name: ''\n location: ''\n identity: {\n type: 'SystemAssigned'\n }\n properties: {\n dnsPrefix: ''\n securityProfile: {\n defender: {\n logAnalyticsWorkspaceResourceId: ''\n securityMonitoring: {\n enabled: true // Critical: enables Defender security monitoring for AKS\n }\n }\n }\n agentPoolProfiles: [\n {\n name: 'system'\n count: 1\n vmSize: 'Standard_DS2_v2'\n mode: 'System'\n }\n ]\n }\n}\n```", + "Other": "1. In Microsoft Defender for Cloud, enable the Containers plan for the subscription that contains the AKS cluster\n2. In the AKS cluster configuration, enable the Microsoft Defender security profile\n3. Verify that Defender security monitoring is enabled for the managed cluster", + "Terraform": "```hcl\nresource \"azurerm_log_analytics_workspace\" \"example\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n sku = \"PerGB2018\"\n}\n\nresource \"azurerm_kubernetes_cluster\" \"example\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n\n default_node_pool {\n name = \"system\"\n node_count = 1\n vm_size = \"Standard_DS2_v2\"\n }\n\n identity {\n type = \"SystemAssigned\"\n }\n\n microsoft_defender {\n log_analytics_workspace_id = azurerm_log_analytics_workspace.example.id\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable the Microsoft Defender security profile for AKS clusters and ensure the subscription has Defender for Containers enabled. Route security monitoring data to the appropriate workspace and review Defender alerts and recommendations as part of the cluster operations process.", + "Url": "https://hub.prowler.com/check/aks_cluster_defender_enabled" + } + }, + "Categories": [ + "threat-detection", + "cluster-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check evaluates the AKS managed cluster Defender security monitoring flag. Defender for Containers plan availability, billing, workspace routing, and alert response processes should be validated separately at the subscription and security operations levels." +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py new file mode 100644 index 0000000000..08074754a6 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py @@ -0,0 +1,29 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_defender_enabled(Check): + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + if cluster.defender_enabled is True: + report.status = "PASS" + report.status_extended = ( + f"Cluster '{cluster.name}' has Defender for Containers " + "enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Cluster '{cluster.name}' does not have Defender for " + "Containers enabled." + ) + + findings.append(report) + + return findings diff --git a/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py b/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py new file mode 100644 index 0000000000..0575b67a4a --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py @@ -0,0 +1,122 @@ +from importlib import import_module +from unittest import mock +from uuid import uuid4 + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +CHECK_MODULE = ( + "prowler.providers.azure.services.aks.aks_cluster_defender_enabled." + "aks_cluster_defender_enabled" +) +CHECK_CLIENT_PATCH = f"{CHECK_MODULE}.aks_client" + + +def get_check_class(): + return import_module(CHECK_MODULE).aks_cluster_defender_enabled + + +def build_cluster(defender_enabled): + return Cluster( + id=str(uuid4()), + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + defender_enabled=defender_enabled, + ) + + +class Test_aks_cluster_defender_enabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + aks_client.clusters = {} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + cluster = build_cluster(defender_enabled=True) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has Defender for Containers enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + @pytest.mark.parametrize("defender_enabled", [False, None, "true"]) + def test_fail(self, defender_enabled): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + cluster = build_cluster(defender_enabled=defender_enabled) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' does not have Defender for " + "Containers enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" From 2111d083df8ee55c38fc47c0a37a63f402d4c54d Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:00:58 +0200 Subject: [PATCH 08/55] fix(ui): bump transitive dompurify to 3.4.10 to patch XSS advisories (#11636) --- ui/CHANGELOG.md | 1 + ui/pnpm-lock.yaml | 9 +++++---- ui/pnpm-workspace.yaml | 4 ++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 3de8c46d56..2cb9f92fcb 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔐 Security - Bump vulnerable `Next.js`, React, AI SDK, `postcss`, `hono`, `qs`, `esbuild`, and Alpine OpenSSL packages (`libcrypto3` and `libssl3`) [(#11581)](https://github.com/prowler-cloud/prowler/pull/11581) +- Bump transitive `dompurify` from 3.4.2 to 3.4.10, patching XSS sanitization bypass advisories [(#11636)](https://github.com/prowler-cloud/prowler/pull/11636) --- diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 6a5e7bb464..6ca6ac4003 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -29,6 +29,7 @@ overrides: qs: 6.15.2 express-rate-limit: 8.5.1 uuid: 11.1.1 + dompurify: 3.4.10 importers: @@ -5593,8 +5594,8 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - dompurify@3.4.2: - resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==} + dompurify@3.4.10: + resolution: {integrity: sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==} dotenv-expand@12.0.3: resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} @@ -15168,7 +15169,7 @@ snapshots: '@babel/runtime': 7.28.6 csstype: 3.2.3 - dompurify@3.4.2: + dompurify@3.4.10: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -16682,7 +16683,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.19 - dompurify: 3.4.2 + dompurify: 3.4.10 es-toolkit: 1.46.1 katex: 0.16.27 khroma: 2.1.0 diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml index f3e525fc03..3db3cc19e3 100644 --- a/ui/pnpm-workspace.yaml +++ b/ui/pnpm-workspace.yaml @@ -45,6 +45,10 @@ overrides: # use the random v4 generator only, so the bug isn't reachable in practice, # but the override unifies the tree on a patched version. "uuid": "11.1.1" + # GHSA-vxr8-fq34-vvx9 (+ several related XSS sanitization bypasses): DOMPurify < 3.4.9, + # pulled in transitively via streamdown > mermaid (which wants ^3.3.1). Pinned to 3.4.10 + # (fixes all open advisories; 3.4.11 is < 24h old and blocked by minimumReleaseAge). + "dompurify": "3.4.10" # --- Level 1: Minimum Release Age --- # Packages must be published for at least 1 day before they can be installed. From 7dd08bc6bf585a1876fb15f97ca806e5aaef2c09 Mon Sep 17 00:00:00 2001 From: s1ns3nz0 Date: Thu, 18 Jun 2026 19:41:04 +0900 Subject: [PATCH 09/55] feat(azure): add mysql_flexible_server_geo_redundant_backup_enabled check (#11041) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + prowler/compliance/azure/hipaa_azure.json | 3 +- .../compliance/azure/iso27001_2022_azure.json | 6 +- .../__init__.py | 0 ...geo_redundant_backup_enabled.metadata.json | 37 ++++++ ...ble_server_geo_redundant_backup_enabled.py | 31 +++++ .../azure/services/mysql/mysql_service.py | 6 + ...erver_geo_redundant_backup_enabled_test.py | 125 ++++++++++++++++++ 8 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py create mode 100644 prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json create mode 100644 prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py create mode 100644 tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index bd0d0bb956..196d2a467e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `databricks_workspace_public_network_access_disabled` check for Azure provider, verifying Databricks workspaces have public network access disabled so connectivity is restricted to Azure Private Link private endpoints [(#11035)](https://github.com/prowler-cloud/prowler/pull/11035) - `databricks_workspace_no_public_ip_enabled` check for Azure provider, verifying Databricks workspaces use secure cluster connectivity (no public IP) so compute nodes are not assigned public IP addresses [(#11036)](https://github.com/prowler-cloud/prowler/pull/11036) - `defender_ensure_defender_cspm_is_on` check for Azure provider, verifying Microsoft Defender Cloud Security Posture Management (CSPM) is enabled on the Standard tier [(#11037)](https://github.com/prowler-cloud/prowler/pull/11037) +- `mysql_flexible_server_geo_redundant_backup_enabled` check for Azure provider, verifying MySQL Flexible Servers have geo-redundant backup enabled so backups are replicated to the paired region [(#11041)](https://github.com/prowler-cloud/prowler/pull/11041) - `aks_cluster_auto_upgrade_enabled` check for Azure provider [(#11027)](https://github.com/prowler-cloud/prowler/pull/11027) - Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398) - Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602) diff --git a/prowler/compliance/azure/hipaa_azure.json b/prowler/compliance/azure/hipaa_azure.json index 3b1184e0e5..70f1e7e13f 100644 --- a/prowler/compliance/azure/hipaa_azure.json +++ b/prowler/compliance/azure/hipaa_azure.json @@ -439,7 +439,8 @@ "keyvault_recoverable", "sqlserver_auditing_retention_90_days", "postgresql_flexible_server_log_retention_days_greater_3", - "cosmosdb_account_backup_policy_continuous" + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled" ] }, { diff --git a/prowler/compliance/azure/iso27001_2022_azure.json b/prowler/compliance/azure/iso27001_2022_azure.json index 719f358de5..6f686d24f0 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -1267,7 +1267,8 @@ } ], "Checks": [ - "cosmosdb_account_backup_policy_continuous" + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled" ] }, { @@ -1296,7 +1297,8 @@ "storage_secure_transfer_required_is_enabled", "vm_ensure_using_managed_disks", "vm_trusted_launch_enabled", - "cosmosdb_account_automatic_failover_enabled" + "cosmosdb_account_automatic_failover_enabled", + "mysql_flexible_server_geo_redundant_backup_enabled" ] }, { diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json new file mode 100644 index 0000000000..1dac541283 --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "mysql_flexible_server_geo_redundant_backup_enabled", + "CheckTitle": "MySQL flexible server has geo-redundant backup enabled", + "CheckType": [], + "ServiceName": "mysql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbformysql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure MySQL Flexible Server** is evaluated for **geo-redundant backup**. Geo-redundant backup stores backup copies in a paired Azure region, enabling restore and recovery from a full regional outage.", + "Risk": "Without **geo-redundant backup**, a regional disaster can cause **permanent data loss**. Locally redundant backups only protect against storage hardware failures within the same region and cannot be restored if the primary region becomes unavailable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-backup-restore", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbformysql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az mysql flexible-server create --name --resource-group --location --geo-redundant-backup Enabled", + "NativeIaC": "```bicep\n// Bicep: MySQL Flexible Server with geo-redundant backup (set at creation)\nresource mysql 'Microsoft.DBforMySQL/flexibleServers@2023-12-30' = {\n name: ''\n location: ''\n properties: {\n backup: {\n geoRedundantBackup: 'Enabled' // CRITICAL: stores backups in the paired region\n }\n }\n}\n```", + "Other": "1. Geo-redundant backup must be configured when the server is created; it cannot be enabled on an existing server\n2. In the Azure portal, start creating a new Azure Database for MySQL flexible server\n3. On the Basics tab, under Compute + storage, open Configure server\n4. Set Geographically redundant backup to Enabled and save\n5. Finish creating the server and migrate workloads to it", + "Terraform": "```hcl\n# Terraform: MySQL Flexible Server with geo-redundant backup (set at creation)\nresource \"azurerm_mysql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n geo_redundant_backup_enabled = true # CRITICAL: stores backups in the paired region\n}\n```" + }, + "Recommendation": { + "Text": "Enable **geo-redundant backup** on MySQL Flexible Servers so backups are replicated to the paired Azure region and can be restored during a regional outage. Because this is set at server creation, plan a migration for existing servers, and pair it with an appropriate backup retention period and periodic restore testing.", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_geo_redundant_backup_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Geo-redundant backup for Azure MySQL Flexible Server can only be configured at server creation time and cannot be changed afterwards." +} diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py new file mode 100644 index 0000000000..5b1685354c --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.mysql.mysql_client import mysql_client + + +class mysql_flexible_server_geo_redundant_backup_enabled(Check): + """ + Ensure Azure MySQL Flexible Servers have geo-redundant backup enabled. + + This check evaluates whether each Azure MySQL Flexible Server stores backups in a paired Azure region, enabling recovery from a full regional outage. + + - PASS: The server has geo-redundant backup enabled (geo_redundant_backup is "Enabled"). + - FAIL: The server does not have geo-redundant backup enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription_id, servers in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) + for server in servers.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription_id + if server.geo_redundant_backup == "Enabled": + report.status = "PASS" + report.status_extended = f"Geo-redundant backup is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + else: + report.status = "FAIL" + report.status_extended = f"Geo-redundant backup is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/mysql/mysql_service.py b/prowler/providers/azure/services/mysql/mysql_service.py index 565e14da01..c626661f98 100644 --- a/prowler/providers/azure/services/mysql/mysql_service.py +++ b/prowler/providers/azure/services/mysql/mysql_service.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional from azure.mgmt.rdbms.mysql_flexibleservers import MySQLManagementClient @@ -21,6 +22,7 @@ class MySQL(AzureService): servers_list = client.servers.list() servers.update({subscription_id: {}}) for server in servers_list: + backup = getattr(server, "backup", None) servers[subscription_id].update( { server.id: FlexibleServer( @@ -31,6 +33,9 @@ class MySQL(AzureService): configurations=self._get_configurations( client, server.id.split("/")[4], server.name ), + geo_redundant_backup=getattr( + backup, "geo_redundant_backup", None + ), ) } ) @@ -78,3 +83,4 @@ class FlexibleServer: location: str version: str configurations: dict[Configuration] + geo_redundant_backup: Optional[str] = None diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py new file mode 100644 index 0000000000..c5e1f27de2 --- /dev/null +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py @@ -0,0 +1,125 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.mysql.mysql_service import FlexibleServer +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_mysql_flexible_server_geo_redundant_backup_enabled: + def test_mysql_no_subscriptions(self): + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_mysql_geo_redundant_backup_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + geo_redundant_backup="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Geo-redundant backup is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_mysql_geo_redundant_backup_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + geo_redundant_backup="Enabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Geo-redundant backup is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" From 751c7fc29ff0305169a3350bb9585f176a23579b Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Thu, 18 Jun 2026 13:03:19 +0200 Subject: [PATCH 10/55] chore(jira): timeout to 90 seconds (#11638) --- prowler/lib/outputs/jira/jira.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index f826d09026..9005b5274e 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -341,7 +341,7 @@ class Jira: } TOKEN_URL = "https://auth.atlassian.com/oauth/token" API_TOKEN_URL = "https://api.atlassian.com/oauth/token/accessible-resources" - REQUEST_TIMEOUT = 30 + REQUEST_TIMEOUT = 90 HEADER_TEMPLATE = { "Content-Type": "application/json", "X-Force-Accept-Language": "true", From 853610bbbf8846931d9884e9e4b215a341aedda0 Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:12:18 +0200 Subject: [PATCH 11/55] feat(ui): resolve public SaaS config at container runtime (#11500) Co-authored-by: Pablo F.G Co-authored-by: Claude Opus 4.8 --- .env | 20 ++- .github/workflows/ui-container-build-push.yml | 4 - .github/workflows/ui-e2e-tests-v2.yml | 5 +- .../prowler-app/templates/ui/configmap.yaml | 5 +- contrib/k8s/helm/prowler-ui/values.yaml | 4 +- docs/developer-guide/end2end-testing.mdx | 4 +- .../developer-guide/environment-variables.mdx | 53 +++++++ docs/docs.json | 1 + docs/troubleshooting.mdx | 34 ++--- ui/CHANGELOG.md | 8 ++ ui/Dockerfile | 14 +- ui/__tests__/msw/handlers/attack-paths.ts | 2 +- .../confirm/confirm-alert-recipient.test.ts | 20 ++- .../alerts/confirm/confirm-alert-recipient.ts | 4 +- .../unsubscribe-alert-recipient.test.ts | 35 ++++- .../unsubscribe-alert-recipient.ts | 4 +- ui/app/(auth)/layout.tsx | 28 +++- ui/app/(prowler)/layout.tsx | 5 +- ui/app/instrumentation.client.ts | 121 ---------------- ui/app/providers.tsx | 3 - .../integrations/saml/saml-config-form.tsx | 10 +- .../runtime-config/runtime-public-config.tsx | 17 +++ ui/components/ui/sidebar/menu.tsx | 3 + ui/hooks/use-runtime-config.test.ts | 52 +++++++ ui/hooks/use-runtime-config.ts | 25 ++++ ui/instrumentation-client.test.ts | 97 +++++++++++++ ui/instrumentation-client.ts | 136 +++++++++++++++++- ui/instrumentation.ts | 13 +- ui/lib/env.test.ts | 46 ++++++ ui/lib/env.ts | 21 +++ ui/lib/get-runtime-config.client.test.ts | 115 +++++++++++++++ ui/lib/get-runtime-config.client.ts | 43 ++++++ ui/lib/helper.ts | 8 +- ui/lib/menu-list.test.ts | 41 ++++++ ui/lib/menu-list.ts | 15 +- ui/lib/runtime-config.shared.ts | 26 ++++ ui/lib/runtime-config.ts | 30 ++++ ui/lib/runtime-env.test.ts | 75 ++++++++++ ui/lib/runtime-env.ts | 21 +++ ui/lib/safe-json.test.ts | 83 +++++++++++ ui/lib/safe-json.ts | 16 +++ ui/next.config.js | 38 ++--- ui/package.json | 8 +- ui/playwright.config.ts | 12 +- ui/sentry/README.md | 21 ++- ui/sentry/sentry.edge.config.test.ts | 60 ++++++++ ui/sentry/sentry.edge.config.ts | 22 ++- ui/sentry/sentry.server.config.test.ts | 63 ++++++++ ui/sentry/sentry.server.config.ts | 25 +++- .../runtime-config/runtime-config-page.ts | 124 ++++++++++++++++ ui/tests/runtime-config/runtime-config.md | 87 +++++++++++ .../runtime-config/runtime-config.spec.ts | 82 +++++++++++ ui/types/env.d.ts | 35 ++++- ui/vitest.config.ts | 5 +- 54 files changed, 1590 insertions(+), 259 deletions(-) create mode 100644 docs/developer-guide/environment-variables.mdx delete mode 100644 ui/app/instrumentation.client.ts create mode 100644 ui/components/runtime-config/runtime-public-config.tsx create mode 100644 ui/hooks/use-runtime-config.test.ts create mode 100644 ui/hooks/use-runtime-config.ts create mode 100644 ui/instrumentation-client.test.ts create mode 100644 ui/lib/env.test.ts create mode 100644 ui/lib/env.ts create mode 100644 ui/lib/get-runtime-config.client.test.ts create mode 100644 ui/lib/get-runtime-config.client.ts create mode 100644 ui/lib/runtime-config.shared.ts create mode 100644 ui/lib/runtime-config.ts create mode 100644 ui/lib/runtime-env.test.ts create mode 100644 ui/lib/runtime-env.ts create mode 100644 ui/lib/safe-json.test.ts create mode 100644 ui/lib/safe-json.ts create mode 100644 ui/sentry/sentry.edge.config.test.ts create mode 100644 ui/sentry/sentry.server.config.test.ts create mode 100644 ui/tests/runtime-config/runtime-config-page.ts create mode 100644 ui/tests/runtime-config/runtime-config.md create mode 100644 ui/tests/runtime-config/runtime-config.spec.ts diff --git a/.env b/.env index c0364e29ae..35417637da 100644 --- a/.env +++ b/.env @@ -6,14 +6,20 @@ PROWLER_UI_VERSION="stable" AUTH_URL=http://localhost:3000 API_BASE_URL=http://prowler-api:8080/api/v1 +# deprecated, use UI_API_BASE_URL NEXT_PUBLIC_API_BASE_URL=${API_BASE_URL} +UI_API_BASE_URL=${API_BASE_URL} +# deprecated, use UI_API_DOCS_URL NEXT_PUBLIC_API_DOCS_URL=http://prowler-api:8080/api/v1/docs +UI_API_DOCS_URL=http://prowler-api:8080/api/v1/docs AUTH_TRUST_HOST=true UI_PORT=3000 # openssl rand -base64 32 AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8=" -# Google Tag Manager ID +# Google Tag Manager ID (empty/unset ⇒ GTM not loaded, zero egress) +# deprecated, use UI_GOOGLE_TAG_MANAGER_ID NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID="" +UI_GOOGLE_TAG_MANAGER_ID="" #### MCP Server #### PROWLER_MCP_VERSION=stable @@ -139,10 +145,16 @@ DJANGO_BROKER_VISIBILITY_TIMEOUT=86400 DJANGO_SENTRY_DSN= DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute -# Sentry settings -SENTRY_ENVIRONMENT=local +# Sentry for the web app (server + browser). Empty/unset UI_SENTRY_DSN ⇒ +# Sentry disabled, zero egress. SENTRY_RELEASE (unprefixed) feeds the web app's +# server/edge SDKs. +UI_SENTRY_DSN= +UI_SENTRY_ENVIRONMENT=local SENTRY_RELEASE=local -NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} +# Reserved runtime public config (registered now; no UI consumer yet) +# POSTHOG_KEY= +# POSTHOG_HOST= +# REO_DEV_CLIENT_ID= #### Prowler release version #### NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.31.0 diff --git a/.github/workflows/ui-container-build-push.yml b/.github/workflows/ui-container-build-push.yml index 984256af7f..3210f6e998 100644 --- a/.github/workflows/ui-container-build-push.yml +++ b/.github/workflows/ui-container-build-push.yml @@ -32,9 +32,6 @@ env: PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud PROWLERCLOUD_DOCKERHUB_IMAGE: prowler-ui - # Build args - NEXT_PUBLIC_API_BASE_URL: http://prowler-api:8080/api/v1 - permissions: {} jobs: @@ -146,7 +143,6 @@ jobs: context: ${{ env.WORKING_DIRECTORY }} build-args: | NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }} - NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }} push: true platforms: ${{ matrix.platform }} tags: | diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index ed407e890d..7165882d62 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -40,7 +40,8 @@ jobs: AUTH_SECRET: 'fallback-ci-secret-for-testing' AUTH_TRUST_HOST: true NEXTAUTH_URL: 'http://localhost:3000' - NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1' + AUTH_URL: 'http://localhost:3000' + UI_API_BASE_URL: 'http://localhost:8080/api/v1' E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }} E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }} @@ -165,7 +166,7 @@ jobs: timeout=150 elapsed=0 while [ $elapsed -lt $timeout ]; do - if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then + if curl -s ${UI_API_BASE_URL}/docs >/dev/null 2>&1; then echo "Prowler API is ready!" exit 0 fi diff --git a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml index 38d6e65ee3..856770722a 100644 --- a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml +++ b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml @@ -11,8 +11,7 @@ data: {{- else }} AUTH_URL: {{ .Values.ui.authUrl | quote }} {{- end }} - API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" - NEXT_PUBLIC_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" - NEXT_PUBLIC_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs" + UI_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" + UI_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs" AUTH_TRUST_HOST: "true" UI_PORT: {{ .Values.ui.service.port | quote }} diff --git a/contrib/k8s/helm/prowler-ui/values.yaml b/contrib/k8s/helm/prowler-ui/values.yaml index d4f2dbe137..aca0178b3a 100644 --- a/contrib/k8s/helm/prowler-ui/values.yaml +++ b/contrib/k8s/helm/prowler-ui/values.yaml @@ -21,8 +21,8 @@ fullnameOverride: "" secrets: SITE_URL: http://localhost:3000 - API_BASE_URL: http://prowler-api:8080/api/v1 - NEXT_PUBLIC_API_DOCS_URL: http://prowler-api:8080/api/v1/docs + UI_API_BASE_URL: http://prowler-api:8080/api/v1 + UI_API_DOCS_URL: http://prowler-api:8080/api/v1/docs AUTH_TRUST_HOST: True UI_PORT: 3000 # openssl rand -base64 32 diff --git a/docs/developer-guide/end2end-testing.mdx b/docs/developer-guide/end2end-testing.mdx index 0a62251531..9013a32245 100644 --- a/docs/developer-guide/end2end-testing.mdx +++ b/docs/developer-guide/end2end-testing.mdx @@ -221,9 +221,9 @@ Before running E2E tests: ``` - **Ensure Prowler API is available** - - By default, Playwright uses `NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`). + - By default, Playwright uses `UI_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`). - Start Prowler API so it is reachable on that URL (for example, via `docker-compose-dev.yml` or the development orchestration used locally). - - If a different API URL is required, set `NEXT_PUBLIC_API_BASE_URL` accordingly before running the tests. + - If a different API URL is required, set `UI_API_BASE_URL` accordingly before running the tests. - **Ensure Prowler App UI is available** - Playwright automatically starts the Next.js server through the `webServer` block in `playwright.config.ts` (`pnpm run dev` by default). diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx new file mode 100644 index 0000000000..1f4d17ee2e --- /dev/null +++ b/docs/developer-guide/environment-variables.mdx @@ -0,0 +1,53 @@ +--- +title: 'Environment Variable Naming Convention' +--- + +Prowler is a monorepo composed of several runtime components — Prowler App (the web user interface), Prowler API (the backend), Prowler SDK, and Prowler MCP Server (Model Context Protocol) — that frequently share a single `.env` file. To keep that shared configuration unambiguous, each component namespaces its environment variables with a component-specific prefix. + +## Component Prefixes + +Each component owns a dedicated prefix for the environment variables it reads: + +| Component | Prefix | Status | +|-----------|--------|--------| +| Prowler App (web UI) | `UI_` | Adopted | +| Prowler API (backend) | `API_` | Planned | +| Prowler SDK | `SDK_` | Planned | +| Prowler MCP Server | `MCP_` | Planned | + +## Why Component Prefixes Matter + +Component prefixes solve three concrete problems in a shared configuration file: + +- **Collisions in a shared `.env`:** Several components historically read identically named variables. The API base URL, for example, is consumed by more than one component, so a single unprefixed name is ambiguous. A component prefix removes that ambiguity. +- **Explicit ownership:** A prefix states, at a glance, which component consumes a variable. +- **Reduced accidental exposure:** For Prowler App, scoping browser-facing configuration under one intentional prefix prevents server-only values from leaking into the client bundle. + +## Prowler App + +Prowler App has adopted the `UI_` prefix. Its public configuration is resolved from the container environment at runtime rather than inlined at build time, so a single pre-built image serves any deployment. For the operational details on changing these values without rebuilding the image, see [Troubleshooting](/troubleshooting). + +The former build-time variables map to the new runtime variables as follows: + +| Former variable | New variable | +|-----------------|--------------| +| `NEXT_PUBLIC_API_BASE_URL` | `UI_API_BASE_URL` | +| `NEXT_PUBLIC_API_DOCS_URL` | `UI_API_DOCS_URL` | +| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` | +| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` | +| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` | + +The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of the App's runtime configuration. + +## Upcoming Breaking Change + + +Adopting the `API_`, `SDK_`, and `MCP_` prefixes for Prowler API, Prowler SDK, and Prowler MCP Server is a planned breaking change in a future release. Migrate environment configuration to the new names when upgrading. + + +Prowler API, Prowler SDK, and Prowler MCP Server have not yet adopted the convention. In a future release, the variables each of these components reads will be namespaced under `API_`, `SDK_`, and `MCP_` respectively. The per-component mapping from current to prefixed names will be documented when each change is released. + +## Deprecated Names + +- **Prowler App:** The bare server-side `SENTRY_DSN` and `SENTRY_ENVIRONMENT` are no longer read; the server and edge runtimes now read `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT`. The former `NEXT_PUBLIC_*` build-time variables are deprecated but still read at runtime as a fallback when the matching `UI_*` variable is unset. This fallback will be removed in a future release, so set the `UI_*` runtime variables on the running container. +- **Prowler API, Prowler SDK, and Prowler MCP Server:** The current, unprefixed variable names are deprecated. They continue to work today and will be removed once the prefixed convention is adopted for each component, as described in [Upcoming Breaking Change](#upcoming-breaking-change). diff --git a/docs/docs.json b/docs/docs.json index 51c7e739b0..a74aa7e91d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -417,6 +417,7 @@ "group": "Miscellaneous", "pages": [ "developer-guide/documentation", + "developer-guide/environment-variables", { "group": "Testing", "pages": [ diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 9d60c57321..50cc43a3c0 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -201,35 +201,29 @@ When running Prowler behind a reverse proxy (nginx, Traefik, etc.) or load balan **Root Cause:** -Next.js environment variables prefixed with `NEXT_PUBLIC_` are **bundled at build time**, not runtime. The pre-built Docker images from Docker Hub (`prowlercloud/prowler-ui:stable`) are built with default internal URLs. Simply setting `NEXT_PUBLIC_API_BASE_URL` in your `.env` file or environment variables and restarting the container will **NOT** work because these values are already compiled into the JavaScript bundle. +The API base and docs URLs are resolved from the container environment **at runtime**. A single pre-built Docker image (`prowlercloud/prowler-ui:stable`) therefore serves any environment: point the URLs at your external domain and restart the container — no rebuild is required. **Solution:** -You must **rebuild** the UI Docker image with your external URL: - -```bash -# Clone the repository (if you haven't already) -git clone https://github.com/prowler-cloud/prowler.git -cd prowler/ui - -# Build with your external URL as a build argument -docker build \ - --build-arg NEXT_PUBLIC_API_BASE_URL=https://prowler.example.com/api/v1 \ - --build-arg NEXT_PUBLIC_API_DOCS_URL=https://prowler.example.com/api/v1/docs \ - -t prowler-ui-custom:latest \ - --target prod \ - . -``` - -Then update your `docker-compose.yml` to use your custom image instead of the pre-built one: +Set the runtime environment variables to your external URL and restart the UI container: ```yaml services: ui: - image: prowler-ui-custom:latest # Use your custom-built image + image: prowlercloud/prowler-ui:stable + environment: + UI_API_BASE_URL: https://prowler.example.com/api/v1 + UI_API_DOCS_URL: https://prowler.example.com/api/v1/docs # ... rest of configuration ``` +The same values can be supplied through your `.env` file: + +```bash +UI_API_BASE_URL=https://prowler.example.com/api/v1 +UI_API_DOCS_URL=https://prowler.example.com/api/v1/docs +``` + -The `NEXT_PUBLIC_` prefix is a Next.js convention that exposes environment variables to the browser. Since the browser bundle is compiled during `docker build`, these variables must be provided as build arguments, not runtime environment variables. +Earlier releases inlined these values into the JavaScript bundle at build time (via the `NEXT_PUBLIC_` prefix) and required a rebuild with `--build-arg`. That is no longer necessary: `UI_API_BASE_URL` and `UI_API_DOCS_URL` are read at container start, so updating them and restarting is sufficient. diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 2cb9f92fcb..97f0dc9830 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -28,6 +28,14 @@ All notable changes to the **Prowler UI** are documented in this file. - Threat Map no longer shows an empty map for accounts that only have Okta or Google Workspace scans [(#11542)](https://github.com/prowler-cloud/prowler/pull/11542) - Compliance attributes requests now pass the selected scan, so multi-provider universal frameworks (e.g. CSA CCM) load the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) +### 🔄 Changed + +- Public SaaS config (Sentry, Google Tag Manager, API base/docs URL) now resolves at container runtime instead of build time; self-hosted deployments set the UI config through the new `UI_`-prefixed env vars (`UI_API_BASE_URL`, `UI_API_DOCS_URL`, `UI_GOOGLE_TAG_MANAGER_ID`, `UI_SENTRY_DSN`, `UI_SENTRY_ENVIRONMENT`), with the previous `NEXT_PUBLIC_*` names still honored as a deprecated fallback [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500) + +### 🐞 Fixed + +- `ui/.env` template now lists only the canonical `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT` names; the deprecated `NEXT_PUBLIC_SENTRY_DSN` and `NEXT_PUBLIC_SENTRY_ENVIRONMENT` entries have been removed [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500) + --- ## [1.30.0] (Prowler v5.30.0) diff --git a/ui/Dockerfile b/ui/Dockerfile index 6cf930ee5e..86673ba046 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -36,12 +36,8 @@ RUN corepack install ENV NEXT_TELEMETRY_DISABLED=1 ARG NEXT_PUBLIC_PROWLER_RELEASE_VERSION ENV NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${NEXT_PUBLIC_PROWLER_RELEASE_VERSION} -ARG NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID -ENV NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=${NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID} -ARG NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} -ARG NEXT_PUBLIC_API_DOCS_URL -ENV NEXT_PUBLIC_API_DOCS_URL=${NEXT_PUBLIC_API_DOCS_URL} + +# GTM / API base+docs URLs are runtime container env (prod stage), not build ARGs. RUN pnpm run build @@ -78,6 +74,12 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +# Runtime configuration is read by `node server.js` at container start and is +# NOT baked into the image. Supply it via your orchestrator (docker-compose, +# Helm/K8s): +# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot) +# - optional: UI_API_DOCS_URL, UI_GOOGLE_TAG_MANAGER_ID, UI_SENTRY_DSN, UI_SENTRY_ENVIRONMENT +# - reserved: POSTHOG_KEY, POSTHOG_HOST, REO_DEV_CLIENT_ID (no consumer yet) # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output CMD ["node", "server.js"] diff --git a/ui/__tests__/msw/handlers/attack-paths.ts b/ui/__tests__/msw/handlers/attack-paths.ts index 39076666dd..7c76fa574b 100644 --- a/ui/__tests__/msw/handlers/attack-paths.ts +++ b/ui/__tests__/msw/handlers/attack-paths.ts @@ -10,7 +10,7 @@ import type { QueryResultAttributes, } from "@/types/attack-paths"; -const API = process.env.NEXT_PUBLIC_API_BASE_URL; +const API = process.env.UI_API_BASE_URL; type JsonApiErrorBody = { errors: Array<{ detail: string; status: string }>; diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts index d95966e680..41118bd6e2 100644 --- a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts +++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts @@ -14,7 +14,7 @@ const lastFetchCall = (): { url: string; init: RequestInit } => { describe("confirmAlertRecipient", () => { beforeEach(() => { vi.stubGlobal("fetch", fetchMock); - vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1"); + vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1"); fetchMock.mockResolvedValue( new Response( JSON.stringify({ @@ -104,7 +104,8 @@ describe("confirmAlertRecipient", () => { }); it("returns the fallback message when the API base URL is missing", async () => { - // Given + // Given - neither the new name nor its legacy fallback is set + vi.stubEnv("UI_API_BASE_URL", ""); vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); // When @@ -120,6 +121,21 @@ describe("confirmAlertRecipient", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => { + // Given - only the legacy name is configured + vi.stubEnv("UI_API_BASE_URL", undefined); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1"); + + // When + const result = await confirmAlertRecipient("token-1"); + + // Then + expect(result.ok).toBe(true); + expect(lastFetchCall().url).toBe( + "https://legacy.example.com/api/v1/alerts/recipients/confirm?token=token-1", + ); + }); + it("returns the fallback message when the request fails", async () => { // Given fetchMock.mockRejectedValueOnce(new Error("network down")); diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts index b567bb4124..19e9216339 100644 --- a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts +++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts @@ -1,3 +1,5 @@ +import { readEnv } from "@/lib/runtime-env"; + interface AlertConfirmApiResponse { state?: string; message?: string; @@ -41,7 +43,7 @@ const toState = (payload: unknown): string => { export const confirmAlertRecipient = async ( token?: string, ): Promise => { - const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"); if (!apiBaseUrl) { return { ok: false, diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts index 8a595fa63e..508625b4be 100644 --- a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts +++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts @@ -14,7 +14,7 @@ const lastFetchCall = (): { url: string; init: RequestInit } => { describe("unsubscribeAlertRecipient", () => { beforeEach(() => { vi.stubGlobal("fetch", fetchMock); - vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1"); + vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1"); fetchMock.mockResolvedValue( new Response( JSON.stringify({ @@ -102,4 +102,37 @@ describe("unsubscribeAlertRecipient", () => { "https://api.example.com/api/v1/alerts/recipients/unsubscribe", ); }); + + it("returns the fallback message when the API base URL is missing", async () => { + // Given - neither the new name nor its legacy fallback is set + vi.stubEnv("UI_API_BASE_URL", ""); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); + + // When + const result = await unsubscribeAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: false, + state: "missing_api_base_url", + message: + "We could not process this unsubscribe link. Please try again later.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => { + // Given - only the legacy name is configured + vi.stubEnv("UI_API_BASE_URL", undefined); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1"); + + // When + const result = await unsubscribeAlertRecipient("token-1"); + + // Then + expect(result.ok).toBe(true); + expect(lastFetchCall().url).toBe( + "https://legacy.example.com/api/v1/alerts/recipients/unsubscribe?token=token-1", + ); + }); }); diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts index af64165a1e..8c3a4e2a79 100644 --- a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts +++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts @@ -1,3 +1,5 @@ +import { readEnv } from "@/lib/runtime-env"; + interface AlertUnsubscribeApiResponse { state?: string; message?: string; @@ -41,7 +43,7 @@ const toState = (payload: unknown): string => { export const unsubscribeAlertRecipient = async ( token?: string, ): Promise => { - const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"); if (!apiBaseUrl) { return { ok: false, diff --git a/ui/app/(auth)/layout.tsx b/ui/app/(auth)/layout.tsx index 07fe3a60c3..79a4ce4e89 100644 --- a/ui/app/(auth)/layout.tsx +++ b/ui/app/(auth)/layout.tsx @@ -2,12 +2,15 @@ import "@/styles/globals.css"; import { GoogleTagManager } from "@next/third-parties/google"; import { Metadata, Viewport } from "next"; +import { connection } from "next/server"; import { ReactNode, Suspense } from "react"; +import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config"; import { NavigationProgress, Toaster } from "@/components/ui"; import { fontSans } from "@/config/fonts"; import { siteConfig } from "@/config/site"; import { cn } from "@/lib"; +import { readEnv } from "@/lib/runtime-env"; import { Providers } from "../providers"; @@ -29,10 +32,27 @@ export const viewport: Viewport = { ], }; -export default function AuthLayout({ children }: { children: ReactNode }) { +export default async function AuthLayout({ + children, +}: { + children: ReactNode; +}) { + // Force dynamic rendering so the read below resolves from the container env + // at request time rather than being snapshotted at build (independent of the + // island's own connection() call). + await connection(); + + // Server-side runtime read. Empty/unset id ⇒ GoogleTagManager is not mounted + const gtmId = readEnv( + "UI_GOOGLE_TAG_MANAGER_ID", + "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID", + ); + return ( - + + + {children} - + {gtmId && } diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx index a093232e1c..2554ab02b9 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -11,6 +11,7 @@ import { OnboardingGate, OnboardingSequenceBanner, } from "@/components/onboarding"; +import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config"; import MainLayout from "@/components/ui/main-layout/main-layout"; import { NavigationProgress } from "@/components/ui/navigation-progress"; import { Toaster } from "@/components/ui/toast"; @@ -76,7 +77,9 @@ export default async function RootLayout({ return ( - + + + 50ms) - enableInp: true, // Interaction to Next Paint (Core Web Vital) - }), - ], - - // 🎣 beforeSend Hook - Filter or modify events before sending to Sentry - ignoreErrors: [ - // Browser extensions - "top.GLOBALS", - // Random network errors - "Network request failed", - "NetworkError", - "Failed to fetch", - // User canceled actions - "AbortError", - "Non-Error promise rejection captured", - // NextAuth expected errors - "NEXT_REDIRECT", - // ResizeObserver errors (common browser quirk, not real bugs) - "ResizeObserver", - ], - - beforeSend(event, hint) { - // Filter out noise: ResizeObserver errors (common browser quirk, not real bugs) - if (event.message?.includes("ResizeObserver")) { - return null; // Don't send to Sentry - } - - // Filter out non-actionable errors - if (event.exception) { - const error = hint.originalException; - - // Don't send cancelled requests - if ( - error && - typeof error === "object" && - "name" in error && - error.name === "AbortError" - ) { - return null; - } - - // Add additional context for API errors - if ( - error && - typeof error === "object" && - "message" in error && - typeof error.message === "string" && - error.message.includes("Request failed") - ) { - event.tags = { - ...event.tags, - error_type: "api_error", - }; - } - } - - return event; // Send to Sentry - }, - }); - - // 👤 Set user context (identifies who experienced the error) - // In production, this will be updated after authentication - if (isDevelopment) { - Sentry.setUser({ - id: "dev-user", - }); - } -} diff --git a/ui/app/providers.tsx b/ui/app/providers.tsx index 7bda5d45fe..41157df06c 100644 --- a/ui/app/providers.tsx +++ b/ui/app/providers.tsx @@ -1,8 +1,5 @@ "use client"; -// Import Sentry client-side initialization -import "@/app/instrumentation.client"; - import { HeroUIProvider } from "@heroui/system"; import { useRouter } from "next/navigation"; import { SessionProvider } from "next-auth/react"; diff --git a/ui/components/integrations/saml/saml-config-form.tsx b/ui/components/integrations/saml/saml-config-form.tsx index a995fffceb..935f5412e9 100644 --- a/ui/components/integrations/saml/saml-config-form.tsx +++ b/ui/components/integrations/saml/saml-config-form.tsx @@ -18,7 +18,7 @@ import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { CustomServerInput } from "@/components/ui/custom"; import { CustomLink } from "@/components/ui/custom/custom-link"; import { FormButtons } from "@/components/ui/form"; -import { apiBaseUrl } from "@/lib"; +import { useRuntimeConfig } from "@/hooks/use-runtime-config"; const validateXMLContent = ( xmlContent: string, @@ -253,10 +253,12 @@ export const SamlConfigForm = ({ reader.readAsText(file); }; + const { apiBaseUrl } = useRuntimeConfig(); const trimmedEmailDomain = emailDomain.trim(); - const acsUrl = trimmedEmailDomain - ? `${apiBaseUrl}/accounts/saml/${trimmedEmailDomain}/acs/` - : ""; + const acsUrl = + trimmedEmailDomain && apiBaseUrl + ? `${apiBaseUrl}/accounts/saml/${trimmedEmailDomain}/acs/` + : ""; return (
before +// the client bundle, so module-load consumers (Sentry init) read it race-free. +export async function RuntimePublicConfig() { + const config = await getRuntimePublicConfig(); + + return ( + breakout so the script tag is not terminated early", () => { + // Given + const value = { sentryDsn: "" }; + + // When + const serialized = serializeForScript(value); + + // Then + expect(serialized).not.toContain(""); + expect(serialized).not.toContain("<"); + expect(serialized).not.toContain(">"); + }); + + it("neutralizes the HTML comment opener " }; + + // When + const serialized = serializeForScript(value); + + // Then + expect(serialized).not.toContain("