diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index e6366fb798..834d99fe80 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -297,14 +297,26 @@ class Provider(ABC): raise ImportError( f"Provider '{arguments.provider}' not found as built-in or entry point" ) - provider_class_name = f"{arguments.provider.capitalize()}Provider" + + # Kept for downstream forks that may extend the dispatch below + # with their own custom built-in branches and reference this name. + # The upstream chain dispatches by `arguments.provider` directly. + provider_class_name = ( + f"{arguments.provider.capitalize()}Provider" # noqa: F841 + ) fixer_config = load_and_validate_config_file( arguments.provider, arguments.fixer_config ) + # Dispatch by exact provider name (equality, not substring) so + # external plug-ins whose names contain a built-in substring + # (e.g. `awsx`, `azure_gov`, `iac_v2`) cannot be silently routed + # to the wrong built-in branch. Anything that doesn't match a + # built-in falls through to the dynamic else and uses the + # contract's `from_cli_args`. if not isinstance(Provider._global, provider_class): - if "aws" in provider_class_name.lower(): + if arguments.provider == "aws": excluded_regions = ( set(arguments.excluded_region) if getattr(arguments, "excluded_region", None) @@ -328,7 +340,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "azure" in provider_class_name.lower(): + elif arguments.provider == "azure": provider_class( az_cli_auth=arguments.az_cli_auth, sp_env_auth=arguments.sp_env_auth, @@ -341,7 +353,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "gcp" in provider_class_name.lower(): + elif arguments.provider == "gcp": provider_class( retries_max_attempts=arguments.gcp_retries_max_attempts, organization_id=arguments.organization_id, @@ -355,7 +367,7 @@ class Provider(ABC): fixer_config=fixer_config, skip_api_check=arguments.skip_api_check, ) - elif "kubernetes" in provider_class_name.lower(): + elif arguments.provider == "kubernetes": provider_class( kubeconfig_file=arguments.kubeconfig_file, context=arguments.context, @@ -365,7 +377,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "m365" in provider_class_name.lower(): + elif arguments.provider == "m365": provider_class( region=arguments.region, config_path=arguments.config_file, @@ -379,7 +391,7 @@ class Provider(ABC): init_modules=arguments.init_modules, fixer_config=fixer_config, ) - elif "nhn" in provider_class_name.lower(): + elif arguments.provider == "nhn": provider_class( username=arguments.nhn_username, password=arguments.nhn_password, @@ -388,7 +400,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "github" in provider_class_name.lower(): + elif arguments.provider == "github": orgs = [] repos = [] @@ -415,13 +427,13 @@ class Provider(ABC): repo_list_file=getattr(arguments, "repo_list_file", None), organizations=orgs, ) - elif "googleworkspace" in provider_class_name.lower(): + elif arguments.provider == "googleworkspace": provider_class( config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "cloudflare" in provider_class_name.lower(): + elif arguments.provider == "cloudflare": provider_class( filter_zones=arguments.region, filter_accounts=arguments.account_id, @@ -429,7 +441,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "iac" in provider_class_name.lower(): + elif arguments.provider == "iac": provider_class( scan_path=arguments.scan_path, scan_repository_url=arguments.scan_repository_url, @@ -442,13 +454,13 @@ class Provider(ABC): oauth_app_token=arguments.oauth_app_token, provider_uid=arguments.provider_uid, ) - elif "llm" in provider_class_name.lower(): + elif arguments.provider == "llm": provider_class( max_concurrency=arguments.max_concurrency, config_path=arguments.config_file, fixer_config=fixer_config, ) - elif "image" in provider_class_name.lower(): + elif arguments.provider == "image": provider_class( images=arguments.images, image_list_file=arguments.image_list_file, @@ -466,7 +478,7 @@ class Provider(ABC): registry_insecure=arguments.registry_insecure, registry_list_images=arguments.registry_list_images, ) - elif "mongodbatlas" in provider_class_name.lower(): + elif arguments.provider == "mongodbatlas": provider_class( atlas_public_key=arguments.atlas_public_key, atlas_private_key=arguments.atlas_private_key, @@ -475,7 +487,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "oraclecloud" in provider_class_name.lower(): + elif arguments.provider == "oraclecloud": provider_class( oci_config_file=arguments.oci_config_file, profile=arguments.profile, @@ -486,7 +498,7 @@ class Provider(ABC): fixer_config=fixer_config, use_instance_principal=arguments.use_instance_principal, ) - elif "openstack" in provider_class_name.lower(): + elif arguments.provider == "openstack": provider_class( clouds_yaml_file=getattr(arguments, "clouds_yaml_file", None), clouds_yaml_content=getattr( @@ -511,7 +523,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "alibabacloud" in provider_class_name.lower(): + elif arguments.provider == "alibabacloud": provider_class( role_arn=arguments.role_arn, role_session_name=arguments.role_session_name, @@ -523,7 +535,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "vercel" in provider_class_name.lower(): + elif arguments.provider == "vercel": provider_class( projects=getattr(arguments, "project", None), config_path=arguments.config_file, diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py index baa939fbcf..653389758c 100644 --- a/tests/providers/external/test_dynamic_provider_loading.py +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -595,6 +595,53 @@ class TestProviderInitialization: assert isinstance(Provider._global, FakePureContractProvider) Provider._global = None + @pytest.mark.parametrize( + "plugin_name", + [ + "awsx", + "aws_lite", + "azure_gov", + "gcp_org", + "github_enterprise", + "iac_v2", + ], + ) + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_external_with_builtin_substring_uses_from_cli_args( + self, mock_import, mock_load_ep, mock_config, plugin_name + ): + """Regression guard for the substring footgun in the dispatch chain. + + An external plug-in whose name contains a built-in substring + (e.g. `awsx`, `aws_lite`, `azure_gov`, `gcp_org`, `github_enterprise`, + `iac_v2`) MUST be routed to the dynamic else and instantiated via + `from_cli_args` — not silently captured by the built-in branch whose + name happens to be a substring of the plug-in name. See PR #10700 + review. + """ + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = FakeExternalProvider + mock_config.return_value = {} + + # Namespace deliberately omits the kwargs of any built-in branch + # (no `aws_retries_max_attempts`, `az_cli_auth`, `personal_access_token`, + # etc.). If equality dispatch is broken and the plug-in is misrouted to + # a built-in branch, attribute access will raise and the global never + # gets wired. + args = Namespace( + provider=plugin_name, + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakeExternalProvider) + Provider._global = None + # =========================================================================== # 3. Check Discovery