mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(m365): add exchange application access policy check (#11247)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
This commit is contained in:
committed by
GitHub
parent
8cd008ba91
commit
b6f74c7284
@@ -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] = []
|
||||
|
||||
|
||||
+41
@@ -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": ""
|
||||
}
|
||||
+99
@@ -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.
|
||||
|
||||
|
||||
+271
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user