feat(m365): add exchange application access policy check (#11247)

Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
This commit is contained in:
Maringanti Vasist Acharya
2026-07-02 14:46:07 +05:30
committed by GitHub
parent 8cd008ba91
commit b6f74c7284
9 changed files with 805 additions and 35 deletions
+1
View File
@@ -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)
@@ -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.
@@ -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] = []
@@ -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 <AppId> -PolicyScopeGroupId <Group> -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": ""
}
@@ -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
@@ -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.
@@ -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.
@@ -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
)