From 144d59de45796e68b4037d074f062fafd8cf46c2 Mon Sep 17 00:00:00 2001 From: lydiavilchez <114735608+lydiavilchez@users.noreply.github.com> Date: Mon, 29 Dec 2025 14:16:11 +0100 Subject: [PATCH] feat(gcp): add check to ensure Managed Instance Groups are attached to load balancers (#9695) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + .../__init__.py | 0 ...group_load_balancer_attached.metadata.json | 38 +++ ...e_instance_group_load_balancer_attached.py | 36 +++ .../gcp/services/compute/compute_service.py | 49 ++++ tests/providers/gcp/gcp_fixtures.py | 6 + ...tance_group_load_balancer_attached_test.py | 274 ++++++++++++++++++ 7 files changed, 404 insertions(+) create mode 100644 prowler/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/__init__.py create mode 100644 prowler/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/compute_instance_group_load_balancer_attached.metadata.json create mode 100644 prowler/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/compute_instance_group_load_balancer_attached.py create mode 100644 tests/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/compute_instance_group_load_balancer_attached_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 9fa79dc610..1c41466b09 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `compute_instance_disk_auto_delete_disabled` check for GCP provider [(#9604)](https://github.com/prowler-cloud/prowler/pull/9604) - Bedrock service pagination [(#9606)](https://github.com/prowler-cloud/prowler/pull/9606) - `ResourceGroup` field to all check metadata for resource classification [(#9656)](https://github.com/prowler-cloud/prowler/pull/9656) +- `compute_instance_group_load_balancer_attached` check for GCP provider [(#9695)](https://github.com/prowler-cloud/prowler/pull/9695) ### Changed - Update AWS Step Functions service metadata to new format [(#9432)](https://github.com/prowler-cloud/prowler/pull/9432) diff --git a/prowler/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/__init__.py b/prowler/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/compute_instance_group_load_balancer_attached.metadata.json b/prowler/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/compute_instance_group_load_balancer_attached.metadata.json new file mode 100644 index 0000000000..46068fb81b --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/compute_instance_group_load_balancer_attached.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "gcp", + "CheckID": "compute_instance_group_load_balancer_attached", + "CheckTitle": "Managed Instance Group is attached to a load balancer", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "compute.googleapis.com/InstanceGroupManager", + "ResourceGroup": "compute", + "Description": "Managed Instance Groups (MIGs) should be attached to load balancers via backend services to enable traffic distribution across instances. Load balancers provide health checking, autoscaling integration, and high availability features that are essential for production workloads.", + "Risk": "Without load balancer attachment, MIGs cannot distribute traffic evenly across instances, which impacts:\n\n- **Application availability** - No automatic failover when instances become unhealthy\n- **Scalability** - Autoscaling benefits are limited without proper traffic distribution\n- **Performance** - Uneven load distribution can cause hotspots and degraded user experience", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/compute/docs/instance-groups", + "https://cloud.google.com/load-balancing/docs/backend-service", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/ComputeEngine/mig-load-balancer-check.html" + ], + "Remediation": { + "Code": { + "CLI": "gcloud compute backend-services add-backend BACKEND_SERVICE_NAME --instance-group=INSTANCE_GROUP_NAME --instance-group-zone=ZONE --global", + "NativeIaC": "", + "Other": "1. Navigate to Network Services > Load balancing\n2. Create or edit an HTTP(S) load balancer\n3. Configure the backend service\n4. Select the target MIG from the instance group dropdown\n5. Configure port and balancing mode\n6. Complete the load balancer setup", + "Terraform": "```hcl\nresource \"google_compute_backend_service\" \"example\" {\n name = \"example-backend-service\"\n protocol = \"HTTP\"\n port_name = \"http\"\n timeout_sec = 30\n\n # Attach MIG as backend\n backend {\n group = google_compute_instance_group_manager.example.instance_group\n }\n\n health_checks = [google_compute_health_check.example.id]\n}\n```" + }, + "Recommendation": { + "Text": "Attach Managed Instance Groups to load balancers using backend services to enable traffic distribution, health checking, and seamless autoscaling. This ensures high availability and optimal performance for production workloads.", + "Url": "https://hub.prowler.com/check/compute_instance_group_load_balancer_attached" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/compute_instance_group_load_balancer_attached.py b/prowler/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/compute_instance_group_load_balancer_attached.py new file mode 100644 index 0000000000..573d38d77f --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/compute_instance_group_load_balancer_attached.py @@ -0,0 +1,36 @@ +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_load_balancer_attached(Check): + """ + Ensure Managed Instance Groups are attached to load balancers. + + This check verifies whether GCP Managed Instance Groups (MIGs) are configured + as backends for load balancers through backend services. MIGs without load + balancer attachments cannot distribute traffic evenly across instances. + + - PASS: The MIG is attached to a load balancer via a backend service. + - FAIL: The MIG is not attached to any load balancer. + """ + + def execute(self) -> list[Check_Report_GCP]: + findings = [] + + for instance_group in compute_client.instance_groups: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=instance_group, + location=instance_group.region, + ) + + if instance_group.load_balanced: + report.status = "PASS" + report.status_extended = f"Managed Instance Group {instance_group.name} is attached to a load balancer." + else: + report.status = "FAIL" + report.status_extended = f"Managed Instance Group {instance_group.name} is not attached to any load balancer." + + 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 70c0f219fc..f940726df7 100644 --- a/prowler/providers/gcp/services/compute/compute_service.py +++ b/prowler/providers/gcp/services/compute/compute_service.py @@ -33,6 +33,7 @@ class Compute(GCPService): 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) + self._associate_migs_with_load_balancers() def _get_regions(self): for project_id in self.project_ids: @@ -461,6 +462,53 @@ class Compute(GCPService): f"{zone} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _associate_migs_with_load_balancers(self) -> None: + load_balanced_groups = set() + + for project_id in self.project_ids: + try: + request = self.client.backendServices().list(project=project_id) + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + for backend_service in response.get("items", []): + for backend in backend_service.get("backends", []): + group_url = backend.get("group", "") + if group_url: + group_name = group_url.split("/")[-1] + load_balanced_groups.add((project_id, group_name)) + request = self.client.backendServices().list_next( + previous_request=request, previous_response=response + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + for region in self.regions: + try: + request = self.client.regionBackendServices().list( + project=project_id, region=region + ) + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + for backend_service in response.get("items", []): + for backend in backend_service.get("backends", []): + group_url = backend.get("group", "") + if group_url: + group_name = group_url.split("/")[-1] + load_balanced_groups.add((project_id, group_name)) + request = self.client.regionBackendServices().list_next( + previous_request=request, previous_response=response + ) + except Exception as error: + logger.error( + f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + for mig in self.instance_groups: + if (mig.project_id, mig.name) in load_balanced_groups: + mig.load_balanced = True + class Disk(BaseModel): name: str @@ -546,3 +594,4 @@ class ManagedInstanceGroup(BaseModel): is_regional: bool target_size: int project_id: str + load_balanced: bool = False diff --git a/tests/providers/gcp/gcp_fixtures.py b/tests/providers/gcp/gcp_fixtures.py index d4d83cd4da..7706ec43aa 100644 --- a/tests/providers/gcp/gcp_fixtures.py +++ b/tests/providers/gcp/gcp_fixtures.py @@ -1027,6 +1027,12 @@ def mock_api_urlMaps_calls(client: MagicMock): "logConfig": {"enable": False}, }, ] + # Mock backendServices().list() for _associate_migs_with_load_balancers() + client.backendServices().list().execute.return_value = {"items": []} + client.backendServices().list_next.return_value = None + # Mock regionBackendServices().list() for _associate_migs_with_load_balancers() + client.regionBackendServices().list().execute.return_value = {"items": []} + client.regionBackendServices().list_next.return_value = None def mock_api_managedZones_calls(client: MagicMock): diff --git a/tests/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/compute_instance_group_load_balancer_attached_test.py b/tests/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/compute_instance_group_load_balancer_attached_test.py new file mode 100644 index 0000000000..7291983112 --- /dev/null +++ b/tests/providers/gcp/services/compute/compute_instance_group_load_balancer_attached/compute_instance_group_load_balancer_attached_test.py @@ -0,0 +1,274 @@ +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_load_balancer_attached: + + def test_no_instance_groups(self): + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instance_groups = [] + + 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_load_balancer_attached.compute_instance_group_load_balancer_attached.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_group_load_balancer_attached.compute_instance_group_load_balancer_attached import ( + compute_instance_group_load_balancer_attached, + ) + + check = compute_instance_group_load_balancer_attached() + result = check.execute() + assert len(result) == 0 + + def test_mig_attached_to_load_balancer_pass(self): + from prowler.providers.gcp.services.compute.compute_service import ( + ManagedInstanceGroup, + ) + + mig = ManagedInstanceGroup( + name="mig-with-lb", + id="123456789", + region="us-central1", + zone=None, + zones=["us-central1-a", "us-central1-b"], + is_regional=True, + target_size=2, + project_id=GCP_PROJECT_ID, + load_balanced=True, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instance_groups = [mig] + 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_load_balancer_attached.compute_instance_group_load_balancer_attached.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_group_load_balancer_attached.compute_instance_group_load_balancer_attached import ( + compute_instance_group_load_balancer_attached, + ) + + check = compute_instance_group_load_balancer_attached() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + f"Managed Instance Group {mig.name} is attached to a load balancer", + 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_mig_not_attached_to_load_balancer_fail(self): + from prowler.providers.gcp.services.compute.compute_service import ( + ManagedInstanceGroup, + ) + + mig = ManagedInstanceGroup( + name="mig-without-lb", + id="987654321", + region="us-central1", + zone="us-central1-a", + zones=["us-central1-a"], + is_regional=False, + target_size=1, + project_id=GCP_PROJECT_ID, + load_balanced=False, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instance_groups = [mig] + 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_load_balancer_attached.compute_instance_group_load_balancer_attached.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_group_load_balancer_attached.compute_instance_group_load_balancer_attached import ( + compute_instance_group_load_balancer_attached, + ) + + check = compute_instance_group_load_balancer_attached() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search( + f"Managed Instance Group {mig.name} is not attached to any load balancer", + 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): + from prowler.providers.gcp.services.compute.compute_service import ( + ManagedInstanceGroup, + ) + + mig_with_lb = ManagedInstanceGroup( + name="mig-with-lb", + 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, + load_balanced=True, + ) + + mig_without_lb = ManagedInstanceGroup( + name="mig-without-lb", + id="222", + region="us-central1", + zone="us-central1-a", + zones=["us-central1-a"], + is_regional=False, + target_size=1, + project_id=GCP_PROJECT_ID, + load_balanced=False, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instance_groups = [mig_with_lb, mig_without_lb] + 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_load_balancer_attached.compute_instance_group_load_balancer_attached.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_group_load_balancer_attached.compute_instance_group_load_balancer_attached import ( + compute_instance_group_load_balancer_attached, + ) + + check = compute_instance_group_load_balancer_attached() + result = check.execute() + + assert len(result) == 2 + assert result[0].status == "PASS" + assert result[0].resource_id == mig_with_lb.id + assert result[1].status == "FAIL" + assert result[1].resource_id == mig_without_lb.id + + def test_zonal_mig_attached_to_load_balancer_pass(self): + from prowler.providers.gcp.services.compute.compute_service import ( + ManagedInstanceGroup, + ) + + mig = ManagedInstanceGroup( + name="zonal-mig-with-lb", + id="333", + region="europe-west1", + zone="europe-west1-b", + zones=["europe-west1-b"], + is_regional=False, + target_size=3, + project_id=GCP_PROJECT_ID, + load_balanced=True, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instance_groups = [mig] + 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_load_balancer_attached.compute_instance_group_load_balancer_attached.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_group_load_balancer_attached.compute_instance_group_load_balancer_attached import ( + compute_instance_group_load_balancer_attached, + ) + + check = compute_instance_group_load_balancer_attached() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + "is attached to a load balancer", + 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