diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 4abc4f1384..69bb1de446 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added +- `exchange_application_access_policy_restricts_mailbox_apps` check for M365 provider, verifying every service principal with Microsoft Graph application-level Exchange mailbox permissions is restricted by an Exchange Online Application Access Policy, preventing tenant-wide mailbox access by unscoped applications [(#11247)](https://github.com/prowler-cloud/prowler/pull/11247) - Per-requirement configuration validation for compliance frameworks via `ConfigRequirements`, so a requirement is reported as FAIL when its configurable checks ran with a configuration too loose to satisfy it (applied across all compliance outputs: CSV, OCSF, and console tables) [(#11669)](https://github.com/prowler-cloud/prowler/pull/11669) - `entra_conditional_access_policy_explicitly_targets_azure_devops` check for M365 provider, verifying at least one enabled Conditional Access policy explicitly includes the Azure DevOps cloud application instead of relying on a broad "All cloud apps" policy [(#11182)](https://github.com/prowler-cloud/prowler/pull/11182) - `entra_conditional_access_policy_no_exclusion_gaps` check for M365 provider, verifying every user, group, role, or application excluded from an enabled Conditional Access policy stays in scope of another enabled policy [(#11577)](https://github.com/prowler-cloud/prowler/pull/11577) diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index 4dae094d90..d9cbdcee90 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -1002,6 +1002,31 @@ class M365PowerShell(PowerShellSession): json_parse=True, ) + def get_application_access_policies(self) -> dict: + """ + Get Exchange Online Application Access Policies. + + Retrieves all Exchange Online Application Access Policies. + + Returns: + dict: Application access policies in JSON format. + + Example: + >>> get_application_access_policies() + [ + { + "Identity": "Policy1", + "AppId": "12345678-1234-1234-1234-123456789012", + "AccessRight": "RestrictAccess", + "Description": "Restrict mailbox access" + } + ] + """ + return self.execute( + "Get-ApplicationAccessPolicy | ConvertTo-Json -Depth 10", + json_parse=True, + ) + def get_user_account_status(self) -> dict: """ Get User Account Status. diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index f8713aff8a..a083eb1c3c 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -1,4 +1,5 @@ import asyncio +import importlib import json from asyncio import gather from datetime import datetime, timezone @@ -9,9 +10,6 @@ from uuid import UUID from kiota_abstractions.base_request_configuration import RequestConfiguration from msgraph.generated.groups.groups_request_builder import GroupsRequestBuilder from msgraph.generated.models.o_data_errors.o_data_error import ODataError -from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import ( - RunHuntingQueryPostRequestBody, -) from msgraph.generated.users.users_request_builder import UsersRequestBuilder from pydantic.v1 import BaseModel, validator @@ -19,6 +17,10 @@ from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service from prowler.providers.m365.m365_provider import M365Provider +run_hunting_query_body = importlib.import_module( + "msgraph.generated.security.microsoft_graph_security_run_hunting_query." + "run_hunting_query_post_request_body" +) # Sentinel identifiers used in Conditional Access ``conditions.users`` # collections that do not correspond to real directory objects and must not be # resolved against Graph. Shared by the resolver below and the check that reads @@ -84,6 +86,7 @@ class Entra(M365Service): self.tenant_domain = provider.identity.tenant_domain self.tenant_id = getattr(provider.identity, "tenant_id", None) self.user_registration_details_error: Optional[str] = None + self.exchange_mailbox_permission_service_principals_error: Optional[str] = None attributes = loop.run_until_complete( gather( self._get_authorization_policy(), @@ -98,6 +101,7 @@ class Entra(M365Service): self._get_authentication_method_configurations(), self._get_service_principals(), self._get_app_registrations(), + self._get_exchange_mailbox_permission_service_principals(), ) ) @@ -115,6 +119,9 @@ class Entra(M365Service): ] = attributes[9] self.service_principals: Dict[str, "ServicePrincipal"] = attributes[10] self.app_registrations: Dict[str, "AppRegistration"] = attributes[11] + self.exchange_mailbox_permission_service_principals: Dict[ + str, "ServicePrincipal" + ] = attributes[12] self.user_accounts_status = {} # Resolve directory-object identifiers referenced by Conditional Access @@ -1054,7 +1061,9 @@ OAuthAppInfo | project OAuthAppId, AppName, AppStatus, PrivilegeLevel, Permissions, ServicePrincipalId, IsAdminConsented, LastUsedTime, AppOrigin """ - request_body = RunHuntingQueryPostRequestBody(query=query) + request_body = run_hunting_query_body.RunHuntingQueryPostRequestBody( + query=query + ) result = await self.client.security.microsoft_graph_security_run_hunting_query.post( request_body @@ -1382,6 +1391,112 @@ OAuthAppInfo ) return service_principals + async def _get_exchange_mailbox_permission_service_principals(self): + """Retrieve service principals with Exchange mailbox Graph app roles.""" + logger.info( + "Entra - Getting service principals with Exchange mailbox permissions..." + ) + self.exchange_mailbox_permission_service_principals_error = None + service_principals = {} + graph_service_principal = None + candidate_service_principals = [] + + try: + sp_response = await self.client.service_principals.get() + while sp_response: + for sp in getattr(sp_response, "value", []) or []: + app_id = getattr(sp, "app_id", None) + if app_id == MICROSOFT_GRAPH_APP_ID: + graph_service_principal = sp + continue + + if not getattr(sp, "account_enabled", True): + continue + + raw_owner = getattr(sp, "app_owner_organization_id", None) + app_owner_org_id = str(raw_owner).lower() if raw_owner else None + if app_owner_org_id in MICROSOFT_FIRST_PARTY_TENANT_IDS: + continue + + candidate_service_principals.append(sp) + + next_link = getattr(sp_response, "odata_next_link", None) + if not next_link: + break + sp_response = await self.client.service_principals.with_url( + next_link + ).get() + + if graph_service_principal is None: + return service_principals + + graph_service_principal_id = getattr(graph_service_principal, "id", None) + exchange_app_roles = {} + for role in getattr(graph_service_principal, "app_roles", []) or []: + role_value = getattr(role, "value", "") or "" + allowed_member_types = getattr(role, "allowed_member_types", []) or [] + if ( + role_value in EXCHANGE_MAILBOX_GRAPH_PERMISSIONS + and "Application" in allowed_member_types + ): + exchange_app_roles[str(getattr(role, "id", ""))] = role_value + + if not graph_service_principal_id or not exchange_app_roles: + return service_principals + + for sp in candidate_service_principals: + assignments_response = ( + await self.client.service_principals.by_service_principal_id( + sp.id + ).app_role_assignments.get() + ) + exchange_permissions = set() + + while assignments_response: + for assignment in getattr(assignments_response, "value", []) or []: + resource_id = str(getattr(assignment, "resource_id", "")) + app_role_id = str(getattr(assignment, "app_role_id", "")) + if resource_id == graph_service_principal_id: + permission = exchange_app_roles.get(app_role_id) + if permission: + exchange_permissions.add(permission) + + next_link = getattr(assignments_response, "odata_next_link", None) + if not next_link: + break + assignments_response = ( + await self.client.service_principals.by_service_principal_id( + sp.id + ) + .app_role_assignments.with_url(next_link) + .get() + ) + + if exchange_permissions: + raw_owner = getattr(sp, "app_owner_organization_id", None) + service_principals[sp.id] = ServicePrincipal( + id=sp.id, + name=getattr(sp, "display_name", "") or "", + app_id=getattr(sp, "app_id", "") or "", + app_owner_organization_id=( + str(raw_owner).lower() if raw_owner else None + ), + account_enabled=getattr(sp, "account_enabled", True), + service_principal_type=getattr( + sp, "service_principal_type", "Application" + ), + exchange_mailbox_permissions=sorted(exchange_permissions), + ) + except Exception as error: + self.exchange_mailbox_permission_service_principals_error = ( + f"{error.__class__.__name__}: {error}" + ) + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return service_principals + async def _get_app_registrations(self) -> Dict[str, "AppRegistration"]: """Retrieve application registrations from Microsoft Entra. @@ -2064,6 +2179,27 @@ TIER_0_ROLE_TEMPLATE_IDS = { "e00e864a-17c5-4a4b-9c06-f5b95a8d5bd8", # Partner Tier2 Support } +MICROSOFT_GRAPH_APP_ID = "00000003-0000-0000-c000-000000000000" + +MICROSOFT_FIRST_PARTY_TENANT_IDS = { + "72f988bf-86f1-41af-91ab-2d7cd011db47", + "f8cdef31-a31e-4b4a-93e4-5f571e91255a", +} + +EXCHANGE_MAILBOX_GRAPH_PERMISSIONS = { + "Calendars.Read", + "Calendars.ReadWrite", + "Contacts.Read", + "Contacts.ReadWrite", + "Mail.Read", + "Mail.ReadBasic", + "Mail.ReadBasic.All", + "Mail.ReadWrite", + "Mail.Send", + "MailboxSettings.Read", + "MailboxSettings.ReadWrite", +} + class ServicePrincipal(BaseModel): """Model representing a Microsoft Entra ID service principal. @@ -2096,6 +2232,9 @@ class ServicePrincipal(BaseModel): password_credentials: List[PasswordCredential] = [] key_credentials: List[KeyCredential] = [] directory_role_template_ids: List[str] = [] + account_enabled: bool = True + service_principal_type: str = "Application" + exchange_mailbox_permissions: List[str] = [] sp_owner_ids: List[str] = [] app_owner_ids: List[str] = [] diff --git a/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/__init__.py b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.metadata.json b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.metadata.json new file mode 100644 index 0000000000..0e4da9420d --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "m365", + "CheckID": "exchange_application_access_policy_restricts_mailbox_apps", + "CheckTitle": "Apps with Exchange mailbox permissions must be scoped via Application Access Policy", + "CheckType": [], + "ServiceName": "exchange", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "**Microsoft 365 Exchange Online** applications with mailbox access permissions should be restricted using **Application Access Policies**.\n\nThis check evaluates whether applications granted Exchange-related Microsoft Graph application permissions are scoped using Exchange Online `ApplicationAccessPolicy` objects.", + "Risk": "Applications with unrestricted Exchange mailbox permissions may gain tenant-wide mailbox access. Without **Application Access Policies**, compromised or over-privileged applications can read or manipulate mail across all mailboxes, leading to unauthorized access, data exfiltration, phishing, and loss of confidentiality and integrity.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/auth-limit-mailbox-access", + "https://learn.microsoft.com/en-us/powershell/module/exchange/new-applicationaccesspolicy", + "https://learn.microsoft.com/en-us/powershell/module/exchange/get-applicationaccesspolicy", + "https://learn.microsoft.com/en-us/graph/api/resources/serviceprincipal?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list-approleassignments?view=graph-rest-1.0" + ], + "Remediation": { + "Code": { + "CLI": "New-ApplicationAccessPolicy -AppId -PolicyScopeGroupId -AccessRight RestrictAccess -Description \"Restrict mailbox access\"", + "NativeIaC": "", + "Other": "1. Connect to Exchange Online PowerShell\n2. Run Get-ApplicationAccessPolicy to review existing policies\n3. Identify applications with Exchange-related Graph application permissions\n4. Create an Application Access Policy for each required application\n5. Scope mailbox access using a dedicated mail-enabled security group", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict applications with Exchange mailbox permissions using **Application Access Policies**. Apply least privilege by limiting mailbox scope to only required users or groups. Regularly review app permissions and remove unused or excessive mailbox access.", + "Url": "https://hub.prowler.com/check/exchange_application_access_policy_restricts_mailbox_apps" + } + }, + "Categories": [ + "identity-access", + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.py b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.py new file mode 100644 index 0000000000..d4c786b338 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.py @@ -0,0 +1,99 @@ +import importlib +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +exchange_client = importlib.import_module( + "prowler.providers.m365.services.exchange.exchange_client" +).exchange_client + + +class exchange_application_access_policy_restricts_mailbox_apps(Check): + """ + Check if applications with Exchange mailbox permissions + are restricted using Exchange Application Access Policies. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + + application_access_policies = exchange_client.application_access_policies + if application_access_policies is None: + report = CheckReportM365( + metadata=self.metadata(), + resource=exchange_client.organization_config, + resource_name="Exchange Online", + resource_id="ExchangeOnlineTenant", + ) + report.status = "MANUAL" + report.status_extended = ( + "Exchange Online PowerShell is unavailable. " + "Enable Exchange Online PowerShell credentials to evaluate " + "Application Access Policies." + ) + findings.append(report) + return findings + + mailbox_permission_collection_error = getattr( + entra_client, + "exchange_mailbox_permission_service_principals_error", + None, + ) + if isinstance(mailbox_permission_collection_error, str): + report = CheckReportM365( + metadata=self.metadata(), + resource=exchange_client.organization_config, + resource_name="Exchange Online", + resource_id="ExchangeOnlineTenant", + ) + report.status = "MANUAL" + report.status_extended = ( + "Microsoft Graph mailbox permission collection failed. " + "Manually verify whether applications with Exchange mailbox " + "permissions are restricted using Application Access Policies." + ) + findings.append(report) + return findings + + policy_app_ids = { + policy.app_id.lower() + for policy in application_access_policies + if getattr(policy, "app_id", None) + and getattr(policy, "access_right", None) == "RestrictAccess" + } + + service_principals = ( + entra_client.exchange_mailbox_permission_service_principals.values() + ) + for service_principal in service_principals: + report = CheckReportM365( + metadata=self.metadata(), + resource=service_principal, + resource_name=service_principal.name, + resource_id=service_principal.id, + ) + permissions = ", ".join(service_principal.exchange_mailbox_permissions) + + if service_principal.app_id.lower() in policy_app_ids: + report.status = "PASS" + report.status_extended = ( + f"Service principal '{service_principal.name}' " + f"({service_principal.app_id}) " + "has Exchange mailbox permissions " + f"({permissions}) and is restricted using an Application " + "Access Policy." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Service principal '{service_principal.name}' " + f"({service_principal.app_id}) " + "has Exchange mailbox permissions " + f"({permissions}) but is not restricted using an Application " + "Access Policy." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/exchange/exchange_service.py b/prowler/providers/m365/services/exchange/exchange_service.py index 3ce6528aa9..c4e4b309a4 100644 --- a/prowler/providers/m365/services/exchange/exchange_service.py +++ b/prowler/providers/m365/services/exchange/exchange_service.py @@ -43,6 +43,7 @@ class Exchange(M365Service): self.role_assignment_policies = [] self.mailbox_audit_properties = [] self.shared_mailboxes = [] + self.application_access_policies = None self.mailboxes = None if self.powershell: @@ -56,6 +57,9 @@ class Exchange(M365Service): self.role_assignment_policies = self._get_role_assignment_policies() self.mailbox_audit_properties = self._get_mailbox_audit_properties() self.shared_mailboxes = self._get_shared_mailboxes() + self.application_access_policies = ( + self._get_application_access_policies() + ) self.mailboxes = self._get_mailboxes() self.powershell.close() @@ -366,6 +370,53 @@ class Exchange(M365Service): ) return shared_mailboxes + def _get_application_access_policies(self): + """ + Get Exchange Online Application Access Policies. + + Returns: + Optional[list[ApplicationAccessPolicy]]: List of application access + policies, or None if the PowerShell command failed. + """ + logger.info("Microsoft365 - Getting application access policies...") + + application_access_policies = [] + + try: + policies_data = self.powershell.get_application_access_policies() + + if not policies_data: + return application_access_policies + + if isinstance(policies_data, dict): + policies_data = [policies_data] + + for policy in policies_data: + if policy: + application_access_policies.append( + ApplicationAccessPolicy( + identity=policy.get("Identity", ""), + app_id=policy.get("AppId", ""), + access_right=policy.get( + "AccessRight", + "", + ), + description=policy.get( + "Description", + "", + ), + ) + ) + + except Exception as error: + logger.error( + f"{error.__class__.__name__}" + f"[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + + return application_access_policies + def _get_mailboxes(self) -> Optional[list["Mailbox"]]: """ Get all recipient-facing mailboxes from Exchange Online. @@ -554,6 +605,17 @@ class SharedMailbox(BaseModel): identity: str +class ApplicationAccessPolicy(BaseModel): + """ + Model for Exchange Online Application Access Policy. + """ + + identity: str + app_id: str + access_right: str + description: str + + class Mailbox(BaseModel): """ Model for an Exchange Online recipient-facing mailbox. diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index ba6247c7bd..fb8a5e5e82 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -1,40 +1,41 @@ import asyncio +import importlib from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from prowler.providers.m365.models import M365IdentityInfo -from prowler.providers.m365.services.entra.entra_service import ( - AdminConsentPolicy, - AdminRoles, - ApplicationEnforcedRestrictions, - ApplicationsConditions, - AppManagementRestrictions, - AuthorizationPolicy, - AuthPolicyRoles, - ConditionalAccessGrantControl, - ConditionalAccessPolicy, - ConditionalAccessPolicyState, - Conditions, - CredentialRestriction, - DefaultAppManagementPolicy, - DefaultUserRolePermissions, - Entra, - GrantControlOperator, - GrantControls, - InvitationsFrom, - Organization, - PersistentBrowser, - SessionControls, - SignInFrequency, - SignInFrequencyInterval, - SignInFrequencyType, - User, - UserAction, - UsersConditions, -) +from prowler.providers.m365.services.entra import entra_service from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider +AdminConsentPolicy = entra_service.AdminConsentPolicy +AdminRoles = entra_service.AdminRoles +ApplicationEnforcedRestrictions = entra_service.ApplicationEnforcedRestrictions +ApplicationsConditions = entra_service.ApplicationsConditions +AppManagementRestrictions = entra_service.AppManagementRestrictions +AuthorizationPolicy = entra_service.AuthorizationPolicy +AuthPolicyRoles = entra_service.AuthPolicyRoles +ConditionalAccessGrantControl = entra_service.ConditionalAccessGrantControl +ConditionalAccessPolicy = entra_service.ConditionalAccessPolicy +ConditionalAccessPolicyState = entra_service.ConditionalAccessPolicyState +Conditions = entra_service.Conditions +CredentialRestriction = entra_service.CredentialRestriction +DefaultAppManagementPolicy = entra_service.DefaultAppManagementPolicy +DefaultUserRolePermissions = entra_service.DefaultUserRolePermissions +Entra = entra_service.Entra +GrantControlOperator = entra_service.GrantControlOperator +GrantControls = entra_service.GrantControls +InvitationsFrom = entra_service.InvitationsFrom +Organization = entra_service.Organization +PersistentBrowser = entra_service.PersistentBrowser +SessionControls = entra_service.SessionControls +SignInFrequency = entra_service.SignInFrequency +SignInFrequencyInterval = entra_service.SignInFrequencyInterval +SignInFrequencyType = entra_service.SignInFrequencyType +User = entra_service.User +UserAction = entra_service.UserAction +UsersConditions = entra_service.UsersConditions + async def mock_entra_get_authorization_policy(_): return AuthorizationPolicy( @@ -697,9 +698,12 @@ class Test_Entra_Service: a descriptive error message naming the missing AuditLog.Read.All permission. """ from msgraph.generated.models.o_data_errors.main_error import MainError - from msgraph.generated.models.o_data_errors.o_data_error import ODataError - odata_error = ODataError() + o_data_error = importlib.import_module( + "msgraph.generated.models.o_data_errors.o_data_error" + ) + + odata_error = o_data_error.ODataError() odata_error.error = MainError() odata_error.error.code = "Authorization_RequestDenied" @@ -879,6 +883,134 @@ class Test_Entra_Service: assert merged.password_credentials[0].display_name == "app-level-secret" assert merged.password_credentials[0].is_active() + def test__get_exchange_mailbox_permission_service_principals(self): + """Service principals with Exchange Graph application roles are returned.""" + graph_sp_id = "graph-sp-id" + mail_read_role_id = "11111111-1111-1111-1111-111111111111" + user_read_role_id = "22222222-2222-2222-2222-222222222222" + + graph_sp = SimpleNamespace( + id=graph_sp_id, + display_name="Microsoft Graph", + app_id="00000003-0000-0000-c000-000000000000", + app_owner_organization_id="f8cdef31-a31e-4b4a-93e4-5f571e91255a", + app_roles=[ + SimpleNamespace( + id=mail_read_role_id, + value="Mail.Read", + allowed_member_types=["Application"], + ), + SimpleNamespace( + id=user_read_role_id, + value="User.Read.All", + allowed_member_types=["Application"], + ), + ], + account_enabled=True, + service_principal_type="Application", + ) + mailbox_app = SimpleNamespace( + id="sp-mailbox", + display_name="Mailbox App", + app_id="app-mailbox", + app_owner_organization_id="33333333-3333-3333-3333-333333333333", + app_roles=[], + account_enabled=True, + service_principal_type="Application", + ) + disabled_app = SimpleNamespace( + id="sp-disabled", + display_name="Disabled App", + app_id="app-disabled", + app_owner_organization_id="33333333-3333-3333-3333-333333333333", + app_roles=[], + account_enabled=False, + service_principal_type="Application", + ) + first_party_app = SimpleNamespace( + id="sp-first-party", + display_name="Microsoft App", + app_id="app-first-party", + app_owner_organization_id="f8cdef31-a31e-4b4a-93e4-5f571e91255a", + app_roles=[], + account_enabled=True, + service_principal_type="Application", + ) + + app_role_assignments = { + "sp-mailbox": SimpleNamespace( + value=[ + SimpleNamespace( + resource_id=graph_sp_id, + app_role_id=mail_read_role_id, + ), + SimpleNamespace( + resource_id=graph_sp_id, + app_role_id=user_read_role_id, + ), + ], + odata_next_link=None, + ) + } + + def by_service_principal_id(service_principal_id): + return SimpleNamespace( + app_role_assignments=SimpleNamespace( + get=AsyncMock( + return_value=app_role_assignments.get( + service_principal_id, + SimpleNamespace(value=[], odata_next_link=None), + ) + ), + with_url=MagicMock(), + ) + ) + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + service_principals=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + value=[graph_sp, mailbox_app, disabled_app, first_party_app], + odata_next_link=None, + ) + ), + with_url=MagicMock(), + by_service_principal_id=MagicMock(side_effect=by_service_principal_id), + ) + ) + + result = asyncio.run( + entra_service._get_exchange_mailbox_permission_service_principals() + ) + + assert set(result.keys()) == {"sp-mailbox"} + assert result["sp-mailbox"].app_id == "app-mailbox" + assert result["sp-mailbox"].exchange_mailbox_permissions == ["Mail.Read"] + + def test__get_exchange_mailbox_permission_service_principals_records_error(self): + """ + Graph collection failures preserve unavailable state separately from empty results. + """ + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + service_principals=SimpleNamespace( + get=AsyncMock(side_effect=RuntimeError("Graph unavailable")) + ) + ) + + result = asyncio.run( + entra_service._get_exchange_mailbox_permission_service_principals() + ) + + assert result == {} + assert "RuntimeError" in ( + entra_service.exchange_mailbox_permission_service_principals_error + ) + assert "Graph unavailable" in ( + entra_service.exchange_mailbox_permission_service_principals_error + ) + def test__resolve_identifiers_for_type_flags_only_404(self): """Only HTTP 404 / Request_ResourceNotFound mark an id as deleted. diff --git a/tests/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps_test.py b/tests/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps_test.py new file mode 100644 index 0000000000..dc61d32ffb --- /dev/null +++ b/tests/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps_test.py @@ -0,0 +1,271 @@ +import importlib +from unittest import mock + +from prowler.providers.m365.services.entra import entra_service +from prowler.providers.m365.services.exchange import exchange_service +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE = ( + "prowler.providers.m365.services.exchange." + "exchange_application_access_policy_restricts_mailbox_apps." + "exchange_application_access_policy_restricts_mailbox_apps" +) + + +class Test_exchange_application_access_policy_restricts_mailbox_apps: + def test_powershell_unavailable_returns_manual(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = None + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "ExchangeOnlineTenant" + assert "Exchange Online PowerShell is unavailable" in result[0].status_extended + + def test_no_resources(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 0 + + def test_graph_collection_unavailable_returns_manual(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = {} + entra_client.exchange_mailbox_permission_service_principals_error = ( + "RuntimeError: Graph unavailable" + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "ExchangeOnlineTenant" + assert ( + "Microsoft Graph mailbox permission collection failed" + in result[0].status_extended + ) + + def test_service_principal_without_policy_fails(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = { + "sp-id": entra_service.ServicePrincipal( + id="sp-id", + name="Mailbox App", + app_id="app-id", + exchange_mailbox_permissions=["Mail.Read", "Mail.Send"], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "sp-id" + assert result[0].resource_name == "Mailbox App" + assert "app-id" in result[0].status_extended + assert "Mail.Read, Mail.Send" in result[0].status_extended + + def test_service_principal_with_policy_passes(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [ + exchange_service.ApplicationAccessPolicy( + identity="policy-id", + app_id="app-id", + access_right="RestrictAccess", + description="Restrict mailbox access", + ) + ] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = { + "sp-id": entra_service.ServicePrincipal( + id="sp-id", + name="Mailbox App", + app_id="app-id", + exchange_mailbox_permissions=["Mail.Read"], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "sp-id" + assert ( + "is restricted using an Application Access Policy" + in result[0].status_extended + ) + + def test_service_principal_with_deny_access_policy_fails(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [ + exchange_service.ApplicationAccessPolicy( + identity="policy-id", + app_id="app-id", + access_right="DenyAccess", + description="Deny mailbox access", + ) + ] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = { + "sp-id": entra_service.ServicePrincipal( + id="sp-id", + name="Mailbox App", + app_id="app-id", + exchange_mailbox_permissions=["Mail.Read"], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "sp-id" + assert result[0].resource_name == "Mailbox App" + assert ( + "is not restricted using an Application Access Policy" + in result[0].status_extended + )