From b2abdbeb60243ce340fd5993dab8c730661451ee Mon Sep 17 00:00:00 2001 From: lydiavilchez <114735608+lydiavilchez@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:49:19 +0100 Subject: [PATCH] feat(gcp-compute): add check to ensure VMs are not preemptible or spot (#9342) Co-authored-by: HugoPBrito --- prowler/CHANGELOG.md | 1 + .../__init__.py | 0 ...ance_preemptible_vm_disabled.metadata.json | 37 ++ ...ompute_instance_preemptible_vm_disabled.py | 32 ++ .../gcp/services/compute/compute_service.py | 6 +- ...e_instance_preemptible_vm_disabled_test.py | 394 ++++++++++++++++++ 6 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 prowler/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/__init__.py create mode 100644 prowler/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/compute_instance_preemptible_vm_disabled.metadata.json create mode 100644 prowler/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/compute_instance_preemptible_vm_disabled.py create mode 100644 tests/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/compute_instance_preemptible_vm_disabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index f73a3d03d1..82519be4ed 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### Added - `cloudstorage_uses_vpc_service_controls` check for GCP provider [(#9256)](https://github.com/prowler-cloud/prowler/pull/9256) - `repository_immutable_releases_enabled` check for GitHub provider [(#9162)](https://github.com/prowler-cloud/prowler/pull/9162) +- `compute_instance_preemptible_vm_disabled` check for GCP provider [(#9342)](https://github.com/prowler-cloud/prowler/pull/9342) - `compute_instance_automatic_restart_enabled` check for GCP provider [(#9271)](https://github.com/prowler-cloud/prowler/pull/9271) --- diff --git a/prowler/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/__init__.py b/prowler/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/compute_instance_preemptible_vm_disabled.metadata.json b/prowler/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/compute_instance_preemptible_vm_disabled.metadata.json new file mode 100644 index 0000000000..019def4a54 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/compute_instance_preemptible_vm_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "gcp", + "CheckID": "compute_instance_preemptible_vm_disabled", + "CheckTitle": "VM instance is not configured as preemptible or Spot VM", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "compute.googleapis.com/Instance", + "Description": "This check verifies that VM instances are not configured as **preemptible** or **Spot VMs**.\n\nBoth preemptible and Spot VMs can be terminated by Google at any time when resources are needed elsewhere, making them unsuitable for production and business-critical workloads. Spot VMs are the newer version of preemptible VMs and are Google's recommended approach for interruptible workloads.", + "Risk": "Preemptible and Spot VMs may be **terminated at any time** by Google Cloud, causing:\n\n- **Service disruptions** for production workloads\n- **Data loss** if workloads are not fault-tolerant\n- **Availability issues** for business-critical applications\n\nThey are designed for batch jobs and fault-tolerant workloads only.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/compute/docs/instances/preemptible", + "https://cloud.google.com/compute/docs/instances/spot", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/ComputeEngine/disable-preemptibility.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Go to Compute Engine console\n2. Select the preemptible or Spot VM instance\n3. Create a machine image from the instance\n4. Create a new instance from the machine image\n5. During creation, set **VM provisioning model** to **Standard** (not Spot)\n6. Delete the original preemptible or Spot VM instance", + "Terraform": "```hcl\nresource \"google_compute_instance\" \"example_resource\" {\n name = \"example-instance\"\n machine_type = \"e2-medium\"\n zone = \"us-central1-a\"\n\n scheduling {\n # Use standard provisioning model for production workloads (not Spot)\n provisioning_model = \"STANDARD\"\n # Also ensure preemptible is false (legacy field)\n preemptible = false\n }\n}\n```" + }, + "Recommendation": { + "Text": "Use standard provisioning model for production and business-critical VM instances. Preemptible and Spot VMs should only be used for fault-tolerant, batch processing, or non-critical workloads that can handle interruptions.", + "Url": "https://hub.prowler.com/checks/compute_instance_preemptible_vm_disabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/compute_instance_preemptible_vm_disabled.py b/prowler/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/compute_instance_preemptible_vm_disabled.py new file mode 100644 index 0000000000..fe64c0d2b7 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/compute_instance_preemptible_vm_disabled.py @@ -0,0 +1,32 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.compute.compute_client import compute_client + + +class compute_instance_preemptible_vm_disabled(Check): + """ + Ensure GCP Compute Engine VM instances are not preemptible or Spot VMs. + + - PASS: VM instance is not preemptible (preemptible=False) and not Spot + (provisioningModel != "SPOT"). + - FAIL: VM instance is preemptible (preemptible=True) or Spot + (provisioningModel="SPOT"). + """ + + def execute(self) -> list[Check_Report_GCP]: + findings = [] + for instance in compute_client.instances: + report = Check_Report_GCP(metadata=self.metadata(), resource=instance) + report.status = "PASS" + report.status_extended = ( + f"VM Instance {instance.name} is not preemptible or Spot VM." + ) + + if instance.preemptible or instance.provisioning_model == "SPOT": + report.status = "FAIL" + vm_type = "preemptible" if instance.preemptible else "Spot VM" + report.status_extended = ( + f"VM Instance {instance.name} is configured as {vm_type}." + ) + + 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 1483563c6d..bbe7d9ff4d 100644 --- a/prowler/providers/gcp/services/compute/compute_service.py +++ b/prowler/providers/gcp/services/compute/compute_service.py @@ -136,13 +136,13 @@ class Compute(GCPService): automatic_restart=instance.get("scheduling", {}).get( "automaticRestart", False ), - preemptible=instance.get("scheduling", {}).get( - "preemptible", False - ), provisioning_model=instance.get("scheduling", {}).get( "provisioningModel", "STANDARD" ), project_id=project_id, + preemptible=instance.get("scheduling", {}).get( + "preemptible", False + ), ) ) diff --git a/tests/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/compute_instance_preemptible_vm_disabled_test.py b/tests/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/compute_instance_preemptible_vm_disabled_test.py new file mode 100644 index 0000000000..da85526845 --- /dev/null +++ b/tests/providers/gcp/services/compute/compute_instance_preemptible_vm_disabled/compute_instance_preemptible_vm_disabled_test.py @@ -0,0 +1,394 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider + + +class TestComputeInstancePreemptibleVmDisabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [] + + 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_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import ( + compute_instance_preemptible_vm_disabled, + ) + + check = compute_instance_preemptible_vm_disabled() + result = check.execute() + assert len(result) == 0 + + def test_instance_not_preemptible(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_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import ( + compute_instance_preemptible_vm_disabled, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="test", + id="1234567890", + zone="us-central1-a", + region="us-central1", + public_ip=True, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=True, + service_accounts=[], + ip_forward=False, + disks_encryption=[("disk1", False), ("disk2", False)], + project_id=GCP_PROJECT_ID, + preemptible=False, + provisioning_model="", + ) + ] + + check = compute_instance_preemptible_vm_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"VM Instance {compute_client.instances[0].name} is not preemptible or Spot VM." + ) + assert result[0].resource_id == "1234567890" + assert result[0].resource_name == "test" + assert result[0].location == "us-central1" + assert result[0].project_id == GCP_PROJECT_ID + + def test_instance_preemptible(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_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import ( + compute_instance_preemptible_vm_disabled, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="test", + id="1234567890", + zone="us-central1-a", + region="us-central1", + public_ip=True, + metadata={}, + shielded_enabled_vtpm=False, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[("disk1", False), ("disk2", False)], + project_id=GCP_PROJECT_ID, + preemptible=True, + provisioning_model="", + ) + ] + + check = compute_instance_preemptible_vm_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"VM Instance {compute_client.instances[0].name} is configured as preemptible." + ) + assert result[0].resource_id == "1234567890" + assert result[0].resource_name == "test" + assert result[0].location == "us-central1" + assert result[0].project_id == GCP_PROJECT_ID + + def test_multiple_instances_mixed_preemptible_and_standard(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_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import ( + compute_instance_preemptible_vm_disabled, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="preemptible-instance", + id="111111111", + zone="us-central1-a", + region="us-central1", + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + project_id=GCP_PROJECT_ID, + preemptible=True, + provisioning_model="", + ), + Instance( + name="standard-instance", + id="222222222", + zone="europe-west1-b", + region="europe-west1", + public_ip=True, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=True, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + project_id=GCP_PROJECT_ID, + preemptible=False, + provisioning_model="", + ), + ] + + check = compute_instance_preemptible_vm_disabled() + result = check.execute() + + assert len(result) == 2 + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "VM Instance preemptible-instance is configured as preemptible." + ) + assert result[0].resource_id == "111111111" + assert result[0].resource_name == "preemptible-instance" + + assert result[1].status == "PASS" + assert ( + result[1].status_extended + == "VM Instance standard-instance is not preemptible or Spot VM." + ) + assert result[1].resource_id == "222222222" + assert result[1].resource_name == "standard-instance" + + def test_instance_spot_vm(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_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import ( + compute_instance_preemptible_vm_disabled, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="spot-vm", + id="3333333333", + zone="us-west1-a", + region="us-west1", + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + project_id=GCP_PROJECT_ID, + preemptible=False, + provisioning_model="SPOT", + ) + ] + + check = compute_instance_preemptible_vm_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "VM Instance spot-vm is configured as Spot VM." + ) + assert result[0].resource_id == "3333333333" + assert result[0].resource_name == "spot-vm" + assert result[0].location == "us-west1" + assert result[0].project_id == GCP_PROJECT_ID + + def test_instance_standard_provisioning_model(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_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import ( + compute_instance_preemptible_vm_disabled, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="standard-vm", + id="4444444444", + zone="asia-east1-a", + region="asia-east1", + public_ip=True, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + project_id=GCP_PROJECT_ID, + preemptible=False, + provisioning_model="STANDARD", + ) + ] + + check = compute_instance_preemptible_vm_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "VM Instance standard-vm is not preemptible or Spot VM." + ) + assert result[0].resource_id == "4444444444" + assert result[0].resource_name == "standard-vm" + assert result[0].location == "asia-east1" + assert result[0].project_id == GCP_PROJECT_ID + + def test_multiple_instances_spot_and_standard(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_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_preemptible_vm_disabled.compute_instance_preemptible_vm_disabled import ( + compute_instance_preemptible_vm_disabled, + ) + from prowler.providers.gcp.services.compute.compute_service import Instance + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [ + Instance( + name="spot-instance", + id="5555555555", + zone="us-central1-c", + region="us-central1", + public_ip=False, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + project_id=GCP_PROJECT_ID, + preemptible=False, + provisioning_model="SPOT", + ), + Instance( + name="standard-instance-2", + id="6666666666", + zone="europe-west2-a", + region="europe-west2", + public_ip=True, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=True, + service_accounts=[], + ip_forward=False, + disks_encryption=[], + project_id=GCP_PROJECT_ID, + preemptible=False, + provisioning_model="STANDARD", + ), + ] + + check = compute_instance_preemptible_vm_disabled() + result = check.execute() + + assert len(result) == 2 + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "VM Instance spot-instance is configured as Spot VM." + ) + assert result[0].resource_id == "5555555555" + assert result[0].resource_name == "spot-instance" + + assert result[1].status == "PASS" + assert ( + result[1].status_extended + == "VM Instance standard-instance-2 is not preemptible or Spot VM." + ) + assert result[1].resource_id == "6666666666" + assert result[1].resource_name == "standard-instance-2"