Skip to content

Commit 43875b6

Browse files
feat(gcp): add check to ensure Managed Instance Groups span multiple zones (#9566)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
1 parent 641dc78 commit 43875b6

File tree

14 files changed

+710
-1
lines changed

14 files changed

+710
-1
lines changed

docs/user-guide/cli/tutorials/configuration_file.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ The following list includes all the Azure checks with configurable variables tha
9393
## GCP
9494

9595
### Configurable Checks
96+
The following list includes all the GCP checks with configurable variables that can be changed in the configuration yaml file:
97+
98+
| Check Name | Value | Type |
99+
|---------------------------------------------------------------|--------------------------------------------------|-----------------|
100+
| `compute_instance_group_multiple_zones` | `mig_min_zones` | Integer |
96101

97102
## Kubernetes
98103

@@ -548,6 +553,9 @@ gcp:
548553
# GCP Compute Configuration
549554
# gcp.compute_public_address_shodan
550555
shodan_api_key: null
556+
# gcp.compute_instance_group_multiple_zones
557+
# Minimum number of zones a MIG should span for high availability
558+
mig_min_zones: 2
551559

552560
# Kubernetes Configuration
553561
kubernetes:

prowler/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
66

77
### Added
88
- Add Prowler ThreatScore for the Alibaba Cloud provider [(#9511)](https://github.com/prowler-cloud/prowler/pull/9511)
9+
- `compute_instance_group_multiple_zones` check for GCP provider [(#9566)](https://github.com/prowler-cloud/prowler/pull/9566)
910

1011
### Changed
1112
- Update AWS Step Functions service metadata to new format [(#9432)](https://github.com/prowler-cloud/prowler/pull/9432)

prowler/config/config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,9 @@ gcp:
507507
# GCP Compute Configuration
508508
# gcp.compute_public_address_shodan
509509
shodan_api_key: null
510+
# gcp.compute_instance_group_multiple_zones
511+
# Minimum number of zones a MIG should span for high availability
512+
mig_min_zones: 2
510513
# GCP Service Account and user-managed keys unused configuration
511514
# gcp.iam_service_account_unused
512515
# gcp.iam_sa_user_managed_key_unused

prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"Provider": "gcp",
3+
"CheckID": "compute_instance_group_multiple_zones",
4+
"CheckTitle": "Ensure Managed Instance Groups span multiple zones for high availability",
5+
"CheckType": [],
6+
"ServiceName": "compute",
7+
"SubServiceName": "",
8+
"ResourceIdTemplate": "",
9+
"Severity": "low",
10+
"ResourceType": "compute.googleapis.com/InstanceGroupManager",
11+
"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.",
12+
"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.",
13+
"RelatedUrl": "",
14+
"AdditionalURLs": [
15+
"https://cloud.google.com/compute/docs/instance-groups/regional-migs",
16+
"https://cloud.google.com/compute/docs/instance-groups/distributing-instances-with-regional-instance-groups"
17+
],
18+
"Remediation": {
19+
"Code": {
20+
"CLI": "gcloud compute instance-groups managed create INSTANCE_GROUP_NAME --region=REGION --template=INSTANCE_TEMPLATE --size=TARGET_SIZE --zones=ZONE1,ZONE2,ZONE3",
21+
"NativeIaC": "",
22+
"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'",
23+
"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```"
24+
},
25+
"Recommendation": {
26+
"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.",
27+
"Url": "https://hub.prowler.com/check/compute_instance_group_multiple_zones"
28+
}
29+
},
30+
"Categories": [
31+
"resilience"
32+
],
33+
"DependsOn": [],
34+
"RelatedTo": [],
35+
"Notes": "This check uses a configurable minimum zone count (default: 2). Configure via 'mig_min_zones' in config.yaml."
36+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from prowler.lib.check.models import Check, Check_Report_GCP
2+
from prowler.providers.gcp.services.compute.compute_client import compute_client
3+
4+
5+
class compute_instance_group_multiple_zones(Check):
6+
"""
7+
Ensure Managed Instance Groups span multiple zones for high availability.
8+
9+
This check verifies whether GCP Managed Instance Groups (MIGs) are distributed
10+
across multiple zones to ensure high availability and fault tolerance.
11+
12+
- PASS: The MIG spans the minimum required zones (configurable via mig_min_zones).
13+
- FAIL: The MIG does not meet the minimum zone requirement.
14+
"""
15+
16+
def execute(self) -> list[Check_Report_GCP]:
17+
findings = []
18+
min_zones = compute_client.audit_config.get("mig_min_zones", 2)
19+
20+
for instance_group in compute_client.instance_groups:
21+
report = Check_Report_GCP(
22+
metadata=self.metadata(),
23+
resource=instance_group,
24+
location=instance_group.region,
25+
)
26+
27+
zone_count = len(instance_group.zones)
28+
zones_str = ", ".join(instance_group.zones)
29+
30+
report.status = "PASS"
31+
if instance_group.is_regional:
32+
report.status_extended = f"Managed Instance Group {instance_group.name} is a regional MIG spanning {zone_count} zones ({zones_str})."
33+
else:
34+
report.status_extended = f"Managed Instance Group {instance_group.name} spans {zone_count} zones ({zones_str})."
35+
36+
if zone_count < min_zones:
37+
report.status = "FAIL"
38+
if instance_group.is_regional:
39+
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}."
40+
else:
41+
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."
42+
43+
findings.append(report)
44+
45+
return findings

prowler/providers/gcp/services/compute/compute_service.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Optional
2+
13
from pydantic.v1 import BaseModel
24

35
from prowler.lib.logger import logger
@@ -18,6 +20,7 @@ def __init__(self, provider: GcpProvider):
1820
self.firewalls = []
1921
self.compute_projects = []
2022
self.load_balancers = []
23+
self.instance_groups = []
2124
self._get_regions()
2225
self._get_projects()
2326
self._get_url_maps()
@@ -28,6 +31,8 @@ def __init__(self, provider: GcpProvider):
2831
self.__threading_call__(self._get_subnetworks, self.regions)
2932
self._get_firewalls()
3033
self.__threading_call__(self._get_addresses, self.regions)
34+
self.__threading_call__(self._get_regional_instance_groups, self.regions)
35+
self.__threading_call__(self._get_zonal_instance_groups, self.zones)
3136

3237
def _get_regions(self):
3338
for project_id in self.project_ids:
@@ -362,6 +367,87 @@ def _describe_backend_service(self):
362367
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
363368
)
364369

370+
def _get_regional_instance_groups(self, region: str) -> None:
371+
"""Fetch regional managed instance groups for all projects."""
372+
for project_id in self.project_ids:
373+
try:
374+
request = self.client.regionInstanceGroupManagers().list(
375+
project=project_id, region=region
376+
)
377+
while request is not None:
378+
response = request.execute(
379+
http=self.__get_AuthorizedHttp_client__(),
380+
num_retries=DEFAULT_RETRY_ATTEMPTS,
381+
)
382+
383+
for mig in response.get("items", []):
384+
zones = [
385+
zone_info["zone"].split("/")[-1]
386+
for zone_info in mig.get("distributionPolicy", {}).get(
387+
"zones", []
388+
)
389+
if zone_info.get("zone")
390+
]
391+
392+
self.instance_groups.append(
393+
ManagedInstanceGroup(
394+
name=mig.get("name", ""),
395+
id=mig.get("id", ""),
396+
region=region,
397+
zone=None,
398+
zones=zones,
399+
is_regional=True,
400+
target_size=mig.get("targetSize", 0),
401+
project_id=project_id,
402+
)
403+
)
404+
405+
request = self.client.regionInstanceGroupManagers().list_next(
406+
previous_request=request, previous_response=response
407+
)
408+
except Exception as error:
409+
logger.error(
410+
f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
411+
)
412+
413+
def _get_zonal_instance_groups(self, zone: str) -> None:
414+
"""Fetch zonal managed instance groups for all projects."""
415+
for project_id in self.project_ids:
416+
try:
417+
request = self.client.instanceGroupManagers().list(
418+
project=project_id, zone=zone
419+
)
420+
while request is not None:
421+
response = request.execute(
422+
http=self.__get_AuthorizedHttp_client__(),
423+
num_retries=DEFAULT_RETRY_ATTEMPTS,
424+
)
425+
426+
for mig in response.get("items", []):
427+
mig_zone = mig.get("zone", zone).split("/")[-1]
428+
mig_region = mig_zone.rsplit("-", 1)[0]
429+
430+
self.instance_groups.append(
431+
ManagedInstanceGroup(
432+
name=mig.get("name", ""),
433+
id=mig.get("id", ""),
434+
region=mig_region,
435+
zone=mig_zone,
436+
zones=[mig_zone],
437+
is_regional=False,
438+
target_size=mig.get("targetSize", 0),
439+
project_id=project_id,
440+
)
441+
)
442+
443+
request = self.client.instanceGroupManagers().list_next(
444+
previous_request=request, previous_response=response
445+
)
446+
except Exception as error:
447+
logger.error(
448+
f"{zone} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
449+
)
450+
365451

366452
class Instance(BaseModel):
367453
name: str
@@ -428,3 +514,14 @@ class LoadBalancer(BaseModel):
428514
service: str
429515
logging: bool = False
430516
project_id: str
517+
518+
519+
class ManagedInstanceGroup(BaseModel):
520+
name: str
521+
id: str
522+
region: str
523+
zone: Optional[str]
524+
zones: list
525+
is_regional: bool
526+
target_size: int
527+
project_id: str

tests/config/config_test.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,11 @@ def mock_prowler_get_latest_release(_, **kwargs):
329329
"defender_attack_path_minimal_risk_level": "High",
330330
}
331331

332-
config_gcp = {"shodan_api_key": None, "max_unused_account_days": 30}
332+
config_gcp = {
333+
"shodan_api_key": None,
334+
"mig_min_zones": 2,
335+
"max_unused_account_days": 30,
336+
}
333337

334338
config_kubernetes = {
335339
"audit_log_maxbackup": 10,

tests/config/fixtures/config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,9 @@ gcp:
410410
# GCP Compute Configuration
411411
# gcp.compute_public_address_shodan
412412
shodan_api_key: null
413+
# gcp.compute_instance_group_multiple_zones
414+
# Minimum number of zones a MIG should span for high availability
415+
mig_min_zones: 2
413416
max_unused_account_days: 30
414417

415418
# Kubernetes Configuration

tests/providers/gcp/gcp_fixtures.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def mock_api_client(GCPService, service, api_version, _):
5757
mock_api_sink_calls(client)
5858
mock_api_services_calls(client)
5959
mock_api_access_policies_calls(client)
60+
mock_api_instance_group_managers_calls(client)
6061

6162
return client
6263

@@ -1184,3 +1185,59 @@ def mock_list_service_perimeters(parent):
11841185

11851186
client.accessPolicies().servicePerimeters().list = mock_list_service_perimeters
11861187
client.accessPolicies().servicePerimeters().list_next.return_value = None
1188+
1189+
1190+
def mock_api_instance_group_managers_calls(client: MagicMock):
1191+
"""Mock API calls for Managed Instance Groups (both regional and zonal)."""
1192+
regional_mig1_id = str(uuid4())
1193+
regional_mig2_id = str(uuid4())
1194+
zonal_mig1_id = str(uuid4())
1195+
1196+
# Mock regional instance group managers
1197+
client.regionInstanceGroupManagers().list().execute.return_value = {
1198+
"items": [
1199+
{
1200+
"name": "regional-mig-1",
1201+
"id": regional_mig1_id,
1202+
"targetSize": 3,
1203+
"distributionPolicy": {
1204+
"zones": [
1205+
{
1206+
"zone": "https://www.googleapis.com/compute/v1/projects/test-project/zones/europe-west1-b"
1207+
},
1208+
{
1209+
"zone": "https://www.googleapis.com/compute/v1/projects/test-project/zones/europe-west1-c"
1210+
},
1211+
{
1212+
"zone": "https://www.googleapis.com/compute/v1/projects/test-project/zones/europe-west1-d"
1213+
},
1214+
]
1215+
},
1216+
},
1217+
{
1218+
"name": "regional-mig-single-zone",
1219+
"id": regional_mig2_id,
1220+
"targetSize": 1,
1221+
"distributionPolicy": {
1222+
"zones": [
1223+
{
1224+
"zone": "https://www.googleapis.com/compute/v1/projects/test-project/zones/europe-west1-b"
1225+
}
1226+
]
1227+
},
1228+
},
1229+
]
1230+
}
1231+
client.regionInstanceGroupManagers().list_next.return_value = None
1232+
1233+
# Mock zonal instance group managers
1234+
client.instanceGroupManagers().list().execute.return_value = {
1235+
"items": [
1236+
{
1237+
"name": "zonal-mig-1",
1238+
"id": zonal_mig1_id,
1239+
"targetSize": 2,
1240+
},
1241+
]
1242+
}
1243+
client.instanceGroupManagers().list_next.return_value = None

0 commit comments

Comments
 (0)