diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index c2f886b3f3..fe8cbf4f39 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -93,6 +93,11 @@ The following list includes all the Azure checks with configurable variables tha ## GCP ### Configurable Checks +The following list includes all the GCP checks with configurable variables that can be changed in the configuration yaml file: + +| Check Name | Value | Type | +|---------------------------------------------------------------|--------------------------------------------------|-----------------| +| `compute_instance_group_multiple_zones` | `mig_min_zones` | Integer | ## Kubernetes @@ -548,6 +553,9 @@ gcp: # GCP Compute Configuration # gcp.compute_public_address_shodan shodan_api_key: null + # gcp.compute_instance_group_multiple_zones + # Minimum number of zones a MIG should span for high availability + mig_min_zones: 2 # Kubernetes Configuration kubernetes: diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 900720e501..777067bcb6 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### Added - Add Prowler ThreatScore for the Alibaba Cloud provider [(#9511)](https://github.com/prowler-cloud/prowler/pull/9511) +- `compute_instance_group_multiple_zones` check for GCP provider [(#9566)](https://github.com/prowler-cloud/prowler/pull/9566) ### Changed - Update AWS Step Functions service metadata to new format [(#9432)](https://github.com/prowler-cloud/prowler/pull/9432) diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index b9c9a5686c..43421cf57d 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -507,6 +507,9 @@ gcp: # GCP Compute Configuration # gcp.compute_public_address_shodan shodan_api_key: null + # gcp.compute_instance_group_multiple_zones + # Minimum number of zones a MIG should span for high availability + mig_min_zones: 2 # GCP Service Account and user-managed keys unused configuration # gcp.iam_service_account_unused # gcp.iam_sa_user_managed_key_unused diff --git a/prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/__init__.py b/prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones.metadata.json b/prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones.metadata.json new file mode 100644 index 0000000000..ebfdcb4540 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "gcp", + "CheckID": "compute_instance_group_multiple_zones", + "CheckTitle": "Ensure Managed Instance Groups span multiple zones for high availability", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "compute.googleapis.com/InstanceGroupManager", + "Description": "Managed Instance Groups (MIGs) should be configured for multi-zone deployments to ensure high availability and fault tolerance. A multi-zone MIG distributes instances across multiple zones within a region, protecting applications from zonal failures.", + "Risk": "Running a MIG in a single zone creates a single point of failure. If that zone experiences an outage, all instances in the group become unavailable, resulting in application downtime during zonal failures, no automatic failover to healthy zones, and reduced resilience against infrastructure issues.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/compute/docs/instance-groups/regional-migs", + "https://cloud.google.com/compute/docs/instance-groups/distributing-instances-with-regional-instance-groups" + ], + "Remediation": { + "Code": { + "CLI": "gcloud compute instance-groups managed create INSTANCE_GROUP_NAME --region=REGION --template=INSTANCE_TEMPLATE --size=TARGET_SIZE --zones=ZONE1,ZONE2,ZONE3", + "NativeIaC": "", + "Other": "1. Navigate to Compute Engine > Instance groups\n2. Click 'Create instance group'\n3. Select 'New managed instance group (stateless)'\n4. For 'Location', select 'Multiple zones'\n5. Choose the target region and zones\n6. Configure the instance template and target size\n7. Click 'Create'", + "Terraform": "```hcl\n# Create a regional MIG that spans multiple zones\nresource \"google_compute_region_instance_group_manager\" \"example\" {\n name = \"example-mig\"\n region = \"us-central1\"\n base_instance_name = \"example\"\n target_size = 3\n\n version {\n instance_template = google_compute_instance_template.example.id\n }\n\n # Distribute instances across multiple zones\n distribution_policy_zones = [\"us-central1-a\", \"us-central1-b\", \"us-central1-c\"]\n}\n```" + }, + "Recommendation": { + "Text": "Use regional managed instance groups instead of zonal MIGs to distribute instances across multiple zones. This provides automatic failover and load distribution, ensuring high availability for production workloads.", + "Url": "https://hub.prowler.com/check/compute_instance_group_multiple_zones" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check uses a configurable minimum zone count (default: 2). Configure via 'mig_min_zones' in config.yaml." +} diff --git a/prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones.py b/prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones.py new file mode 100644 index 0000000000..1f1d7d316d --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones.py @@ -0,0 +1,45 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.compute.compute_client import compute_client + + +class compute_instance_group_multiple_zones(Check): + """ + Ensure Managed Instance Groups span multiple zones for high availability. + + This check verifies whether GCP Managed Instance Groups (MIGs) are distributed + across multiple zones to ensure high availability and fault tolerance. + + - PASS: The MIG spans the minimum required zones (configurable via mig_min_zones). + - FAIL: The MIG does not meet the minimum zone requirement. + """ + + def execute(self) -> list[Check_Report_GCP]: + findings = [] + min_zones = compute_client.audit_config.get("mig_min_zones", 2) + + for instance_group in compute_client.instance_groups: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=instance_group, + location=instance_group.region, + ) + + zone_count = len(instance_group.zones) + zones_str = ", ".join(instance_group.zones) + + report.status = "PASS" + if instance_group.is_regional: + report.status_extended = f"Managed Instance Group {instance_group.name} is a regional MIG spanning {zone_count} zones ({zones_str})." + else: + report.status_extended = f"Managed Instance Group {instance_group.name} spans {zone_count} zones ({zones_str})." + + if zone_count < min_zones: + report.status = "FAIL" + if instance_group.is_regional: + report.status_extended = f"Managed Instance Group {instance_group.name} is a regional MIG but only spans {zone_count} zone(s) ({zones_str}), minimum required is {min_zones}." + else: + report.status_extended = f"Managed Instance Group {instance_group.name} is a zonal MIG running only in {zones_str}, consider converting to a regional MIG for high availability." + + findings.append(report) + + return findings diff --git a/prowler/providers/gcp/services/compute/compute_service.py b/prowler/providers/gcp/services/compute/compute_service.py index 357dd613a3..900c840ab0 100644 --- a/prowler/providers/gcp/services/compute/compute_service.py +++ b/prowler/providers/gcp/services/compute/compute_service.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -18,6 +20,7 @@ class Compute(GCPService): self.firewalls = [] self.compute_projects = [] self.load_balancers = [] + self.instance_groups = [] self._get_regions() self._get_projects() self._get_url_maps() @@ -28,6 +31,8 @@ class Compute(GCPService): self.__threading_call__(self._get_subnetworks, self.regions) self._get_firewalls() self.__threading_call__(self._get_addresses, self.regions) + self.__threading_call__(self._get_regional_instance_groups, self.regions) + self.__threading_call__(self._get_zonal_instance_groups, self.zones) def _get_regions(self): for project_id in self.project_ids: @@ -362,6 +367,87 @@ class Compute(GCPService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_regional_instance_groups(self, region: str) -> None: + """Fetch regional managed instance groups for all projects.""" + for project_id in self.project_ids: + try: + request = self.client.regionInstanceGroupManagers().list( + project=project_id, region=region + ) + while request is not None: + response = request.execute( + http=self.__get_AuthorizedHttp_client__(), + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + + for mig in response.get("items", []): + zones = [ + zone_info["zone"].split("/")[-1] + for zone_info in mig.get("distributionPolicy", {}).get( + "zones", [] + ) + if zone_info.get("zone") + ] + + self.instance_groups.append( + ManagedInstanceGroup( + name=mig.get("name", ""), + id=mig.get("id", ""), + region=region, + zone=None, + zones=zones, + is_regional=True, + target_size=mig.get("targetSize", 0), + project_id=project_id, + ) + ) + + request = self.client.regionInstanceGroupManagers().list_next( + previous_request=request, previous_response=response + ) + except Exception as error: + logger.error( + f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_zonal_instance_groups(self, zone: str) -> None: + """Fetch zonal managed instance groups for all projects.""" + for project_id in self.project_ids: + try: + request = self.client.instanceGroupManagers().list( + project=project_id, zone=zone + ) + while request is not None: + response = request.execute( + http=self.__get_AuthorizedHttp_client__(), + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + + for mig in response.get("items", []): + mig_zone = mig.get("zone", zone).split("/")[-1] + mig_region = mig_zone.rsplit("-", 1)[0] + + self.instance_groups.append( + ManagedInstanceGroup( + name=mig.get("name", ""), + id=mig.get("id", ""), + region=mig_region, + zone=mig_zone, + zones=[mig_zone], + is_regional=False, + target_size=mig.get("targetSize", 0), + project_id=project_id, + ) + ) + + request = self.client.instanceGroupManagers().list_next( + previous_request=request, previous_response=response + ) + except Exception as error: + logger.error( + f"{zone} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class Instance(BaseModel): name: str @@ -428,3 +514,14 @@ class LoadBalancer(BaseModel): service: str logging: bool = False project_id: str + + +class ManagedInstanceGroup(BaseModel): + name: str + id: str + region: str + zone: Optional[str] + zones: list + is_regional: bool + target_size: int + project_id: str diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 1af1690afa..f67f3fda35 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -329,7 +329,11 @@ config_azure = { "defender_attack_path_minimal_risk_level": "High", } -config_gcp = {"shodan_api_key": None, "max_unused_account_days": 30} +config_gcp = { + "shodan_api_key": None, + "mig_min_zones": 2, + "max_unused_account_days": 30, +} config_kubernetes = { "audit_log_maxbackup": 10, diff --git a/tests/config/fixtures/config.yaml b/tests/config/fixtures/config.yaml index d62332bfd1..b7c5278147 100644 --- a/tests/config/fixtures/config.yaml +++ b/tests/config/fixtures/config.yaml @@ -410,6 +410,9 @@ gcp: # GCP Compute Configuration # gcp.compute_public_address_shodan shodan_api_key: null + # gcp.compute_instance_group_multiple_zones + # Minimum number of zones a MIG should span for high availability + mig_min_zones: 2 max_unused_account_days: 30 # Kubernetes Configuration diff --git a/tests/providers/gcp/gcp_fixtures.py b/tests/providers/gcp/gcp_fixtures.py index bfa552ccd8..d4d83cd4da 100644 --- a/tests/providers/gcp/gcp_fixtures.py +++ b/tests/providers/gcp/gcp_fixtures.py @@ -57,6 +57,7 @@ def mock_api_client(GCPService, service, api_version, _): mock_api_sink_calls(client) mock_api_services_calls(client) mock_api_access_policies_calls(client) + mock_api_instance_group_managers_calls(client) return client @@ -1184,3 +1185,59 @@ def mock_api_access_policies_calls(client: MagicMock): client.accessPolicies().servicePerimeters().list = mock_list_service_perimeters client.accessPolicies().servicePerimeters().list_next.return_value = None + + +def mock_api_instance_group_managers_calls(client: MagicMock): + """Mock API calls for Managed Instance Groups (both regional and zonal).""" + regional_mig1_id = str(uuid4()) + regional_mig2_id = str(uuid4()) + zonal_mig1_id = str(uuid4()) + + # Mock regional instance group managers + client.regionInstanceGroupManagers().list().execute.return_value = { + "items": [ + { + "name": "regional-mig-1", + "id": regional_mig1_id, + "targetSize": 3, + "distributionPolicy": { + "zones": [ + { + "zone": "https://www.googleapis.com/compute/v1/projects/test-project/zones/europe-west1-b" + }, + { + "zone": "https://www.googleapis.com/compute/v1/projects/test-project/zones/europe-west1-c" + }, + { + "zone": "https://www.googleapis.com/compute/v1/projects/test-project/zones/europe-west1-d" + }, + ] + }, + }, + { + "name": "regional-mig-single-zone", + "id": regional_mig2_id, + "targetSize": 1, + "distributionPolicy": { + "zones": [ + { + "zone": "https://www.googleapis.com/compute/v1/projects/test-project/zones/europe-west1-b" + } + ] + }, + }, + ] + } + client.regionInstanceGroupManagers().list_next.return_value = None + + # Mock zonal instance group managers + client.instanceGroupManagers().list().execute.return_value = { + "items": [ + { + "name": "zonal-mig-1", + "id": zonal_mig1_id, + "targetSize": 2, + }, + ] + } + client.instanceGroupManagers().list_next.return_value = None diff --git a/tests/providers/gcp/gcp_provider_test.py b/tests/providers/gcp/gcp_provider_test.py index ad01635a99..e561674d1f 100644 --- a/tests/providers/gcp/gcp_provider_test.py +++ b/tests/providers/gcp/gcp_provider_test.py @@ -91,6 +91,7 @@ class TestGCPProvider: "shodan_api_key": None, "max_unused_account_days": 180, "storage_min_retention_days": 90, + "mig_min_zones": 2, } @freeze_time(datetime.today()) diff --git a/tests/providers/gcp/services/compute/compute_instance_group_multiple_zones/__init__.py b/tests/providers/gcp/services/compute/compute_instance_group_multiple_zones/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones_test.py b/tests/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones_test.py new file mode 100644 index 0000000000..884be3aed2 --- /dev/null +++ b/tests/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones_test.py @@ -0,0 +1,389 @@ +from re import search +from unittest import mock + +from prowler.providers.gcp.models import GCPProject +from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider + + +class Test_compute_instance_group_multiple_zones: + """Tests for the compute_instance_group_multiple_zones check.""" + + def test_no_instance_groups(self): + """Test when there are no managed instance groups.""" + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instance_groups = [] + compute_client.audit_config = {"mig_min_zones": 2} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones import ( + compute_instance_group_multiple_zones, + ) + + check = compute_instance_group_multiple_zones() + result = check.execute() + assert len(result) == 0 + + def test_regional_mig_multiple_zones_pass(self): + """Test a regional MIG spanning multiple zones - should PASS.""" + from prowler.providers.gcp.services.compute.compute_service import ( + ManagedInstanceGroup, + ) + + mig = ManagedInstanceGroup( + name="regional-mig-1", + id="123456789", + region="us-central1", + zone=None, + zones=["us-central1-a", "us-central1-b", "us-central1-c"], + is_regional=True, + target_size=3, + project_id=GCP_PROJECT_ID, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instance_groups = [mig] + compute_client.audit_config = {"mig_min_zones": 2} + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test-project", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "us-central1" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones import ( + compute_instance_group_multiple_zones, + ) + + check = compute_instance_group_multiple_zones() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + f"Managed Instance Group {mig.name} is a regional MIG spanning 3 zones", + result[0].status_extended, + ) + assert result[0].resource_id == mig.id + assert result[0].resource_name == mig.name + assert result[0].location == mig.region + assert result[0].project_id == GCP_PROJECT_ID + + def test_zonal_mig_single_zone_fail(self): + """Test a zonal MIG in a single zone - should FAIL.""" + from prowler.providers.gcp.services.compute.compute_service import ( + ManagedInstanceGroup, + ) + + mig = ManagedInstanceGroup( + name="zonal-mig-1", + id="987654321", + region="us-central1", + zone="us-central1-a", + zones=["us-central1-a"], + is_regional=False, + target_size=2, + project_id=GCP_PROJECT_ID, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instance_groups = [mig] + compute_client.audit_config = {"mig_min_zones": 2} + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test-project", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "us-central1" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones import ( + compute_instance_group_multiple_zones, + ) + + check = compute_instance_group_multiple_zones() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search( + f"Managed Instance Group {mig.name} is a zonal MIG running only in", + result[0].status_extended, + ) + assert result[0].resource_id == mig.id + assert result[0].resource_name == mig.name + assert result[0].location == mig.region + assert result[0].project_id == GCP_PROJECT_ID + + def test_regional_mig_single_zone_fail(self): + """Test a regional MIG with only one zone configured - should FAIL.""" + from prowler.providers.gcp.services.compute.compute_service import ( + ManagedInstanceGroup, + ) + + mig = ManagedInstanceGroup( + name="regional-mig-single-zone", + id="111222333", + region="europe-west1", + zone=None, + zones=["europe-west1-b"], + is_regional=True, + target_size=1, + project_id=GCP_PROJECT_ID, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instance_groups = [mig] + compute_client.audit_config = {"mig_min_zones": 2} + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test-project", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "europe-west1" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones import ( + compute_instance_group_multiple_zones, + ) + + check = compute_instance_group_multiple_zones() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search( + f"Managed Instance Group {mig.name} is a regional MIG but only spans 1 zone", + result[0].status_extended, + ) + assert result[0].resource_id == mig.id + assert result[0].resource_name == mig.name + assert result[0].location == mig.region + assert result[0].project_id == GCP_PROJECT_ID + + def test_multiple_migs_mixed_results(self): + """Test multiple MIGs with mixed compliance results.""" + from prowler.providers.gcp.services.compute.compute_service import ( + ManagedInstanceGroup, + ) + + mig_regional_pass = ManagedInstanceGroup( + name="regional-mig-good", + id="111", + region="us-central1", + zone=None, + zones=["us-central1-a", "us-central1-b"], + is_regional=True, + target_size=2, + project_id=GCP_PROJECT_ID, + ) + + mig_zonal_fail = ManagedInstanceGroup( + name="zonal-mig-bad", + id="222", + region="us-central1", + zone="us-central1-a", + zones=["us-central1-a"], + is_regional=False, + target_size=1, + project_id=GCP_PROJECT_ID, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instance_groups = [mig_regional_pass, mig_zonal_fail] + compute_client.audit_config = {"mig_min_zones": 2} + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test-project", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "us-central1" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones import ( + compute_instance_group_multiple_zones, + ) + + check = compute_instance_group_multiple_zones() + result = check.execute() + + assert len(result) == 2 + # First MIG (regional with 2 zones) should pass + assert result[0].status == "PASS" + assert result[0].resource_id == mig_regional_pass.id + # Second MIG (zonal with 1 zone) should fail + assert result[1].status == "FAIL" + assert result[1].resource_id == mig_zonal_fail.id + + def test_custom_min_zones_config(self): + """Test that the configurable min zones parameter is respected.""" + from prowler.providers.gcp.services.compute.compute_service import ( + ManagedInstanceGroup, + ) + + # MIG with 2 zones - should fail if min_zones is 3 + mig = ManagedInstanceGroup( + name="regional-mig-2zones", + id="333", + region="us-central1", + zone=None, + zones=["us-central1-a", "us-central1-b"], + is_regional=True, + target_size=2, + project_id=GCP_PROJECT_ID, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instance_groups = [mig] + compute_client.audit_config = {"mig_min_zones": 3} # Require 3 zones + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test-project", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "us-central1" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones import ( + compute_instance_group_multiple_zones, + ) + + check = compute_instance_group_multiple_zones() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search("minimum required is 3", result[0].status_extended) + + def test_default_min_zones_when_not_configured(self): + """Test that default min_zones (2) is used when not configured.""" + from prowler.providers.gcp.services.compute.compute_service import ( + ManagedInstanceGroup, + ) + + mig = ManagedInstanceGroup( + name="regional-mig-default", + id="444", + region="us-central1", + zone=None, + zones=["us-central1-a", "us-central1-b"], + is_regional=True, + target_size=2, + project_id=GCP_PROJECT_ID, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instance_groups = [mig] + compute_client.audit_config = {} # No mig_min_zones configured + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test-project", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "us-central1" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_group_multiple_zones.compute_instance_group_multiple_zones import ( + compute_instance_group_multiple_zones, + ) + + check = compute_instance_group_multiple_zones() + result = check.execute() + + assert len(result) == 1 + # 2 zones >= default 2, so should PASS + assert result[0].status == "PASS" diff --git a/tests/providers/gcp/services/compute/compute_service_test.py b/tests/providers/gcp/services/compute/compute_service_test.py index 8fe324c343..b8c1d1be19 100644 --- a/tests/providers/gcp/services/compute/compute_service_test.py +++ b/tests/providers/gcp/services/compute/compute_service_test.py @@ -186,3 +186,68 @@ class TestComputeService: assert compute_client.load_balancers[3].service == "regional_service2" assert compute_client.load_balancers[3].project_id == GCP_PROJECT_ID assert not compute_client.load_balancers[3].logging + + # Test Managed Instance Groups + # We expect 3 MIGs: 2 regional (from region europe-west1-b) and 1 zonal (from zone1) + assert len(compute_client.instance_groups) == 3 + + # First regional MIG - multiple zones + regional_mig_1 = next( + ( + mig + for mig in compute_client.instance_groups + if mig.name == "regional-mig-1" + ), + None, + ) + assert regional_mig_1 is not None + assert regional_mig_1.id.__class__.__name__ == "str" + assert regional_mig_1.region == "europe-west1-b" + assert regional_mig_1.zone is None # Regional MIGs don't have a single zone + assert len(regional_mig_1.zones) == 3 + assert "europe-west1-b" in regional_mig_1.zones + assert "europe-west1-c" in regional_mig_1.zones + assert "europe-west1-d" in regional_mig_1.zones + assert regional_mig_1.is_regional + assert regional_mig_1.target_size == 3 + assert regional_mig_1.project_id == GCP_PROJECT_ID + + # Second regional MIG - single zone + regional_mig_2 = next( + ( + mig + for mig in compute_client.instance_groups + if mig.name == "regional-mig-single-zone" + ), + None, + ) + assert regional_mig_2 is not None + assert regional_mig_2.id.__class__.__name__ == "str" + assert regional_mig_2.region == "europe-west1-b" + assert regional_mig_2.zone is None + assert len(regional_mig_2.zones) == 1 + assert "europe-west1-b" in regional_mig_2.zones + assert regional_mig_2.is_regional + assert regional_mig_2.target_size == 1 + assert regional_mig_2.project_id == GCP_PROJECT_ID + + # Zonal MIG + zonal_mig = next( + ( + mig + for mig in compute_client.instance_groups + if mig.name == "zonal-mig-1" + ), + None, + ) + assert zonal_mig is not None + assert zonal_mig.id.__class__.__name__ == "str" + assert ( + zonal_mig.region == "zone1" + ) # zone1 has no hyphen so region is "zone1" + assert zonal_mig.zone == "zone1" + assert len(zonal_mig.zones) == 1 + assert "zone1" in zonal_mig.zones + assert not zonal_mig.is_regional + assert zonal_mig.target_size == 2 + assert zonal_mig.project_id == GCP_PROJECT_ID