From 5ec4a1cbbab56fe1c0ab7ddf3e3047d2c26cede7 Mon Sep 17 00:00:00 2001 From: s1ns3nz0 Date: Thu, 18 Jun 2026 00:35:32 +0900 Subject: [PATCH] feat(gcp): add cloudfunction_function_inside_vpc check (#11021) Co-authored-by: Lydia Vilchez --- prowler/CHANGELOG.md | 1 + .../gcp/services/cloudfunction/__init__.py | 0 .../cloudfunction/cloudfunction_client.py | 6 + .../__init__.py | 0 ...function_function_inside_vpc.metadata.json | 40 ++++ .../cloudfunction_function_inside_vpc.py | 39 ++++ .../cloudfunction/cloudfunction_service.py | 84 ++++++++ .../gcp/services/cloudfunction/__init__.py | 0 .../__init__.py | 0 .../cloudfunction_function_inside_vpc_test.py | 197 ++++++++++++++++++ .../cloudfunction_service_test.py | 102 +++++++++ 11 files changed, 469 insertions(+) create mode 100644 prowler/providers/gcp/services/cloudfunction/__init__.py create mode 100644 prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py create mode 100644 prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py create mode 100644 prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json create mode 100644 prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py create mode 100644 prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py create mode 100644 tests/providers/gcp/services/cloudfunction/__init__.py create mode 100644 tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py create mode 100644 tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py create mode 100644 tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 14fa976490..ada7536290 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. - `config_delegated_admin_and_org_aggregator_all_regions` check for AWS provider, verifying that AWS Config has a delegated administrator and an organization aggregator covering all AWS regions [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) - `sagemaker_clarify_exists` check for AWS provider [(#11211)](https://github.com/prowler-cloud/prowler/pull/11211) - `cloudsql_instance_high_availability_enabled` check for GCP provider, verifying Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover [(#11024)](https://github.com/prowler-cloud/prowler/pull/11024) +- `cloudfunction_function_inside_vpc` check for GCP provider, verifying Cloud Functions have a Serverless VPC Access connector for private egress [(#11021)](https://github.com/prowler-cloud/prowler/pull/11021) - `identity_storage_service_level_admins_scoped` check for OCI provider CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions [(#11523)](https://github.com/prowler-cloud/prowler/pull/11523) - `cosmosdb_account_automatic_failover_enabled` check for Azure provider [(#11031)](https://github.com/prowler-cloud/prowler/pull/11031) - `cosmosdb_account_backup_policy_continuous` check for Azure provider [(#11032)](https://github.com/prowler-cloud/prowler/pull/11032) diff --git a/prowler/providers/gcp/services/cloudfunction/__init__.py b/prowler/providers/gcp/services/cloudfunction/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py new file mode 100644 index 0000000000..a252da1be7 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + CloudFunction, +) + +cloudfunction_client = CloudFunction(Provider.get_global_provider()) diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json new file mode 100644 index 0000000000..72c908bf80 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "cloudfunction_function_inside_vpc", + "CheckTitle": "Cloud Function is connected to a VPC network", + "CheckType": [], + "ServiceName": "cloudfunction", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "cloudfunctions.googleapis.com/Function", + "Description": "Cloud Functions are attached to a **Serverless VPC Access connector** so egress traffic is routed through a private VPC network instead of the public internet.\n\nThe evaluation reviews each function's network configuration to confirm that a connector is configured.", + "Risk": "Without a VPC connector, Cloud Functions cannot privately reach internal resources such as `Cloud SQL`, `Memorystore`, or `GKE`, forcing those services to be exposed over public IPs. This expands the **attack surface**, weakens **confidentiality** of internal traffic, and breaks network segmentation controls required by most security frameworks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/functions/docs/networking/connecting-vpc", + "https://cloud.google.com/vpc/docs/serverless-vpc-access" + ], + "Remediation": { + "Code": { + "CLI": "gcloud functions deploy --region= --vpc-connector=projects//locations//connectors/ --egress-settings=all-traffic", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Cloud Functions\n2. Select the function and click Edit\n3. Under Connections, select the VPC connector for your network\n4. Set Egress settings to route all traffic through the VPC connector\n5. Save and redeploy the function", + "Terraform": "```hcl\nresource \"google_cloudfunctions2_function\" \"\" {\n name = \"\"\n location = \"us-central1\"\n\n service_config {\n vpc_connector = \"\" # Critical: routes egress through the VPC\n vpc_connector_egress_settings = \"ALL_TRAFFIC\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Apply **defense in depth** by routing Cloud Function egress through a **Serverless VPC Access connector** when the function must reach internal resources.\n\nScope each connector to **least privilege** subnets so functions cannot reach unintended endpoints.", + "Url": "https://hub.prowler.com/check/cloudfunction_function_inside_vpc" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudfunction_function_not_publicly_accessible", + "cloudsql_instance_public_ip", + "compute_instance_public_ip" + ], + "Notes": "A VPC connector must be created in the same region as the Cloud Function. This check only verifies that a connector is attached; it does not validate egress settings or connector configuration." +} diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py new file mode 100644 index 0000000000..46866b350d --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudfunction.cloudfunction_client import ( + cloudfunction_client, +) + + +class cloudfunction_function_inside_vpc(Check): + """Check that Cloud Functions are attached to a Serverless VPC Access connector. + + Verifies that each active Cloud Function has a `vpcConnector` configured so + egress traffic flows through a private VPC network instead of the public + internet. Functions in non-`ACTIVE` states are skipped because their network + configuration is transient. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the VPC-connector check across all Cloud Functions. + + Returns: + A list of `Check_Report_GCP` findings, one per active Cloud + Function. Status is `PASS` when a `vpc_connector` is set and `FAIL` + otherwise. + """ + findings = [] + for function in cloudfunction_client.functions: + if function.state != "ACTIVE": + continue + report = Check_Report_GCP(metadata=self.metadata(), resource=function) + if function.vpc_connector: + report.status = "PASS" + report.status_extended = ( + f"Cloud Function {function.name} is connected to a VPC via " + f"connector: {function.vpc_connector}." + ) + else: + report.status = "FAIL" + report.status_extended = f"Cloud Function {function.name} is not connected to any VPC network." + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py new file mode 100644 index 0000000000..d1043151b7 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py @@ -0,0 +1,84 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS +from prowler.providers.gcp.gcp_provider import GcpProvider +from prowler.providers.gcp.lib.service.service import GCPService + + +class CloudFunction(GCPService): + """Cloud Functions v2 service client. + + Enumerates Cloud Functions across every accessible project and region + using the `cloudfunctions.googleapis.com` v2 API and exposes them through + the `functions` attribute. + """ + + def __init__(self, provider: GcpProvider) -> None: + """Initialize the service and preload Cloud Functions.""" + super().__init__("cloudfunctions", provider, api_version="v2") + self.functions = [] + self._get_functions() + + def _get_functions(self) -> None: + """Fetch Cloud Functions for every project and location.""" + for project_id in self.project_ids: + try: + locations = self.client.projects().locations() + locations_request = locations.list(name=f"projects/{project_id}") + while locations_request is not None: + locations_response = locations_request.execute( + num_retries=DEFAULT_RETRY_ATTEMPTS + ) + for location in locations_response.get("locations", []): + location_id = location["locationId"] + try: + functions = locations.functions() + request = functions.list( + parent=f"projects/{project_id}/locations/{location_id}" + ) + while request is not None: + response = request.execute( + num_retries=DEFAULT_RETRY_ATTEMPTS + ) + for fn in response.get("functions", []): + service_config = fn.get("serviceConfig", {}) + self.functions.append( + Function( + name=fn["name"].split("/")[-1], + project_id=project_id, + location=location_id, + state=fn.get("state", "UNKNOWN"), + vpc_connector=service_config.get( + "vpcConnector" + ), + ) + ) + request = functions.list_next( + previous_request=request, + previous_response=response, + ) + except Exception as error: + logger.error( + f"{location_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + locations_request = locations.list_next( + previous_request=locations_request, + previous_response=locations_response, + ) + except Exception as error: + logger.error( + f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Function(BaseModel): + """Cloud Function resource consumed by GCP checks.""" + + name: str + project_id: str + location: str + state: str + vpc_connector: Optional[str] = None diff --git a/tests/providers/gcp/services/cloudfunction/__init__.py b/tests/providers/gcp/services/cloudfunction/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py new file mode 100644 index 0000000000..b764760538 --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py @@ -0,0 +1,197 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.cloudfunction." + "cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc" +) +_CLIENT_PATH = f"{_CHECK_PATH}.cloudfunction_client" + + +class Test_cloudfunction_function_inside_vpc: + def test_no_functions(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + + cloudfunction_client.functions = [] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 0 + + def test_function_with_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + connector = ( + f"projects/{GCP_PROJECT_ID}/locations/{GCP_US_CENTER1_LOCATION}" + f"/connectors/my-connector" + ) + cloudfunction_client.functions = [ + Function( + name="fn-vpc", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector=connector, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Cloud Function fn-vpc is connected to a VPC via connector: {connector}." + ) + assert result[0].resource_id == "fn-vpc" + assert result[0].resource_name == "fn-vpc" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_without_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + name="fn-public", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector=None, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cloud Function fn-public is not connected to any VPC network." + ) + assert result[0].resource_id == "fn-public" + assert result[0].resource_name == "fn-public" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_with_empty_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + name="fn-empty", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector="", + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_inactive_function_skipped(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + name="fn-deploy", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="DEPLOYING", + vpc_connector=None, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py new file mode 100644 index 0000000000..82f77a939f --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py @@ -0,0 +1,102 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + CloudFunction, +) +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + +_LOCATION_ID = "us-central1" +_FUNCTION_NAME = "my-function" +_CONNECTOR = ( + f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/connectors/my-connector" +) + + +def _make_cloudfunction_client(functions_list): + """Return a mock GCP API client for the Cloud Functions v2 service.""" + client = MagicMock() + + client.projects().locations().list().execute.return_value = { + "locations": [{"locationId": _LOCATION_ID}] + } + client.projects().locations().list_next.return_value = None + + client.projects().locations().functions().list().execute.return_value = { + "functions": functions_list + } + client.projects().locations().functions().list_next.return_value = None + + return client + + +class TestCloudFunctionService: + def test_get_functions_with_vpc_connector(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/{_FUNCTION_NAME}", + "state": "ACTIVE", + "serviceConfig": { + "vpcConnector": _CONNECTOR, + }, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + fn = cf_client.functions[0] + assert fn.name == _FUNCTION_NAME + assert fn.project_id == GCP_PROJECT_ID + assert fn.location == _LOCATION_ID + assert fn.state == "ACTIVE" + assert fn.vpc_connector == _CONNECTOR + + def test_get_functions_without_vpc_connector(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/no-vpc-func", + "state": "ACTIVE", + "serviceConfig": {}, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + fn = cf_client.functions[0] + assert fn.name == "no-vpc-func" + assert fn.vpc_connector is None