feat(gcp): add check to ensure Compute Engine disk images are not publicly shared (#9718)

This commit is contained in:
lydiavilchez
2026-01-07 15:05:36 +01:00
committed by GitHub
parent beb2daa30d
commit e12e0dc1aa
8 changed files with 368 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- 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)
- `compute_image_not_publicly_shared` check for GCP provider [(#9718)](https://github.com/prowler-cloud/prowler/pull/9718)
### Changed
- Update AWS Step Functions service metadata to new format [(#9432)](https://github.com/prowler-cloud/prowler/pull/9432)

View File

@@ -0,0 +1,37 @@
{
"Provider": "gcp",
"CheckID": "compute_image_not_publicly_shared",
"CheckTitle": "Compute Engine disk image is not publicly shared",
"CheckType": [],
"ServiceName": "compute",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "compute.googleapis.com/Image",
"ResourceGroup": "compute",
"Description": "Custom disk images should not be shared publicly with **allAuthenticatedUsers**.\n\nNote: Per Google Cloud API restrictions, **allUsers** cannot be assigned to Compute Engine images. The security concern is **allAuthenticatedUsers**, which grants access to anyone with a Google account.\n\nPublicly shared disk images can expose application snapshots and sensitive data to anyone with a Google Cloud account, potentially leading to unauthorized access and data breaches.",
"Risk": "Publicly shared disk images can expose **sensitive data** and application configurations to unauthorized users.\n\n- Any authenticated GCP user can access the image content\n- Could lead to **data breaches** if images contain secrets or proprietary code\n- Attackers may use exposed images to understand application architecture",
"RelatedUrl": "",
"AdditionalURLs": [
"https://cloud.google.com/compute/docs/images/managing-access-custom-images",
"https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/ComputeEngine/publicly-shared-disk-images.html"
],
"Remediation": {
"Code": {
"CLI": "gcloud compute images remove-iam-policy-binding IMAGE_NAME --member='allAuthenticatedUsers' --role='ROLE_NAME'",
"NativeIaC": "",
"Other": "1. Go to the GCP Console\n2. Navigate to Compute Engine > Images\n3. Select the disk image\n4. Click on the INFO PANEL to view permissions\n5. Remove **allAuthenticatedUsers** bindings\n6. Click Save",
"Terraform": "```hcl\nresource \"google_compute_image_iam_binding\" \"example_resource\" {\n project = \"your-project-id\"\n image = \"your-image-name\"\n role = \"roles/compute.imageUser\"\n # Remove allAuthenticatedUsers and grant access only to specific members\n members = [\n \"user:specific-user@example.com\",\n ]\n}\n```"
},
"Recommendation": {
"Text": "Restrict access to custom disk images by removing the **allAuthenticatedUsers** IAM binding. Apply the principle of least privilege by granting access only to specific users, groups, or service accounts that require it.",
"Url": "https://hub.prowler.com/check/compute_image_not_publicly_shared"
}
},
"Categories": [
"internet-exposed"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}

View File

@@ -0,0 +1,39 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.compute.compute_client import compute_client
class compute_image_not_publicly_shared(Check):
"""Ensure Compute Engine disk images are not publicly shared.
This check evaluates whether custom disk images in GCP Compute Engine
have IAM bindings that grant access to allAuthenticatedUsers, which allows
anyone with a Google account to access the image.
Note: allUsers cannot be assigned to Compute Engine images (API restriction).
Only allAuthenticatedUsers can be set, which is the security risk.
Reference: https://cloud.google.com/compute/docs/images/managing-access-custom-images
- PASS: The disk image is not publicly shared.
- FAIL: The disk image is publicly shared with allAuthenticatedUsers.
"""
def execute(self) -> list[Check_Report_GCP]:
findings = []
for image in compute_client.images:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=image,
location="global",
)
report.status = "PASS"
report.status_extended = (
f"Compute Engine disk image {image.name} is not publicly shared."
)
if image.publicly_shared:
report.status = "FAIL"
report.status_extended = f"Compute Engine disk image {image.name} is publicly shared with allAuthenticatedUsers."
findings.append(report)
return findings

View File

@@ -21,6 +21,7 @@ class Compute(GCPService):
self.compute_projects = []
self.load_balancers = []
self.instance_groups = []
self.images = []
self._get_regions()
self._get_projects()
self._get_url_maps()
@@ -34,6 +35,7 @@ class Compute(GCPService):
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()
self._get_images()
def _get_regions(self):
for project_id in self.project_ids:
@@ -533,6 +535,52 @@ class Compute(GCPService):
if (mig.project_id, mig.name) in load_balanced_groups:
mig.load_balanced = True
def _get_images(self) -> None:
for project_id in self.project_ids:
try:
request = self.client.images().list(project=project_id)
while request is not None:
response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
for image in response.get("items", []):
publicly_shared = False
try:
iam_policy = (
self.client.images()
.getIamPolicy(
project=project_id, resource=image["name"]
)
.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
)
for binding in iam_policy.get("bindings", []):
# allUsers cannot be assigned to Compute Engine images (API restriction).
# Only allAuthenticatedUsers can be set, which is the security risk.
if "allAuthenticatedUsers" in binding.get(
"members", []
):
publicly_shared = True
break
except Exception as error:
logger.error(
f"{project_id}/{image['name']} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.images.append(
Image(
name=image["name"],
id=image["id"],
project_id=project_id,
publicly_shared=publicly_shared,
)
)
request = self.client.images().list_next(
previous_request=request, previous_response=response
)
except Exception as error:
logger.error(
f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class Disk(BaseModel):
name: str
@@ -625,3 +673,10 @@ class ManagedInstanceGroup(BaseModel):
project_id: str
auto_healing_policies: list[AutoHealingPolicy] = []
load_balanced: bool = False
class Image(BaseModel):
name: str
id: str
project_id: str
publicly_shared: bool = False

View File

@@ -58,6 +58,7 @@ def mock_api_client(GCPService, service, api_version, _):
mock_api_services_calls(client)
mock_api_access_policies_calls(client)
mock_api_instance_group_managers_calls(client)
mock_api_images_calls(client)
return client
@@ -1260,3 +1261,53 @@ def mock_api_instance_group_managers_calls(client: MagicMock):
]
}
client.instanceGroupManagers().list_next.return_value = None
def mock_api_images_calls(client: MagicMock):
image1_id = str(uuid4())
image2_id = str(uuid4())
image3_id = str(uuid4())
client.images().list().execute.return_value = {
"items": [
{
"name": "test-image-1",
"id": image1_id,
},
{
"name": "test-image-2",
"id": image2_id,
},
{
"name": "test-image-3",
"id": image3_id,
},
]
}
client.images().list_next.return_value = None
def mock_get_image_iam_policy(project, resource):
return_value = MagicMock()
if resource == "test-image-1":
return_value.execute.return_value = {
"bindings": [
{
"role": "roles/compute.imageUser",
"members": ["user:test@example.com"],
}
]
}
elif resource == "test-image-2":
return_value.execute.return_value = {
"bindings": [
{
"role": "roles/compute.imageUser",
"members": ["allAuthenticatedUsers"],
}
]
}
elif resource == "test-image-3":
return_value.execute.side_effect = Exception("Permission denied")
return return_value
client.images().getIamPolicy = mock_get_image_iam_policy

View File

@@ -0,0 +1,168 @@
from unittest import mock
from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider
class Test_compute_image_not_publicly_shared:
def test_compute_no_images(self):
compute_client = mock.MagicMock()
compute_client.images = []
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_image_not_publicly_shared.compute_image_not_publicly_shared.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_image_not_publicly_shared.compute_image_not_publicly_shared import (
compute_image_not_publicly_shared,
)
check = compute_image_not_publicly_shared()
result = check.execute()
assert len(result) == 0
def test_image_not_publicly_shared(self):
compute_client = mock.MagicMock()
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_image_not_publicly_shared.compute_image_not_publicly_shared.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_image_not_publicly_shared.compute_image_not_publicly_shared import (
compute_image_not_publicly_shared,
)
from prowler.providers.gcp.services.compute.compute_service import Image
image = Image(
name="private-image",
id="1234567890",
project_id=GCP_PROJECT_ID,
publicly_shared=False,
)
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.images = [image]
check = compute_image_not_publicly_shared()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Compute Engine disk image private-image is not publicly shared."
)
assert result[0].resource_id == "1234567890"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].resource_name == "private-image"
assert result[0].location == "global"
def test_image_publicly_shared_with_all_authenticated_users(self):
from prowler.providers.gcp.services.compute.compute_service import Image
image = Image(
name="public-image",
id="1234567890",
project_id=GCP_PROJECT_ID,
publicly_shared=True,
)
compute_client = mock.MagicMock()
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.images = [image]
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_image_not_publicly_shared.compute_image_not_publicly_shared.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_image_not_publicly_shared.compute_image_not_publicly_shared import (
compute_image_not_publicly_shared,
)
check = compute_image_not_publicly_shared()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Compute Engine disk image public-image is publicly shared with allAuthenticatedUsers."
)
assert result[0].resource_id == "1234567890"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].resource_name == "public-image"
assert result[0].location == "global"
def test_multiple_images_mixed_sharing(self):
from prowler.providers.gcp.services.compute.compute_service import Image
private_image = Image(
name="private-image",
id="1111111111",
project_id=GCP_PROJECT_ID,
publicly_shared=False,
)
public_image = Image(
name="public-image",
id="2222222222",
project_id=GCP_PROJECT_ID,
publicly_shared=True,
)
compute_client = mock.MagicMock()
compute_client.project_ids = [GCP_PROJECT_ID]
compute_client.images = [private_image, public_image]
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_image_not_publicly_shared.compute_image_not_publicly_shared.compute_client",
new=compute_client,
),
):
from prowler.providers.gcp.services.compute.compute_image_not_publicly_shared.compute_image_not_publicly_shared import (
compute_image_not_publicly_shared,
)
check = compute_image_not_publicly_shared()
result = check.execute()
assert len(result) == 2
private_result = next(
r for r in result if r.resource_name == "private-image"
)
public_result = next(r for r in result if r.resource_name == "public-image")
assert private_result.status == "PASS"
assert (
private_result.status_extended
== "Compute Engine disk image private-image is not publicly shared."
)
assert public_result.status == "FAIL"
assert (
public_result.status_extended
== "Compute Engine disk image public-image is publicly shared with allAuthenticatedUsers."
)

View File

@@ -258,3 +258,20 @@ class TestComputeService:
assert len(zonal_mig.auto_healing_policies) == 1
assert zonal_mig.auto_healing_policies[0].health_check == "tcp-health-check"
assert zonal_mig.auto_healing_policies[0].initial_delay_sec == 120
# Test images
assert len(compute_client.images) == 3
assert compute_client.images[0].name == "test-image-1"
assert compute_client.images[0].id.__class__.__name__ == "str"
assert compute_client.images[0].project_id == GCP_PROJECT_ID
assert not compute_client.images[0].publicly_shared
assert compute_client.images[1].name == "test-image-2"
assert compute_client.images[1].id.__class__.__name__ == "str"
assert compute_client.images[1].project_id == GCP_PROJECT_ID
assert compute_client.images[1].publicly_shared
assert compute_client.images[2].name == "test-image-3"
assert compute_client.images[2].id.__class__.__name__ == "str"
assert compute_client.images[2].project_id == GCP_PROJECT_ID
assert not compute_client.images[2].publicly_shared