diff --git a/prowler/providers/huaweicloud/models.py b/prowler/providers/huaweicloud/models.py index 38cb1acaab..2e03ad7101 100644 --- a/prowler/providers/huaweicloud/models.py +++ b/prowler/providers/huaweicloud/models.py @@ -384,6 +384,21 @@ class HuaweiCloudSession: .build() ) + elif service == "functiongraph": + from huaweicloudsdkfunctiongraph.v2 import FunctionGraphClient + from huaweicloudsdkfunctiongraph.v2.region.functiongraph_region import ( + FunctionGraphRegion, + ) + + client_region = region or self._region + return ( + FunctionGraphClient.new_builder() + .with_credentials(self._get_basic_credentials(client_region)) + .with_http_config(self._http_config()) + .with_region(_aligned_region(FunctionGraphRegion, client_region)) + .build() + ) + else: raise HuaweiCloudServiceError( message=f"Huawei Cloud service '{service}' is not supported" diff --git a/prowler/providers/huaweicloud/services/functiongraph/functiongraph_function_vpc_configured/functiongraph_function_vpc_configured.py b/prowler/providers/huaweicloud/services/functiongraph/functiongraph_function_vpc_configured/functiongraph_function_vpc_configured.py index 5e92d85bd8..a836023387 100644 --- a/prowler/providers/huaweicloud/services/functiongraph/functiongraph_function_vpc_configured/functiongraph_function_vpc_configured.py +++ b/prowler/providers/huaweicloud/services/functiongraph/functiongraph_function_vpc_configured/functiongraph_function_vpc_configured.py @@ -14,16 +14,15 @@ class functiongraph_function_vpc_configured(Check): metadata=self.metadata(), resource=function, ) - report.region = function.region - report.resource_id = function.function_id - report.resource_arn = f"huaweicloud:functiongraph:{function.region}:{functiongraph_client.audited_account}:function/{function.function_id}" - if function.func_vpc_id: + if function.vpc_id: report.status = "PASS" - report.status_extended = f"Function '{function.name}' is configured within VPC '{function.func_vpc_id}'." + report.status_extended = f"Function '{function.name}' is configured within VPC '{function.vpc_id}'." else: report.status = "FAIL" - report.status_extended = f"Function '{function.name}' is not associated with a VPC and has direct internet access without network restrictions." + report.status_extended = ( + f"Function '{function.name}' is not configured within a VPC." + ) findings.append(report) diff --git a/prowler/providers/huaweicloud/services/functiongraph/functiongraph_service.py b/prowler/providers/huaweicloud/services/functiongraph/functiongraph_service.py index b9802cbd35..afde9bf567 100644 --- a/prowler/providers/huaweicloud/services/functiongraph/functiongraph_service.py +++ b/prowler/providers/huaweicloud/services/functiongraph/functiongraph_service.py @@ -1,9 +1,9 @@ from typing import List, Optional -from pydantic.v1 import BaseModel - from prowler.lib.logger import logger +from prowler.lib.scan_filters.scan_filters import is_resource_filtered from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService +from prowler.providers.huaweicloud.models import HuaweiCloudBaseModel class FunctionGraph(HuaweiCloudService): @@ -19,77 +19,93 @@ class FunctionGraph(HuaweiCloudService): self.functions: List[FunctionGraphFunction] = [] - if self.session.is_mock: + if getattr(self.session, "is_mock", False): self._load_mock_data() return - self._list_functions() + self.__threading_call__(self._list_functions) def _load_mock_data(self): """Load mock data for testing.""" region = "la-south-2" self.functions = [ FunctionGraphFunction( - function_id="fg-mock-001", + id="fg-mock-001", name="function-secure", + arn="urn:fss:la-south-2:project:function:default:function-secure:latest", runtime="Python3.9", timeout=30, memory_size=128, - func_vpc_id="vpc-12345", + vpc_id="vpc-12345", region=region, ), FunctionGraphFunction( - function_id="fg-mock-002", + id="fg-mock-002", name="function-insecure", + arn="urn:fss:la-south-2:project:function:default:function-insecure:latest", runtime="Python3.9", timeout=30, memory_size=128, - func_vpc_id=None, + vpc_id=None, region=region, ), ] - def _list_functions(self): - """List all FunctionGraph functions across regions.""" - if not self.regional_clients: - return + def _list_functions(self, regional_client): + """List every FunctionGraph function in one region.""" + region = getattr(regional_client, "region", "unknown") + logger.info(f"FunctionGraph - Listing Functions in {region}...") + discovered_functions = [] + marker = None - for region, client in self.regional_clients.items(): - logger.info(f"FunctionGraph - Listing Functions in {region}...") + try: + from huaweicloudsdkfunctiongraph.v2 import ListFunctionsRequest - try: - from huaweicloudsdkfunctiongraph.v2 import ListFunctionsRequest - - request = ListFunctionsRequest() - response = self._call_with_retries(client.list_functions, request) - - if response and response.functions: - for func in response.functions: - self.functions.append( - FunctionGraphFunction( - function_id=getattr(func, "resource_id", ""), - name=getattr(func, "func_name", ""), - runtime=getattr(func, "runtime", ""), - timeout=getattr(func, "timeout", 0), - memory_size=getattr(func, "memory_size", 0), - func_vpc_id=getattr(func, "func_vpc_id", None), - region=region, - ) - ) - - except Exception as error: - logger.error( - f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + while True: + request = ListFunctionsRequest(marker=marker, maxitems="400") + response = self._call_with_retries( + regional_client.list_functions, request ) + for function in getattr(response, "functions", None) or []: + resource_id = getattr(function, "resource_id", None) or "" + if self.audit_resources and not is_resource_filtered( + resource_id, self.audit_resources + ): + continue + discovered_functions.append( + FunctionGraphFunction( + id=resource_id, + name=getattr(function, "func_name", None) or resource_id, + arn=getattr(function, "func_urn", None) or "", + runtime=getattr(function, "runtime", None) or "", + timeout=getattr(function, "timeout", None) or 0, + memory_size=getattr(function, "memory_size", None) or 0, + vpc_id=getattr(function, "func_vpc_id", None), + region=region, + ) + ) -class FunctionGraphFunction(BaseModel): + next_marker = getattr(response, "next_marker", None) + if not next_marker or str(next_marker) == marker: + break + marker = str(next_marker) + + self.functions.extend(discovered_functions) + except Exception as error: + logger.error( + f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class FunctionGraphFunction(HuaweiCloudBaseModel): """FunctionGraph function model.""" - function_id: str + id: str name: str = "" + arn: str = "" runtime: str = "" timeout: int = 0 memory_size: int = 0 - func_vpc_id: Optional[str] = None + vpc_id: Optional[str] = None region: str = "" diff --git a/pyproject.toml b/pyproject.toml index 601ac0a02c..9000c5fec6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,6 +123,7 @@ dependencies = [ "huaweicloudsdkecs==3.1.204", "huaweicloudsdkelb==3.1.204", "huaweicloudsdkevs==3.1.204", + "huaweicloudsdkfunctiongraph==3.1.204", "huaweicloudsdkiam==3.1.204", "huaweicloudsdkkms==3.1.204", "huaweicloudsdkobs==3.1.204", @@ -260,6 +261,7 @@ constraint-dependencies = [ "huaweicloudsdkecs==3.1.204", "huaweicloudsdkelb==3.1.204", "huaweicloudsdkevs==3.1.204", + "huaweicloudsdkfunctiongraph==3.1.204", "huaweicloudsdkiam==3.1.204", "huaweicloudsdkkms==3.1.204", "huaweicloudsdkobs==3.1.204", diff --git a/tests/providers/huaweicloud/huaweicloud_provider_test.py b/tests/providers/huaweicloud/huaweicloud_provider_test.py index 6efb94eb66..5d180a4afc 100644 --- a/tests/providers/huaweicloud/huaweicloud_provider_test.py +++ b/tests/providers/huaweicloud/huaweicloud_provider_test.py @@ -50,6 +50,31 @@ class TestHuaweiCloudProviderSetupSession: session = HuaweicloudProvider.setup_session() assert session.get_credentials().ak == ACCESS_KEY + def test_creates_functiongraph_client_for_europe(self): + session = HuaweiCloudSession( + HuaweiCloudCredentials(ak=ACCESS_KEY, sk=SECRET_KEY) + ) + functiongraph_client = mock.MagicMock() + builder = mock.MagicMock() + builder.with_credentials.return_value.with_http_config.return_value.with_region.return_value.build.return_value = ( + functiongraph_client + ) + + with mock.patch( + "huaweicloudsdkfunctiongraph.v2.FunctionGraphClient.new_builder", + return_value=builder, + ): + client = session.client("functiongraph", "eu-west-101") + + assert client is functiongraph_client + region = builder.with_credentials.return_value.with_http_config.return_value.with_region.call_args.args[ + 0 + ] + assert region.id == "eu-west-101" + assert region.endpoints == [ + "https://functiongraph.eu-west-101.myhuaweicloud.eu" + ] + class TestHuaweiCloudProviderValidateCredentials: def test_resolves_caller_identity_from_iam(self): diff --git a/tests/providers/huaweicloud/services/functiongraph/functiongraph_function_vpc_configured/functiongraph_function_vpc_configured_test.py b/tests/providers/huaweicloud/services/functiongraph/functiongraph_function_vpc_configured/functiongraph_function_vpc_configured_test.py index 88b600a2b9..d3f3ceea3f 100644 --- a/tests/providers/huaweicloud/services/functiongraph/functiongraph_function_vpc_configured/functiongraph_function_vpc_configured_test.py +++ b/tests/providers/huaweicloud/services/functiongraph/functiongraph_function_vpc_configured/functiongraph_function_vpc_configured_test.py @@ -28,12 +28,13 @@ class Test_functiongraph_function_vpc_configured: functiongraph_client.functions = [ FunctionGraphFunction( - function_id="fg-001", + id="fg-001", name="function-secure", + arn="urn:fss:la-south-2:project:function:default:function-secure:latest", runtime="Python3.9", timeout=30, memory_size=128, - func_vpc_id="vpc-12345", + vpc_id="vpc-12345", region="la-south-2", ), ] @@ -45,6 +46,8 @@ class Test_functiongraph_function_vpc_configured: assert len(results) == 1 assert results[0].status == "PASS" assert results[0].resource_id == "fg-001" + assert results[0].resource_name == "function-secure" + assert results[0].resource_arn.endswith("function-secure:latest") assert "configured within VPC" in results[0].status_extended def test_functiongraph_vpc_configured_fail(self): @@ -69,12 +72,13 @@ class Test_functiongraph_function_vpc_configured: functiongraph_client.functions = [ FunctionGraphFunction( - function_id="fg-002", + id="fg-002", name="function-insecure", + arn="urn:fss:la-south-2:project:function:default:function-insecure:latest", runtime="Python3.9", timeout=30, memory_size=128, - func_vpc_id=None, + vpc_id=None, region="la-south-2", ), ] @@ -86,7 +90,7 @@ class Test_functiongraph_function_vpc_configured: assert len(results) == 1 assert results[0].status == "FAIL" assert results[0].resource_id == "fg-002" - assert "not associated with a VPC" in results[0].status_extended + assert "not configured within a VPC" in results[0].status_extended def test_functiongraph_vpc_configured_mixed(self): functiongraph_client = mock.MagicMock() @@ -110,21 +114,23 @@ class Test_functiongraph_function_vpc_configured: functiongraph_client.functions = [ FunctionGraphFunction( - function_id="fg-001", + id="fg-001", name="function-secure", + arn="urn:fss:la-south-2:project:function:default:function-secure:latest", runtime="Python3.9", timeout=30, memory_size=128, - func_vpc_id="vpc-12345", + vpc_id="vpc-12345", region="la-south-2", ), FunctionGraphFunction( - function_id="fg-002", + id="fg-002", name="function-insecure", + arn="urn:fss:la-south-2:project:function:default:function-insecure:latest", runtime="Python3.9", timeout=30, memory_size=128, - func_vpc_id=None, + vpc_id=None, region="la-south-2", ), ] diff --git a/tests/providers/huaweicloud/services/functiongraph/huaweicloud_functiongraph_service_test.py b/tests/providers/huaweicloud/services/functiongraph/huaweicloud_functiongraph_service_test.py new file mode 100644 index 0000000000..c82d414605 --- /dev/null +++ b/tests/providers/huaweicloud/services/functiongraph/huaweicloud_functiongraph_service_test.py @@ -0,0 +1,129 @@ +from types import SimpleNamespace +from unittest import mock + +from prowler.providers.huaweicloud.services.functiongraph.functiongraph_service import ( + FunctionGraph, +) +from tests.providers.huaweicloud.huaweicloud_fixtures import ( + set_mocked_huaweicloud_provider, +) + + +class TestFunctionGraphService: + def test_real_session_lists_functions(self): + regional_client = mock.MagicMock(region="eu-west-101") + regional_client.list_functions.return_value = SimpleNamespace( + functions=[], next_marker=None + ) + provider = set_mocked_huaweicloud_provider() + provider.session = SimpleNamespace(client=mock.MagicMock()) + provider.generate_regional_clients = mock.MagicMock( + return_value={"eu-west-101": regional_client} + ) + provider.get_default_region = mock.MagicMock(return_value="eu-west-101") + + service = FunctionGraph(provider) + + assert service.functions == [] + regional_client.list_functions.assert_called_once() + + def test_lists_every_page_and_maps_sdk_fields(self): + first_function = SimpleNamespace( + resource_id="resource-1", + func_urn="urn:fss:eu-west-101:project-1:function:default:first:latest", + func_name="first", + runtime=None, + timeout=None, + memory_size=None, + func_vpc_id=None, + ) + second_function = SimpleNamespace( + resource_id="resource-2", + func_urn="urn:fss:eu-west-101:project-1:function:default:second:latest", + func_name="second", + runtime="Python3.9", + timeout=30, + memory_size=128, + func_vpc_id="vpc-1", + ) + regional_client = mock.MagicMock(region="eu-west-101") + regional_client.list_functions.side_effect = [ + SimpleNamespace(functions=[first_function], next_marker=400), + SimpleNamespace(functions=[second_function], next_marker=None), + ] + service = FunctionGraph.__new__(FunctionGraph) + service.functions = [] + service.audit_resources = [] + service._call_with_retries = mock.MagicMock( + side_effect=regional_client.list_functions.side_effect + ) + + service._list_functions(regional_client) + + assert [function.id for function in service.functions] == [ + "resource-1", + "resource-2", + ] + assert service.functions[0].arn == first_function.func_urn + assert service.functions[0].runtime == "" + assert service.functions[0].timeout == 0 + assert service.functions[0].memory_size == 0 + assert service.functions[0].vpc_id is None + assert service.functions[1].vpc_id == "vpc-1" + assert service._call_with_retries.call_count == 2 + first_request = service._call_with_retries.call_args_list[0].args[1] + second_request = service._call_with_retries.call_args_list[1].args[1] + assert first_request.maxitems == "400" + assert first_request.marker is None + assert second_request.marker == "400" + + def test_api_error_does_not_fabricate_functions(self): + function = SimpleNamespace( + resource_id="resource-1", + func_urn="urn:fss:eu-west-101:project-1:function:default:first:latest", + func_name="first", + runtime="Python3.9", + timeout=30, + memory_size=128, + func_vpc_id=None, + ) + regional_client = mock.MagicMock(region="eu-west-101") + service = FunctionGraph.__new__(FunctionGraph) + service.functions = [] + service.audit_resources = [] + service._call_with_retries = mock.MagicMock( + side_effect=[ + SimpleNamespace(functions=[function], next_marker=400), + Exception("boom"), + ] + ) + + service._list_functions(regional_client) + + assert service.functions == [] + + def test_repeated_marker_stops_pagination(self): + function = SimpleNamespace( + resource_id="resource-1", + func_urn="urn:fss:eu-west-101:project-1:function:default:first:latest", + func_name="first", + runtime="Python3.9", + timeout=30, + memory_size=128, + func_vpc_id=None, + ) + regional_client = mock.MagicMock(region="eu-west-101") + service = FunctionGraph.__new__(FunctionGraph) + service.functions = [] + service.audit_resources = [] + service._call_with_retries = mock.MagicMock( + side_effect=[ + SimpleNamespace(functions=[function], next_marker=400), + SimpleNamespace(functions=[], next_marker=400), + ] + ) + + service._list_functions(regional_client) + + assert [item.id for item in service.functions] == ["resource-1"] + assert service._call_with_retries.call_count == 2 diff --git a/uv.lock b/uv.lock index ebed613212..79aa9cb8af 100644 --- a/uv.lock +++ b/uv.lock @@ -100,6 +100,7 @@ constraints = [ { name = "huaweicloudsdkecs", specifier = "==3.1.204" }, { name = "huaweicloudsdkelb", specifier = "==3.1.204" }, { name = "huaweicloudsdkevs", specifier = "==3.1.204" }, + { name = "huaweicloudsdkfunctiongraph", specifier = "==3.1.204" }, { name = "huaweicloudsdkiam", specifier = "==3.1.204" }, { name = "huaweicloudsdkkms", specifier = "==3.1.204" }, { name = "huaweicloudsdkobs", specifier = "==3.1.204" }, @@ -2310,6 +2311,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/cf/531dc55fd9d0f3bbd3eef24c7e4d78c6a1ba8eb80fa506e3574d72bcc98a/huaweicloudsdkevs-3.1.204-py3-none-any.whl", hash = "sha256:9118ac4c576e54aa7eaa926949e2b6824c5f038a2274b51d9a304d37fc0d7e2f", size = 251404, upload-time = "2026-07-09T09:02:50.05Z" }, ] +[[package]] +name = "huaweicloudsdkfunctiongraph" +version = "3.1.204" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huaweicloudsdkcore" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/10/19e2f8a0f15aa6c3c1b2f67eb58e5d06aac0b3eef527283624acfd946b0a/huaweicloudsdkfunctiongraph-3.1.204-py3-none-any.whl", hash = "sha256:0999e57f56b157817c9d8d68940279feff6c1170693e2f41eef21a1fd12a7de9", size = 556873, upload-time = "2026-07-09T09:02:52.402Z" }, +] + [[package]] name = "huaweicloudsdkiam" version = "3.1.204" @@ -3753,6 +3765,7 @@ dependencies = [ { name = "huaweicloudsdkecs" }, { name = "huaweicloudsdkelb" }, { name = "huaweicloudsdkevs" }, + { name = "huaweicloudsdkfunctiongraph" }, { name = "huaweicloudsdkiam" }, { name = "huaweicloudsdkkms" }, { name = "huaweicloudsdkobs" }, @@ -3873,6 +3886,7 @@ requires-dist = [ { name = "huaweicloudsdkecs", specifier = "==3.1.204" }, { name = "huaweicloudsdkelb", specifier = "==3.1.204" }, { name = "huaweicloudsdkevs", specifier = "==3.1.204" }, + { name = "huaweicloudsdkfunctiongraph", specifier = "==3.1.204" }, { name = "huaweicloudsdkiam", specifier = "==3.1.204" }, { name = "huaweicloudsdkkms", specifier = "==3.1.204" }, { name = "huaweicloudsdkobs", specifier = "==3.1.204" },