diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index a997e09d31f..b976547a624 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -95,6 +95,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `zone_waf_enabled` check for Cloudflare provider now appends a plan-aware hint to the FAIL `status_extended`: a possible-false-positive note on paid plans (Pro, Business, Enterprise) where the legacy `waf` zone setting can read `off` even though WAF managed rulesets are deployed via the dashboard, and a "not available on the Cloudflare Free plan" note on Free zones [(#9896)](https://github.com/prowler-cloud/prowler/pull/9896) - Google Workspace Gmail checks sharing a single resource row, causing the service field to be overwritten by the last check executed [(#11169)](https://github.com/prowler-cloud/prowler/pull/11169) - Google Workspace Drive and Calendar services missing server-side policy filters [(#11195)](https://github.com/prowler-cloud/prowler/pull/11195) +- `logging_sink_created` GCP check no longer false-FAILs projects covered by an organisation-level aggregated sink with `includeChildren=True`; the service now also queries `organizations/{id}/sinks` and passes any project whose org has a matching aggregated sink [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) - `entra_users_mfa_capable` and `entra_break_glass_account_fido2_security_key_registered` report a preventive FAIL per affected user (with the missing permission named) when the M365 service principal lacks `AuditLog.Read.All`, instead of mass false positives [(#10907)](https://github.com/prowler-cloud/prowler/pull/10907) - Duplicated GCP CIS requirements IDs [(#11180)](https://github.com/prowler-cloud/prowler/pull/11180) - `VercelSession.token` is now excluded from serialization and representation to prevent the Vercel API token from leaking through `.dict()`, `.json()` or logs [(#11198)](https://github.com/prowler-cloud/prowler/pull/11198) diff --git a/prowler/providers/azure/azure_provider.py b/prowler/providers/azure/azure_provider.py index c9496ac0a52..ee69e8bde9c 100644 --- a/prowler/providers/azure/azure_provider.py +++ b/prowler/providers/azure/azure_provider.py @@ -11,6 +11,7 @@ import requests from azure.core.exceptions import ClientAuthenticationError, HttpResponseError from azure.identity import ( + ClientAssertionCredential, ClientSecretCredential, CredentialUnavailableError, DefaultAzureCredential, @@ -46,6 +47,7 @@ AzureNotValidClientIdError, AzureNotValidClientSecretError, AzureNotValidTenantIdError, + AzureOIDCTokenMissingError, AzureSetUpIdentityError, AzureSetUpRegionConfigError, AzureSetUpSessionError, @@ -112,6 +114,7 @@ def __init__( sp_env_auth: bool = False, browser_auth: bool = False, managed_identity_auth: bool = False, + oidc_auth: bool = False, tenant_id: str = None, region: str = "AzureCloud", subscription_ids: list = [], @@ -131,6 +134,7 @@ def __init__( sp_env_auth (bool): Flag indicating whether to use Service Principal environment authentication. browser_auth (bool): Flag indicating whether to use interactive browser authentication. managed_identity_auth (bool): Flag indicating whether to use managed identity authentication. + oidc_auth (bool): Flag indicating whether to use OIDC/Workload Identity Federation authentication. tenant_id (str): The Azure Active Directory tenant ID. region (str): The Azure region. subscription_ids (list): List of subscription IDs. @@ -229,6 +233,7 @@ def __init__( sp_env_auth, browser_auth, managed_identity_auth, + oidc_auth, tenant_id, client_id, client_secret, @@ -253,6 +258,7 @@ def __init__( sp_env_auth, browser_auth, managed_identity_auth, + oidc_auth, tenant_id, azure_credentials, self._region_config, @@ -264,6 +270,7 @@ def __init__( sp_env_auth, browser_auth, managed_identity_auth, + oidc_auth, subscription_ids, client_id, ) @@ -344,6 +351,7 @@ def validate_arguments( sp_env_auth: bool, browser_auth: bool, managed_identity_auth: bool, + oidc_auth: bool, tenant_id: str, client_id: str, client_secret: str, @@ -356,16 +364,18 @@ def validate_arguments( sp_env_auth (bool): Flag indicating whether Service Principal environment authentication is enabled. browser_auth (bool): Flag indicating whether browser authentication is enabled. managed_identity_auth (bool): Flag indicating whether managed identity authentication is enabled. + oidc_auth (bool): Flag indicating whether OIDC/Workload Identity Federation authentication is enabled. tenant_id (str): The Azure Tenant ID. client_id (str): The Azure Client ID. client_secret (str): The Azure Client Secret. Raises: AzureBrowserAuthNoTenantIDError: If browser authentication is enabled but the tenant ID is not found. + AzureOIDCTokenMissingError: If OIDC authentication is enabled but required env vars are missing. """ if not client_id and not client_secret: - if not browser_auth and tenant_id: + if not browser_auth and tenant_id and not oidc_auth: raise AzureTenantIDNoBrowserAuthError( file=os.path.basename(__file__), message="Azure Tenant ID (--tenant-id) is required for browser authentication mode", @@ -375,10 +385,11 @@ def validate_arguments( and not sp_env_auth and not browser_auth and not managed_identity_auth + and not oidc_auth ): raise AzureNoAuthenticationMethodError( file=os.path.basename(__file__), - message="Azure provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --managed-identity-auth]", + message="Azure provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --managed-identity-auth | --oidc-auth]", ) elif browser_auth and not tenant_id: raise AzureBrowserAuthNoTenantIDError( @@ -469,6 +480,7 @@ def setup_session( sp_env_auth: bool, browser_auth: bool, managed_identity_auth: bool, + oidc_auth: bool, tenant_id: str, azure_credentials: dict, region_config: AzureRegionConfig, @@ -482,6 +494,7 @@ def setup_session( sp_env_auth (bool): Flag indicating whether to use Service Principal authentication with environment variables. browser_auth (bool): Flag indicating whether to use interactive browser authentication. managed_identity_auth (bool): Flag indicating whether to use managed identity authentication. + oidc_auth (bool): Flag indicating whether to use OIDC/Workload Identity Federation authentication. tenant_id (str): The Azure Active Directory tenant ID. azure_credentials (dict): The Azure configuration object. It contains the following keys: - tenant_id: The Azure Active Directory tenant ID. @@ -496,6 +509,38 @@ def setup_session( Exception: If failed to retrieve Azure credentials. """ + # OIDC / Workload Identity Federation auth + if oidc_auth: + try: + AzureProvider.check_oidc_creds_env_vars() + oidc_tenant_id = getenv("AZURE_TENANT_ID") + oidc_client_id = getenv("AZURE_CLIENT_ID") + + def get_oidc_token(): + """Return the current OIDC JWT, preferring AZURE_FEDERATED_TOKEN.""" + token = getenv("AZURE_FEDERATED_TOKEN") or getenv("AZURE_OIDC_TOKEN") + return token + + credentials = ClientAssertionCredential( + tenant_id=oidc_tenant_id, + client_id=oidc_client_id, + func=get_oidc_token, + ) + return credentials + except AzureOIDCTokenMissingError as oidc_error: + logger.critical( + f"{oidc_error.__class__.__name__}[{oidc_error.__traceback__.tb_lineno}] -- {oidc_error}" + ) + raise oidc_error + except Exception as error: + logger.critical("Failed to retrieve azure credentials using OIDC authentication") + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise AzureSetUpSessionError( + file=os.path.basename(__file__), original_exception=error + ) + # Browser auth creds cannot be set with DefaultAzureCredentials() if not browser_auth: if sp_env_auth: @@ -603,12 +648,14 @@ def setup_session( return credentials + @staticmethod def test_connection( az_cli_auth=False, sp_env_auth=False, browser_auth=False, managed_identity_auth=False, + oidc_auth=False, tenant_id=None, region="AzureCloud", raise_on_exception=True, @@ -625,6 +672,7 @@ def test_connection( sp_env_auth (bool): Flag indicating if Service Principal environment authentication is used. browser_auth (bool): Flag indicating if browser authentication is used. managed_identity_auth (bool): Flag indicating if managed entity authentication is used. + oidc_auth (bool): Flag indicating if OIDC/Workload Identity Federation authentication is used. tenant_id (str): The Azure Active Directory tenant ID. region (str): The Azure region. raise_on_exception (bool): Flag indicating whether to raise an exception if the connection fails. @@ -659,6 +707,7 @@ def test_connection( sp_env_auth, browser_auth, managed_identity_auth, + oidc_auth, tenant_id, client_id, client_secret, @@ -681,6 +730,7 @@ def test_connection( sp_env_auth, browser_auth, managed_identity_auth, + oidc_auth, tenant_id, azure_credentials, region_config, @@ -872,12 +922,48 @@ def check_service_principal_creds_env_vars(): message=f"Missing environment variable {env_var} required to authenticate.", ) + @staticmethod + def check_oidc_creds_env_vars(): + """ + Checks the presence of required environment variables for OIDC/Workload Identity Federation + authentication against Azure. + + This method checks for the presence of the following environment variables: + - AZURE_CLIENT_ID: Azure client ID + - AZURE_TENANT_ID: Azure tenant ID + - AZURE_FEDERATED_TOKEN or AZURE_OIDC_TOKEN: OIDC JWT token + + If any of the required environment variables is missing, it logs a critical error and raises + an AzureOIDCTokenMissingError. + """ + logger.info( + "Azure provider: checking OIDC/Workload Identity Federation environment variables ..." + ) + for env_var in ["AZURE_CLIENT_ID", "AZURE_TENANT_ID"]: + if not getenv(env_var): + logger.critical( + f"Azure provider: Missing environment variable {env_var} needed for OIDC authentication" + ) + raise AzureOIDCTokenMissingError( + file=os.path.basename(__file__), + message=f"Missing environment variable {env_var} required for OIDC authentication.", + ) + if not getenv("AZURE_FEDERATED_TOKEN") and not getenv("AZURE_OIDC_TOKEN"): + logger.critical( + "Azure provider: Missing OIDC token. Set AZURE_FEDERATED_TOKEN or AZURE_OIDC_TOKEN." + ) + raise AzureOIDCTokenMissingError( + file=os.path.basename(__file__), + message="Missing OIDC token. Set AZURE_FEDERATED_TOKEN or AZURE_OIDC_TOKEN environment variable.", + ) + def setup_identity( self, az_cli_auth, sp_env_auth, browser_auth, managed_identity_auth, + oidc_auth, subscription_ids, client_id, ): @@ -889,7 +975,9 @@ def setup_identity( sp_env_auth (bool): Flag indicating if Service Principal environment authentication is used. browser_auth (bool): Flag indicating if browser authentication is used. managed_identity_auth (bool): Flag indicating if managed entity authentication is used. + oidc_auth (bool): Flag indicating if OIDC/Workload Identity Federation authentication is used. subscription_ids (list): List of subscription IDs. + client_id (str): The Azure client ID (for static credentials). Returns: AzureIdentityInfo: An instance of AzureIdentityInfo containing the identity information. @@ -902,7 +990,7 @@ def setup_identity( # the identity can access AAD and retrieve the tenant domain name. # With cli also should be possible but right now it does not work, azure python package issue is coming # At the time of writting this with az cli creds is not working, despite that is included - if sp_env_auth or browser_auth or az_cli_auth or client_id: + if sp_env_auth or browser_auth or az_cli_auth or client_id or oidc_auth: async def get_azure_identity(): # Trying to recover tenant domain info @@ -939,10 +1027,10 @@ async def get_azure_identity(): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" ) # since that exception is not considered as critical, we keep filling another identity fields - if sp_env_auth or client_id: + if sp_env_auth or client_id or oidc_auth: # The id of the sp can be retrieved from environment variables identity.identity_id = getenv("AZURE_CLIENT_ID", default=client_id) - identity.identity_type = "Service Principal" + identity.identity_type = "Service Principal" if not oidc_auth else "Service Principal (OIDC)" # Same here, if user can access AAD, some fields are retrieved if not, default value, for az cli # should work but it doesn't, pending issue else: diff --git a/prowler/providers/azure/exceptions/exceptions.py b/prowler/providers/azure/exceptions/exceptions.py index 5114c867a41..a9d3089f1b7 100644 --- a/prowler/providers/azure/exceptions/exceptions.py +++ b/prowler/providers/azure/exceptions/exceptions.py @@ -102,6 +102,11 @@ class AzureBaseException(ProwlerException): "message": "The provided provider_id does not match with the available subscriptions", "remediation": "Check the provider_id and ensure it is a valid subscription for the given credentials.", }, + (2024, "AzureOIDCTokenMissingError"): { + "message": "Azure OIDC token missing", + "remediation": "Set the AZURE_FEDERATED_TOKEN or AZURE_OIDC_TOKEN environment variable with a valid OIDC JWT token. " + "Also ensure AZURE_CLIENT_ID and AZURE_TENANT_ID are set.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -291,3 +296,10 @@ def __init__(self, file=None, original_exception=None, message=None): super().__init__( 2023, file=file, original_exception=original_exception, message=message ) + + +class AzureOIDCTokenMissingError(AzureCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 2024, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/azure/lib/arguments/arguments.py b/prowler/providers/azure/lib/arguments/arguments.py index 2b624a3f235..76e7d6734b1 100644 --- a/prowler/providers/azure/lib/arguments/arguments.py +++ b/prowler/providers/azure/lib/arguments/arguments.py @@ -29,6 +29,12 @@ def init_parser(self): action="store_true", help="Use managed identity authentication to log in against Azure ", ) + azure_auth_modes_group.add_argument( + "--oidc-auth", + action="store_true", + help="Use OIDC/Workload Identity Federation authentication to log in against Azure. " + "Requires AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_FEDERATED_TOKEN (or AZURE_OIDC_TOKEN) environment variables.", + ) # Subscriptions azure_subscriptions_subparser = azure_parser.add_argument_group("Subscriptions") azure_subscriptions_subparser.add_argument( diff --git a/prowler/providers/gcp/services/logging/logging_service.py b/prowler/providers/gcp/services/logging/logging_service.py index 637c8782b2d..2459895c4cd 100644 --- a/prowler/providers/gcp/services/logging/logging_service.py +++ b/prowler/providers/gcp/services/logging/logging_service.py @@ -12,6 +12,7 @@ def __init__(self, provider: GcpProvider): self.sinks = [] self.metrics = [] self._get_sinks() + self._get_org_sinks() self._get_metrics() def _get_sinks(self): @@ -39,6 +40,38 @@ def _get_sinks(self): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_org_sinks(self): + """Fetch org-level sinks with includeChildren so child projects are not falsely failed.""" + org_ids = set() + for project in self.projects.values(): + if project.organization: + org_ids.add(project.organization.id) + + for org_id in org_ids: + try: + request = self.client.sinks().list(parent=f"organizations/{org_id}") + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + + for sink in response.get("sinks", []): + self.sinks.append( + Sink( + name=sink["name"], + destination=sink["destination"], + filter=sink.get("filter", "all"), + project_id=f"organizations/{org_id}", + include_children=sink.get("includeChildren", False), + ) + ) + + request = self.client.sinks().list_next( + previous_request=request, previous_response=response + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _get_metrics(self): for project_id in self.project_ids: try: @@ -76,6 +109,7 @@ class Sink(BaseModel): destination: str filter: str project_id: str + include_children: bool = False class Metric(BaseModel): diff --git a/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py b/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py index 30104a050d8..a7846e3dd86 100644 --- a/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py +++ b/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py @@ -5,31 +5,50 @@ class logging_sink_created(Check): def execute(self) -> Check_Report_GCP: findings = [] + + # Map project_id -> sink for direct project-level sinks projects_with_logging_sink = {} for sink in logging_client.sinks: - if sink.filter == "all": + if sink.filter == "all" and not sink.include_children: projects_with_logging_sink[sink.project_id] = sink + # Collect org resource names that have a covering sink (includeChildren=True) + covering_org_sinks = {} + for sink in logging_client.sinks: + if sink.filter == "all" and sink.include_children: + covering_org_sinks[sink.project_id] = sink + for project in logging_client.project_ids: - if project not in projects_with_logging_sink.keys(): - project_obj = logging_client.projects.get(project) + project_obj = logging_client.projects.get(project) + + # Determine whether this project is covered by an org-level sink + org = getattr(project_obj, "organization", None) if project_obj else None + org_resource = f"organizations/{org.id}" if org else None + covering_sink = ( + covering_org_sinks.get(org_resource) if org_resource else None + ) + + if project in projects_with_logging_sink: + sink = projects_with_logging_sink[project] + sink_name = getattr(sink, "name", None) or "unknown" report = Check_Report_GCP( metadata=self.metadata(), - resource=project_obj, - resource_id=project, + resource=sink, + resource_id=sink_name, project_id=project, location=logging_client.region, - resource_name=(getattr(project_obj, "name", None) or "GCP Project"), + resource_name=( + sink_name if sink_name != "unknown" else "Logging Sink" + ), ) - report.status = "FAIL" - report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}." + report.status = "PASS" + report.status_extended = f"Sink {sink_name} is enabled exporting copies of all the log entries in project {project}." findings.append(report) - else: - sink = projects_with_logging_sink[project] - sink_name = getattr(sink, "name", None) or "unknown" + elif covering_sink: + sink_name = getattr(covering_sink, "name", None) or "unknown" report = Check_Report_GCP( metadata=self.metadata(), - resource=sink, + resource=covering_sink, resource_id=sink_name, project_id=project, location=logging_client.region, @@ -38,6 +57,18 @@ def execute(self) -> Check_Report_GCP: ), ) report.status = "PASS" - report.status_extended = f"Sink {sink_name} is enabled exporting copies of all the log entries in project {project}." + report.status_extended = f"Sink {sink_name} at organization level is exporting copies of all the log entries in project {project}." + findings.append(report) + else: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=project_obj, + resource_id=project, + project_id=project, + location=logging_client.region, + resource_name=(getattr(project_obj, "name", None) or "GCP Project"), + ) + report.status = "FAIL" + report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}." findings.append(report) return findings diff --git a/tests/providers/azure/azure_provider_test.py b/tests/providers/azure/azure_provider_test.py index 1d9aa97e6b6..a389ad3e347 100644 --- a/tests/providers/azure/azure_provider_test.py +++ b/tests/providers/azure/azure_provider_test.py @@ -19,6 +19,7 @@ AzureHTTPResponseError, AzureInvalidProviderIdError, AzureNoAuthenticationMethodError, + AzureOIDCTokenMissingError, AzureTenantIDNoBrowserAuthError, ) from prowler.providers.azure.models import AzureIdentityInfo, AzureRegionConfig @@ -158,7 +159,7 @@ def test_azure_provider_not_auth_methods(self): ) assert exception.type == AzureNoAuthenticationMethodError assert ( - "Azure provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --managed-identity-auth]" + "Azure provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --managed-identity-auth | --oidc-auth]" in exception.value.args[0] ) @@ -475,7 +476,7 @@ def test_test_connection_without_any_method(self): assert exception.type == AzureNoAuthenticationMethodError assert ( - "[2003] Azure provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --managed-identity-auth]" + "[2003] Azure provider requires at least one authentication method set: [--az-cli-auth | --sp-env-auth | --browser-auth | --managed-identity-auth | --oidc-auth]" in exception.value.args[0] ) @@ -624,6 +625,7 @@ def test_setup_identity_auto_discovery_preserves_unique_display_names(self): sp_env_auth=False, browser_auth=False, managed_identity_auth=False, + oidc_auth=False, subscription_ids=[], client_id=None, ) @@ -656,6 +658,7 @@ def test_setup_identity_auto_discovery_preserves_duplicate_display_names( sp_env_auth=False, browser_auth=False, managed_identity_auth=False, + oidc_auth=False, subscription_ids=[], client_id=None, ) @@ -685,6 +688,7 @@ def test_setup_identity_filtered_preserves_unique_display_names(self): sp_env_auth=False, browser_auth=False, managed_identity_auth=False, + oidc_auth=False, subscription_ids=[first_id, second_id], client_id=None, ) @@ -715,6 +719,7 @@ def test_setup_identity_filtered_preserves_duplicate_display_names(self): sp_env_auth=False, browser_auth=False, managed_identity_auth=False, + oidc_auth=False, subscription_ids=[first_id, second_id], client_id=None, ) @@ -1092,6 +1097,7 @@ def test_setup_identity_succeeds_without_active_event_loop(self): sp_env_auth=True, browser_auth=False, managed_identity_auth=False, + oidc_auth=False, subscription_ids=[], client_id="00000000-0000-0000-0000-000000000000", ) @@ -1102,3 +1108,178 @@ def test_setup_identity_succeeds_without_active_event_loop(self): assert isinstance(identity, AzureIdentityInfo) assert identity.subscriptions == {sub_id: "Sub"} graph_client.domains.get.assert_awaited_once() + + +class TestAzureProviderOIDCAuth: + """Tests for OIDC/Workload Identity Federation authentication (Issue #11386).""" + + def test_check_oidc_creds_env_vars_missing_client_id(self): + """check_oidc_creds_env_vars raises AzureOIDCTokenMissingError when AZURE_CLIENT_ID is missing.""" + with patch.dict( + "os.environ", + { + "AZURE_TENANT_ID": "test-tenant-id", + "AZURE_FEDERATED_TOKEN": "test-token", + }, + clear=True, + ): + with pytest.raises(AzureOIDCTokenMissingError) as exc_info: + AzureProvider.check_oidc_creds_env_vars() + assert "AZURE_CLIENT_ID" in exc_info.value.args[0] + + def test_check_oidc_creds_env_vars_missing_tenant_id(self): + """check_oidc_creds_env_vars raises AzureOIDCTokenMissingError when AZURE_TENANT_ID is missing.""" + with patch.dict( + "os.environ", + { + "AZURE_CLIENT_ID": "test-client-id", + "AZURE_FEDERATED_TOKEN": "test-token", + }, + clear=True, + ): + with pytest.raises(AzureOIDCTokenMissingError) as exc_info: + AzureProvider.check_oidc_creds_env_vars() + assert "AZURE_TENANT_ID" in exc_info.value.args[0] + + def test_check_oidc_creds_env_vars_missing_token(self): + """check_oidc_creds_env_vars raises AzureOIDCTokenMissingError when both token env vars are missing.""" + with patch.dict( + "os.environ", + { + "AZURE_CLIENT_ID": "test-client-id", + "AZURE_TENANT_ID": "test-tenant-id", + }, + clear=True, + ): + with pytest.raises(AzureOIDCTokenMissingError) as exc_info: + AzureProvider.check_oidc_creds_env_vars() + assert "AZURE_FEDERATED_TOKEN" in exc_info.value.args[0] + + def test_check_oidc_creds_env_vars_with_oidc_token_fallback(self): + """check_oidc_creds_env_vars passes when AZURE_OIDC_TOKEN is set (fallback).""" + with patch.dict( + "os.environ", + { + "AZURE_CLIENT_ID": "test-client-id", + "AZURE_TENANT_ID": "test-tenant-id", + "AZURE_OIDC_TOKEN": "test-oidc-token", + }, + clear=True, + ): + # Should not raise + AzureProvider.check_oidc_creds_env_vars() + + def test_check_oidc_creds_env_vars_success(self): + """check_oidc_creds_env_vars passes when all required env vars are present.""" + with patch.dict( + "os.environ", + { + "AZURE_CLIENT_ID": "test-client-id", + "AZURE_TENANT_ID": "test-tenant-id", + "AZURE_FEDERATED_TOKEN": "eyJhbGciOiJSUzI1NiJ9.test.token", + }, + clear=True, + ): + # Should not raise + AzureProvider.check_oidc_creds_env_vars() + + def test_setup_session_oidc_auth_success(self): + """setup_session with oidc_auth=True returns a ClientAssertionCredential.""" + from azure.identity import ClientAssertionCredential + + with ( + patch( + "prowler.providers.azure.azure_provider.AzureProvider.check_oidc_creds_env_vars" + ), + patch.dict( + "os.environ", + { + "AZURE_CLIENT_ID": "test-client-id", + "AZURE_TENANT_ID": "test-tenant-id", + "AZURE_FEDERATED_TOKEN": "eyJhbGciOiJSUzI1NiJ9.test.token", + }, + ), + patch( + "prowler.providers.azure.azure_provider.ClientAssertionCredential" + ) as mock_client_assertion, + ): + mock_credential = MagicMock(spec=ClientAssertionCredential) + mock_client_assertion.return_value = mock_credential + + region_config = AzureRegionConfig( + name="AzureCloud", + authority=None, + base_url="https://management.azure.com", + credential_scopes=["https://management.azure.com/.default"], + ) + + credentials = AzureProvider.setup_session( + az_cli_auth=False, + sp_env_auth=False, + browser_auth=False, + managed_identity_auth=False, + oidc_auth=True, + tenant_id=None, + azure_credentials=None, + region_config=region_config, + ) + + mock_client_assertion.assert_called_once_with( + tenant_id="test-tenant-id", + client_id="test-client-id", + func=mock_client_assertion.call_args.kwargs["func"], + ) + assert credentials is mock_credential + + def test_setup_session_oidc_auth_missing_token_raises(self): + """setup_session with oidc_auth=True raises AzureOIDCTokenMissingError when token is missing.""" + region_config = AzureRegionConfig( + name="AzureCloud", + authority=None, + base_url="https://management.azure.com", + credential_scopes=["https://management.azure.com/.default"], + ) + with patch.dict( + "os.environ", + { + "AZURE_CLIENT_ID": "test-client-id", + "AZURE_TENANT_ID": "test-tenant-id", + }, + clear=True, + ): + with pytest.raises(AzureOIDCTokenMissingError): + AzureProvider.setup_session( + az_cli_auth=False, + sp_env_auth=False, + browser_auth=False, + managed_identity_auth=False, + oidc_auth=True, + tenant_id=None, + azure_credentials=None, + region_config=region_config, + ) + + def test_test_connection_oidc_auth_success(self): + """test_connection with oidc_auth=True returns a successful Connection.""" + with ( + patch( + "prowler.providers.azure.azure_provider.AzureProvider.setup_session" + ) as mock_setup_session, + patch( + "prowler.providers.azure.azure_provider.SubscriptionClient" + ) as mock_resource_client, + ): + mock_session = MagicMock() + mock_setup_session.return_value = mock_session + + mock_client = MagicMock() + mock_resource_client.return_value = mock_client + + test_connection = AzureProvider.test_connection( + oidc_auth=True, + raise_on_exception=False, + ) + + assert isinstance(test_connection, Connection) + assert test_connection.is_connected + assert test_connection.error is None diff --git a/tests/providers/gcp/services/logging/logging_service_test.py b/tests/providers/gcp/services/logging/logging_service_test.py index 0396130c2f3..7220babdd0d 100644 --- a/tests/providers/gcp/services/logging/logging_service_test.py +++ b/tests/providers/gcp/services/logging/logging_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.gcp.services.logging.logging_service import Logging from tests.providers.gcp.gcp_fixtures import ( @@ -66,3 +66,80 @@ def test_service(self): == "resource.type=gae_app AND severity>=ERROR" ) assert logging_client.metrics[1].project_id == GCP_PROJECT_ID + + def test_org_sinks_fetched_when_project_has_organization(self): + """_get_org_sinks() appends org-level sinks when projects have an org.""" + from prowler.providers.gcp.models import GCPOrganization, GCPProject + + org_id = "999888777" + provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + provider.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + mock_client = MagicMock() + mock_client.sinks().list().execute.return_value = { + "sinks": [ + { + "name": "org-sink", + "destination": "storage.googleapis.com/org-bucket", + "filter": "all", + "includeChildren": True, + } + ] + } + mock_client.sinks().list_next.return_value = None + mock_client.projects().metrics().list().execute.return_value = {"metrics": []} + mock_client.projects().metrics().list_next.return_value = None + + 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__", + return_value=mock_client, + ), + ): + logging_svc = Logging(provider) + + org_sinks = [ + s for s in logging_svc.sinks if s.project_id == f"organizations/{org_id}" + ] + assert len(org_sinks) == 1 + assert org_sinks[0].name == "org-sink" + assert org_sinks[0].include_children is True + assert org_sinks[0].filter == "all" + + def test_org_sinks_skipped_when_no_organization(self): + """_get_org_sinks() adds nothing when projects have no organization.""" + 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, + ), + ): + logging_svc = Logging( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + org_sinks = [ + s + for s in logging_svc.sinks + if s.project_id.startswith("organizations/") + ] + assert org_sinks == [] diff --git a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py index b9c6481d228..6ced615f658 100644 --- a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py +++ b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py @@ -1,6 +1,6 @@ from unittest.mock import MagicMock, patch -from prowler.providers.gcp.models import GCPProject +from prowler.providers.gcp.models import GCPOrganization, GCPProject from tests.providers.gcp.gcp_fixtures import ( GCP_EU1_LOCATION, GCP_PROJECT_ID, @@ -268,6 +268,7 @@ def test_sink_with_none_name(self): sink.name = None sink.filter = "all" sink.project_id = GCP_PROJECT_ID + sink.include_children = False logging_client.project_ids = [GCP_PROJECT_ID] logging_client.region = GCP_EU1_LOCATION @@ -311,9 +312,10 @@ def test_sink_with_missing_name_attribute(self): ) # Create a MagicMock sink object without name attribute - sink = MagicMock(spec=["filter", "project_id"]) + sink = MagicMock(spec=["filter", "project_id", "include_children"]) sink.filter = "all" sink.project_id = GCP_PROJECT_ID + sink.include_children = False logging_client.project_ids = [GCP_PROJECT_ID] logging_client.region = GCP_EU1_LOCATION @@ -336,3 +338,175 @@ def test_sink_with_missing_name_attribute(self): assert result[0].resource_id == "unknown" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_org_level_sink_with_include_children_passes(self): + """Projects covered by an org-level sink with includeChildren=True should PASS.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="org-sink", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Sink org-sink at organization level is exporting copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == "org-sink" + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + + def test_org_level_sink_without_include_children_fails(self): + """Projects NOT covered by includeChildren should still FAIL if no direct project sink.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="org-sink-no-children", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=False, + ) + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no logging sinks to export copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + + def test_project_sink_takes_precedence_over_org_sink(self): + """A direct project sink should be reported even when an org-level sink also covers the project.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="project-sink", + destination="storage.googleapis.com/project-bucket", + filter="all", + project_id=GCP_PROJECT_ID, + ), + Sink( + name="org-sink", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ), + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Sink project-sink is enabled exporting copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == "project-sink" + assert result[0].project_id == GCP_PROJECT_ID