From 9537bb5a8cb8e6f219a93c94f87d813f804125ca Mon Sep 17 00:00:00 2001 From: StylusFrost Date: Tue, 2 Jun 2026 12:44:26 +0200 Subject: [PATCH] feat(sdk): add sdk_only provider property to hide providers from the app - Add sdk_only contract property (default True), hidden from the app until opt-in - Add Provider.get_app_providers() returning providers with sdk_only = False - Mark app-exposed built-in providers as sdk_only = False --- .../alibabacloud/alibabacloud_provider.py | 1 + prowler/providers/aws/aws_provider.py | 1 + prowler/providers/azure/azure_provider.py | 1 + .../cloudflare/cloudflare_provider.py | 1 + prowler/providers/common/provider.py | 26 +++++++ prowler/providers/gcp/gcp_provider.py | 1 + prowler/providers/github/github_provider.py | 1 + .../googleworkspace_provider.py | 1 + prowler/providers/iac/iac_provider.py | 1 + prowler/providers/image/image_provider.py | 1 + .../kubernetes/kubernetes_provider.py | 1 + prowler/providers/m365/m365_provider.py | 1 + .../mongodbatlas/mongodbatlas_provider.py | 1 + prowler/providers/okta/okta_provider.py | 1 + .../providers/openstack/openstack_provider.py | 1 + .../oraclecloud/oraclecloud_provider.py | 1 + prowler/providers/vercel/vercel_provider.py | 1 + .../external/test_dynamic_provider_loading.py | 78 +++++++++++++++++++ 18 files changed, 120 insertions(+) diff --git a/prowler/providers/alibabacloud/alibabacloud_provider.py b/prowler/providers/alibabacloud/alibabacloud_provider.py index 82e48e2f14..d7020186a0 100644 --- a/prowler/providers/alibabacloud/alibabacloud_provider.py +++ b/prowler/providers/alibabacloud/alibabacloud_provider.py @@ -53,6 +53,7 @@ class AlibabacloudProvider(Provider): """ _type: str = "alibabacloud" + sdk_only: bool = False _identity: AlibabaCloudIdentityInfo _session: AlibabaCloudSession _audit_resources: list = [] diff --git a/prowler/providers/aws/aws_provider.py b/prowler/providers/aws/aws_provider.py index cf6cbdd73a..b4c9ed3771 100644 --- a/prowler/providers/aws/aws_provider.py +++ b/prowler/providers/aws/aws_provider.py @@ -90,6 +90,7 @@ class AwsProvider(Provider): """ _type: str = "aws" + sdk_only: bool = False _identity: AWSIdentityInfo _session: AWSSession _organizations_metadata: AWSOrganizationsInfo diff --git a/prowler/providers/azure/azure_provider.py b/prowler/providers/azure/azure_provider.py index c9496ac0a5..cb27bdfdb1 100644 --- a/prowler/providers/azure/azure_provider.py +++ b/prowler/providers/azure/azure_provider.py @@ -97,6 +97,7 @@ class AzureProvider(Provider): """ _type: str = "azure" + sdk_only: bool = False _session: DefaultAzureCredential _identity: AzureIdentityInfo _audit_config: dict diff --git a/prowler/providers/cloudflare/cloudflare_provider.py b/prowler/providers/cloudflare/cloudflare_provider.py index 48763df395..9c39839f5d 100644 --- a/prowler/providers/cloudflare/cloudflare_provider.py +++ b/prowler/providers/cloudflare/cloudflare_provider.py @@ -46,6 +46,7 @@ class CloudflareProvider(Provider): """Cloudflare provider.""" _type: str = "cloudflare" + sdk_only: bool = False _session: CloudflareSession _identity: CloudflareIdentityInfo _audit_config: dict diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 27d46f2a35..2780c62d05 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -142,6 +142,10 @@ class Provider(ABC): _cli_help_text: str = "" + # CLI/SDK-only provider, hidden from the app (API/UI). Defaults True; a + # provider opts into the app with ``sdk_only = False``. See get_app_providers(). + sdk_only: bool = True + @classmethod def from_cli_args(cls, arguments: Namespace, fixer_config: dict) -> "Provider": """Instantiate the provider from CLI arguments and return the instance. @@ -678,6 +682,28 @@ class Provider(ABC): providers.add(ep.name) return sorted(providers) + @staticmethod + def get_app_providers() -> list[str]: + """Return the providers the app (API/UI) may expose: those with + ``sdk_only = False``. + + Counterpart of :meth:`get_available_providers`, which lists every + provider for the CLI. A provider whose class cannot be imported is + treated as ``sdk_only`` (excluded) so a broken plug-in never leaks in. + """ + app_providers = [] + for name in Provider.get_available_providers(): + try: + provider_class = Provider.get_class(name) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + continue + if not getattr(provider_class, "sdk_only", True): + app_providers.append(name) + return app_providers + @staticmethod def is_tool_wrapper_provider(provider: str) -> bool: """Return True if the provider delegates scanning to an external tool. diff --git a/prowler/providers/gcp/gcp_provider.py b/prowler/providers/gcp/gcp_provider.py index 5017b84c42..2e78a26939 100644 --- a/prowler/providers/gcp/gcp_provider.py +++ b/prowler/providers/gcp/gcp_provider.py @@ -59,6 +59,7 @@ class GcpProvider(Provider): """ _type: str = "gcp" + sdk_only: bool = False _session: Credentials _project_ids: list _excluded_project_ids: list diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index 0f6e7f59ea..d832a93f98 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -91,6 +91,7 @@ class GithubProvider(Provider): """ _type: str = "github" + sdk_only: bool = False _auth_method: str = None MAX_REPO_LIST_LINES: int = 10_000 MAX_REPO_NAME_LENGTH: int = 500 diff --git a/prowler/providers/googleworkspace/googleworkspace_provider.py b/prowler/providers/googleworkspace/googleworkspace_provider.py index 4c431aa736..2a40a59ddf 100644 --- a/prowler/providers/googleworkspace/googleworkspace_provider.py +++ b/prowler/providers/googleworkspace/googleworkspace_provider.py @@ -54,6 +54,7 @@ class GoogleworkspaceProvider(Provider): """ _type: str = "googleworkspace" + sdk_only: bool = False _session: GoogleWorkspaceSession _identity: GoogleWorkspaceIdentityInfo _domain_resource: GoogleWorkspaceResource diff --git a/prowler/providers/iac/iac_provider.py b/prowler/providers/iac/iac_provider.py index b91fdf070e..df9114e033 100644 --- a/prowler/providers/iac/iac_provider.py +++ b/prowler/providers/iac/iac_provider.py @@ -28,6 +28,7 @@ from prowler.providers.common.provider import Provider class IacProvider(Provider): _type: str = "iac" + sdk_only: bool = False audit_metadata: Audit_Metadata def __init__( diff --git a/prowler/providers/image/image_provider.py b/prowler/providers/image/image_provider.py index b142240867..7a245e4125 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -59,6 +59,7 @@ class ImageProvider(Provider): """ _type: str = "image" + sdk_only: bool = False FINDING_BATCH_SIZE: int = 100 MAX_IMAGE_LIST_LINES: int = 10_000 MAX_IMAGE_NAME_LENGTH: int = 500 diff --git a/prowler/providers/kubernetes/kubernetes_provider.py b/prowler/providers/kubernetes/kubernetes_provider.py index 2572b5be88..b350a18ebc 100644 --- a/prowler/providers/kubernetes/kubernetes_provider.py +++ b/prowler/providers/kubernetes/kubernetes_provider.py @@ -58,6 +58,7 @@ class KubernetesProvider(Provider): """ _type: str = "kubernetes" + sdk_only: bool = False _session: KubernetesSession _namespaces: list _audit_config: dict diff --git a/prowler/providers/m365/m365_provider.py b/prowler/providers/m365/m365_provider.py index 0454d85f3d..040e390658 100644 --- a/prowler/providers/m365/m365_provider.py +++ b/prowler/providers/m365/m365_provider.py @@ -99,6 +99,7 @@ class M365Provider(Provider): """ _type: str = "m365" + sdk_only: bool = False _session: DefaultAzureCredential # Must be used besides being named for Azure _identity: M365IdentityInfo _audit_config: dict diff --git a/prowler/providers/mongodbatlas/mongodbatlas_provider.py b/prowler/providers/mongodbatlas/mongodbatlas_provider.py index c40f1ced6e..07ff6f86cc 100644 --- a/prowler/providers/mongodbatlas/mongodbatlas_provider.py +++ b/prowler/providers/mongodbatlas/mongodbatlas_provider.py @@ -36,6 +36,7 @@ class MongodbatlasProvider(Provider): """ _type: str = "mongodbatlas" + sdk_only: bool = False _session: MongoDBAtlasSession _identity: MongoDBAtlasIdentityInfo _audit_config: dict diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index 0a7b0b8e9d..d1b107a10a 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -67,6 +67,7 @@ class OktaProvider(Provider): """ _type: str = "okta" + sdk_only: bool = False _auth_method: str = None _session: OktaSession _identity: OktaIdentityInfo diff --git a/prowler/providers/openstack/openstack_provider.py b/prowler/providers/openstack/openstack_provider.py index e81a399478..e11c4f7909 100644 --- a/prowler/providers/openstack/openstack_provider.py +++ b/prowler/providers/openstack/openstack_provider.py @@ -36,6 +36,7 @@ class OpenstackProvider(Provider): """OpenStack provider responsible for bootstrapping the SDK session.""" _type: str = "openstack" + sdk_only: bool = False _session: OpenStackSession _identity: OpenStackIdentityInfo _audit_config: dict diff --git a/prowler/providers/oraclecloud/oraclecloud_provider.py b/prowler/providers/oraclecloud/oraclecloud_provider.py index 5aa959d985..b498bbb502 100644 --- a/prowler/providers/oraclecloud/oraclecloud_provider.py +++ b/prowler/providers/oraclecloud/oraclecloud_provider.py @@ -59,6 +59,7 @@ class OraclecloudProvider(Provider): """ _type: str = "oraclecloud" + sdk_only: bool = False _identity: OCIIdentityInfo _session: OCISession _audit_config: dict diff --git a/prowler/providers/vercel/vercel_provider.py b/prowler/providers/vercel/vercel_provider.py index 54ab4627a9..3de89becbe 100644 --- a/prowler/providers/vercel/vercel_provider.py +++ b/prowler/providers/vercel/vercel_provider.py @@ -33,6 +33,7 @@ class VercelProvider(Provider): """Vercel provider.""" _type: str = "vercel" + sdk_only: bool = False _session: VercelSession _identity: VercelIdentityInfo _audit_config: dict diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py index aff56c7246..a3b072f69d 100644 --- a/tests/providers/external/test_dynamic_provider_loading.py +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -344,6 +344,84 @@ class TestProviderDiscovery: assert help_text["nohelptext"] == "" +class TestSdkOnly: + """The ``sdk_only`` flag (default True) and Provider.get_app_providers.""" + + def test_base_contract_defaults_to_sdk_only(self): + # The default must be True so nothing leaks into the app implicitly. + assert Provider.sdk_only is True + + def test_external_provider_without_flag_is_sdk_only(self): + # FakeExternalProvider does not override the flag -> inherits True. + assert FakeExternalProvider.sdk_only is True + + @patch("prowler.providers.common.provider.Provider.get_class") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_app_providers_filters_out_sdk_only( + self, mock_available, mock_get_class + ): + app_cls = type("AppProvider", (Provider,), {"sdk_only": False}) + sdk_cls = type("SdkProvider", (Provider,), {"sdk_only": True}) + mock_available.return_value = ["appone", "sdkone", "apptwo"] + mock_get_class.side_effect = lambda name: { + "appone": app_cls, + "sdkone": sdk_cls, + "apptwo": app_cls, + }[name] + + app_providers = Provider.get_app_providers() + + assert app_providers == ["appone", "apptwo"] + + @patch("prowler.providers.common.provider.Provider.get_class") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_app_providers_excludes_provider_that_fails_to_load( + self, mock_available, mock_get_class + ): + # A provider whose class cannot be imported is treated as sdk_only + # (excluded) so a broken plug-in never leaks into the app. + app_cls = type("AppProvider", (Provider,), {"sdk_only": False}) + mock_available.return_value = ["appone", "broken"] + + def _get_class(name): + if name == "broken": + raise ImportError("missing transitive dep") + return app_cls + + mock_get_class.side_effect = _get_class + + assert Provider.get_app_providers() == ["appone"] + + def test_app_exposed_builtins_declare_sdk_only_false(self): + # The providers implemented end-to-end in the API/UI must opt in. + app_providers = set(Provider.get_app_providers()) + for name in ( + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "mongodbatlas", + "iac", + "oraclecloud", + "alibabacloud", + "cloudflare", + "openstack", + "image", + "googleworkspace", + "vercel", + "okta", + ): + assert name in app_providers, f"{name} should be exposed to the app" + + def test_sdk_only_builtins_are_hidden_from_app(self): + # Built-ins not implemented in the API stay SDK-only via the default. + app_providers = set(Provider.get_app_providers()) + for name in ("llm", "nhn", "scaleway", "stackit"): + assert name not in app_providers, f"{name} must be hidden from the app" + + class TestIsToolWrapperProvider: """Tests for Provider.is_tool_wrapper_provider — the helper that combines the built-in EXTERNAL_TOOL_PROVIDERS frozenset with the is_external_tool_provider