diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d0dc54094c..c42eb0728e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -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_instance_single_network_interface` check for GCP provider [(#9702)](https://github.com/prowler-cloud/prowler/pull/9702) - `compute_image_not_publicly_shared` check for GCP provider [(#9718)](https://github.com/prowler-cloud/prowler/pull/9718) ### Changed diff --git a/prowler/providers/gcp/services/compute/compute_instance_single_network_interface/__init__.py b/prowler/providers/gcp/services/compute/compute_instance_single_network_interface/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/compute/compute_instance_single_network_interface/compute_instance_single_network_interface.metadata.json b/prowler/providers/gcp/services/compute/compute_instance_single_network_interface/compute_instance_single_network_interface.metadata.json new file mode 100644 index 0000000000..8c6069c6b6 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_instance_single_network_interface/compute_instance_single_network_interface.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "compute_instance_single_network_interface", + "CheckTitle": "VM instance has a single network interface", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "compute.googleapis.com/Instance", + "ResourceGroup": "compute", + "Description": "VM instances should be configured with only **one network interface** unless multiple interfaces are explicitly required for complex network configurations.\n\nMultiple network interfaces expand the attack surface and create additional network pathways that may be exploited.", + "Risk": "Multiple network interfaces on a VM instance can:\n\n- **Expand attack surface** by providing additional entry points for unauthorized access\n- **Create unintended network paths** that bypass security controls\n- **Increase management complexity** leading to potential misconfigurations", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/ComputeEngine/vms-with-multiple-enis.html", + "https://cloud.google.com/vpc/docs/multiple-interfaces-concepts" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Create a machine image from the non-compliant VM instance\n2. Create a new VM instance from the machine image with only one network interface\n3. Verify the new instance is functioning correctly\n4. Delete the original multi-interface 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 boot_disk {\n initialize_params {\n image = \"debian-cloud/debian-11\"\n }\n }\n\n # Only one network interface\n network_interface {\n network = \"default\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Configure VM instances with only the **minimum network connectivity** required for their intended purpose. Review instances with multiple network interfaces and consolidate to a single interface unless multi-NIC configuration is explicitly required for network appliance or routing purposes.", + "Url": "https://hub.prowler.com/check/compute_instance_single_network_interface" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "compute_instance_public_ip", + "compute_instance_ip_forwarding_is_enabled" + ], + "Notes": "Instances created by GKE or used as network virtual appliances may legitimately require multiple network interfaces." +} diff --git a/prowler/providers/gcp/services/compute/compute_instance_single_network_interface/compute_instance_single_network_interface.py b/prowler/providers/gcp/services/compute/compute_instance_single_network_interface/compute_instance_single_network_interface.py new file mode 100644 index 0000000000..0fb2a1fdc5 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_instance_single_network_interface/compute_instance_single_network_interface.py @@ -0,0 +1,43 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.compute.compute_client import compute_client + + +class compute_instance_single_network_interface(Check): + """ + Ensure that VM instances have a single network interface. + + This check evaluates whether Compute Engine instances are configured with only + one network interface to minimize network complexity and reduce attack surface. + - PASS: The VM instance has a single network interface. + - MANUAL: The VM instance is a GKE-managed instance with multiple network interfaces + (manual review recommended as these may legitimately require multiple interfaces). + - FAIL: The VM instance has multiple network interfaces (excluding GKE instances). + """ + + 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" + + interface_names = [nic.name for nic in instance.network_interfaces] + interface_count = len(instance.network_interfaces) + + if interface_count == 1: + report.status_extended = f"VM Instance {instance.name} has a single network interface: {interface_names[0]}." + elif interface_count > 1: + # GKE instances may legitimately require multiple network interfaces + if instance.name.startswith("gke-"): + report.status = "MANUAL" + report.status_extended = f"VM Instance {instance.name} has {interface_count} network interfaces: {', '.join(interface_names)}. This is a GKE-managed instance which may legitimately require multiple interfaces. Manual review recommended." + else: + report.status = "FAIL" + report.status_extended = f"VM Instance {instance.name} has {interface_count} network interfaces: {', '.join(interface_names)}." + else: + report.status_extended = ( + f"VM Instance {instance.name} has no network interfaces." + ) + + 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 df171a16d7..efe40dd765 100644 --- a/prowler/providers/gcp/services/compute/compute_service.py +++ b/prowler/providers/gcp/services/compute/compute_service.py @@ -105,10 +105,30 @@ class Compute(GCPService): for instance in response.get("items", []): public_ip = False - for interface in instance.get("networkInterfaces", []): + network_interfaces_raw = instance.get("networkInterfaces", []) + + network_interfaces = [] + for interface in network_interfaces_raw: for config in interface.get("accessConfigs", []): if "natIP" in config: public_ip = True + + network_interfaces.append( + NetworkInterface( + name=interface.get("name", ""), + network=( + interface.get("network", "").split("/")[-1] + if interface.get("network") + else "" + ), + subnetwork=( + interface.get("subnetwork", "").split("/")[-1] + if interface.get("subnetwork") + else "" + ), + ) + ) + self.instances.append( Instance( name=instance["name"], @@ -167,6 +187,7 @@ class Compute(GCPService): deletion_protection=instance.get( "deletionProtection", False ), + network_interfaces=network_interfaces, ) ) @@ -582,6 +603,12 @@ class Compute(GCPService): ) +class NetworkInterface(BaseModel): + name: str + network: str = "" + subnetwork: str = "" + + class Disk(BaseModel): name: str auto_delete: bool = False @@ -608,6 +635,7 @@ class Instance(BaseModel): preemptible: bool = False provisioning_model: str = "STANDARD" deletion_protection: bool = False + network_interfaces: list[NetworkInterface] = [] class Network(BaseModel): diff --git a/tests/providers/gcp/services/compute/compute_instance_single_network_interface/compute_instance_single_network_interface_test.py b/tests/providers/gcp/services/compute/compute_instance_single_network_interface/compute_instance_single_network_interface_test.py new file mode 100644 index 0000000000..413ff7df36 --- /dev/null +++ b/tests/providers/gcp/services/compute/compute_instance_single_network_interface/compute_instance_single_network_interface_test.py @@ -0,0 +1,357 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider + + +class Test_compute_instance_single_network_interface: + def test_compute_no_instances(self): + compute_client = mock.MagicMock() + 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_single_network_interface.compute_instance_single_network_interface.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_single_network_interface.compute_instance_single_network_interface import ( + compute_instance_single_network_interface, + ) + + check = compute_instance_single_network_interface() + result = check.execute() + assert len(result) == 0 + + def test_single_network_interface(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_single_network_interface.compute_instance_single_network_interface.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_single_network_interface.compute_instance_single_network_interface import ( + compute_instance_single_network_interface, + ) + from prowler.providers.gcp.services.compute.compute_service import ( + Instance, + NetworkInterface, + ) + + instance = Instance( + name="test-instance", + id="1234567890", + 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=[ + {"email": "123-compute@developer.gserviceaccount.com"} + ], + ip_forward=False, + disks_encryption=[], + project_id=GCP_PROJECT_ID, + network_interfaces=[ + NetworkInterface( + name="nic0", network="default", subnetwork="default" + ) + ], + ) + + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [instance] + + check = compute_instance_single_network_interface() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "VM Instance test-instance has a single network interface: nic0." + ) + assert result[0].resource_id == "1234567890" + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].resource_name == "test-instance" + assert result[0].location == "us-central1" + + def test_multiple_network_interfaces(self): + from prowler.providers.gcp.services.compute.compute_service import ( + Instance, + NetworkInterface, + ) + + instance = Instance( + name="multi-nic-instance", + id="9876543210", + zone="us-central1-a", + region="us-central1", + public_ip=True, + metadata={}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=False, + service_accounts=[ + {"email": f"{GCP_PROJECT_ID}-compute@developer.gserviceaccount.com"} + ], + ip_forward=False, + disks_encryption=[], + project_id=GCP_PROJECT_ID, + network_interfaces=[ + NetworkInterface(name="nic0", network="default", subnetwork="subnet-1"), + NetworkInterface(name="nic1", network="vpc-2", subnetwork="subnet-2"), + NetworkInterface(name="nic2", network="vpc-3", subnetwork="subnet-3"), + ], + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [instance] + + 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_single_network_interface.compute_instance_single_network_interface.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_single_network_interface.compute_instance_single_network_interface import ( + compute_instance_single_network_interface, + ) + + check = compute_instance_single_network_interface() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "VM Instance multi-nic-instance has 3 network interfaces: nic0, nic1, nic2." + ) + assert result[0].resource_id == "9876543210" + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].resource_name == "multi-nic-instance" + assert result[0].location == "us-central1" + + def test_two_network_interfaces(self): + from prowler.providers.gcp.services.compute.compute_service import ( + Instance, + NetworkInterface, + ) + + instance = Instance( + name="dual-nic-instance", + id="1111111111", + zone="europe-west1-b", + region="europe-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, + network_interfaces=[ + NetworkInterface(name="nic0", network="default", subnetwork="default"), + NetworkInterface(name="nic1", network="vpc-2", subnetwork="subnet-2"), + ], + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [instance] + + 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_single_network_interface.compute_instance_single_network_interface.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_single_network_interface.compute_instance_single_network_interface import ( + compute_instance_single_network_interface, + ) + + check = compute_instance_single_network_interface() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "VM Instance dual-nic-instance has 2 network interfaces: nic0, nic1." + ) + assert result[0].resource_id == "1111111111" + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].resource_name == "dual-nic-instance" + assert result[0].location == "europe-west1" + + def test_mixed_instances(self): + from prowler.providers.gcp.services.compute.compute_service import ( + Instance, + NetworkInterface, + ) + + instance_single_nic = Instance( + name="single-nic-instance", + id="1111111111", + 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, + network_interfaces=[ + NetworkInterface(name="nic0", network="default", subnetwork="default") + ], + ) + + instance_multi_nic = Instance( + name="multi-nic-instance", + id="2222222222", + zone="us-central1-a", + region="us-central1", + 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, + network_interfaces=[ + NetworkInterface(name="nic0", network="default", subnetwork="default"), + NetworkInterface(name="nic1", network="vpc-2", subnetwork="subnet-2"), + NetworkInterface(name="nic2", network="vpc-3", subnetwork="subnet-3"), + NetworkInterface(name="nic3", network="vpc-4", subnetwork="subnet-4"), + ], + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [instance_single_nic, instance_multi_nic] + + 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_single_network_interface.compute_instance_single_network_interface.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_single_network_interface.compute_instance_single_network_interface import ( + compute_instance_single_network_interface, + ) + + check = compute_instance_single_network_interface() + result = check.execute() + + assert len(result) == 2 + + # First instance: single NIC (PASS) + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "VM Instance single-nic-instance has a single network interface: nic0." + ) + assert result[0].resource_id == "1111111111" + assert result[0].resource_name == "single-nic-instance" + + # Second instance: multiple NICs (FAIL) + assert result[1].status == "FAIL" + assert ( + result[1].status_extended + == "VM Instance multi-nic-instance has 4 network interfaces: nic0, nic1, nic2, nic3." + ) + assert result[1].resource_id == "2222222222" + assert result[1].resource_name == "multi-nic-instance" + + def test_gke_instance_multiple_network_interfaces(self): + from prowler.providers.gcp.services.compute.compute_service import ( + Instance, + NetworkInterface, + ) + + instance = Instance( + name="gke-cluster-default-pool-12345678-abcd", + id="9999999999", + 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, + network_interfaces=[ + NetworkInterface( + name="nic0", network="gke-network", subnetwork="gke-subnet" + ), + NetworkInterface( + name="nic1", network="gke-network-2", subnetwork="gke-subnet-2" + ), + ], + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [instance] + + 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_single_network_interface.compute_instance_single_network_interface.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_single_network_interface.compute_instance_single_network_interface import ( + compute_instance_single_network_interface, + ) + + check = compute_instance_single_network_interface() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == "VM Instance gke-cluster-default-pool-12345678-abcd has 2 network interfaces: nic0, nic1. This is a GKE-managed instance which may legitimately require multiple interfaces. Manual review recommended." + ) + assert result[0].resource_id == "9999999999" + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].resource_name == "gke-cluster-default-pool-12345678-abcd" + assert result[0].location == "us-central1" diff --git a/tests/providers/gcp/services/compute/compute_service_test.py b/tests/providers/gcp/services/compute/compute_service_test.py index 697519f2d1..28a3466a5d 100644 --- a/tests/providers/gcp/services/compute/compute_service_test.py +++ b/tests/providers/gcp/services/compute/compute_service_test.py @@ -60,6 +60,7 @@ class TestComputeService: assert not compute_client.instances[0].automatic_restart assert not compute_client.instances[0].preemptible assert compute_client.instances[0].provisioning_model == "STANDARD" + assert len(compute_client.instances[0].network_interfaces) == 1 assert compute_client.instances[1].name == "instance2" assert compute_client.instances[1].id.__class__.__name__ == "str" @@ -84,6 +85,7 @@ class TestComputeService: assert not compute_client.instances[1].automatic_restart assert not compute_client.instances[1].preemptible assert compute_client.instances[1].provisioning_model == "STANDARD" + assert len(compute_client.instances[1].network_interfaces) == 0 assert len(compute_client.networks) == 3 assert compute_client.networks[0].name == "network1"