Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions prowler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `iam_role_access_not_stale_to_bedrock` and `iam_user_access_not_stale_to_bedrock` checks for AWS provider [(#10536)](https://github.com/prowler-cloud/prowler/pull/10536)
- `iam_policy_no_wildcard_marketplace_subscribe` and `iam_inline_policy_no_wildcard_marketplace_subscribe` checks for AWS provider [(#10525)](https://github.com/prowler-cloud/prowler/pull/10525)
- `bedrock_vpc_endpoints_configured` check for AWS provider [(#10591)](https://github.com/prowler-cloud/prowler/pull/10591)
- `exchange_organization_delicensing_resiliency_enabled` check for m365 provider [(#10608)](https://github.com/prowler-cloud/prowler/pull/10608)

---

Expand Down
1 change: 1 addition & 0 deletions prowler/compliance/m365/iso27001_2022_m365.json
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@
],
"Checks": [
"admincenter_groups_not_public_visibility",
"exchange_organization_delicensing_resiliency_enabled",
"teams_meeting_recording_disabled"
]
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"Provider": "m365",
"CheckID": "exchange_organization_delicensing_resiliency_enabled",
"CheckTitle": "Delicensing Resiliency protects Exchange Online mailboxes from immediate access loss during license changes",
"CheckType": [],
"ServiceName": "exchange",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "**Microsoft 365 Exchange Online** Delicensing Resiliency provides a grace period when licenses expire or are reassigned, preventing immediate mailbox access loss.\n\nThis evaluates whether the organization has **Delayed Delicensing** enabled to protect mailbox data during licensing transitions. Note: This feature is only available to tenants with 5000 or more paid licenses.",
"Risk": "Without **Delicensing Resiliency**, removing or reassigning an Exchange Online license causes **immediate mailbox inaccessibility**. This can lead to data loss, business disruption, and inability to recover mailbox contents during organizational changes such as role transitions or license optimizations.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/exchange/recipients-in-exchange-online/manage-user-mailboxes/delicensing-resiliency"
],
"Remediation": {
"Code": {
"CLI": "Set-OrganizationConfig -DelayedDelicensingEnabled $true",
"NativeIaC": "",
"Other": "1. Connect to Exchange Online PowerShell\n2. Run: Set-OrganizationConfig -DelayedDelicensingEnabled $true\n3. Verify with: Get-OrganizationConfig | Format-List DelayedDelicensingEnabled",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable **Delicensing Resiliency** to ensure mailbox data is preserved during license transitions. This provides a grace period allowing administrators to reassign licenses or export data before access is permanently revoked, maintaining **business continuity** and **data protection**.",
"Url": "https://hub.prowler.com/check/exchange_organization_delicensing_resiliency_enabled"
}
},
"Categories": [
"resilience"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check includes an automated fixer that runs `Set-OrganizationConfig -DelayedDelicensingEnabled $true` via Exchange Online PowerShell. Delicensing Resiliency is only available to tenants with 5000 or more paid licenses; trial licenses also count toward this threshold but cannot be discerned from the SDK, so tenants at or above the threshold (or with an unknown license count) are reported as a preventive FAIL and eligibility can be confirmed by running the fixer, which succeeds on qualifying tenants and fails otherwise."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Check for Exchange Online Delicensing Resiliency configuration."""

from typing import List

from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.exchange.exchange_client import exchange_client

DELICENSING_LICENSE_THRESHOLD = 5000


class exchange_organization_delicensing_resiliency_enabled(Check):
"""
Check if Delicensing Resiliency is enabled for Exchange Online.

Delicensing Resiliency provides a grace period when licenses expire or are
reassigned, preventing immediate mailbox access loss and allowing
organizations time to manage licensing transitions.

This feature is only available to tenants with 5000 or more paid licenses.

Attributes:
metadata: Metadata associated with the check (inherited from Check).
"""

def execute(self) -> List[CheckReportM365]:
"""
Execute the check for Delicensing Resiliency in Exchange Online.

Iterates over the Exchange Online organization configuration and
evaluates whether Delicensing Resiliency is enabled, taking into
account the tenant's paid license count.

Returns:
List[CheckReportM365]: A list of reports containing the result of the check.
"""
findings = []
organization_config = exchange_client.organization_config
if organization_config:
report = CheckReportM365(
metadata=self.metadata(),
resource=organization_config,
resource_name=organization_config.name,
resource_id=organization_config.guid,
)

if organization_config.delayed_delicensing_enabled:
report.status = "PASS"
report.status_extended = (
"Delicensing Resiliency is enabled for Exchange Online, "
"providing a grace period when licenses are removed."
)
elif (
organization_config.total_paid_licenses is not None
and organization_config.total_paid_licenses
< DELICENSING_LICENSE_THRESHOLD
):
report.status = "PASS"
report.status_extended = (
f"Delicensing Resiliency is not applicable for this tenant. "
f"The tenant has {organization_config.total_paid_licenses} "
f"total licenses, which is below the "
f"{DELICENSING_LICENSE_THRESHOLD} paid license threshold "
f"required by Microsoft for this feature."
)
else:
report.status = "FAIL"
report.status_extended = (
"Delicensing Resiliency is not enabled for Exchange Online. "
"This feature requires the tenant to have 5000 or more paid "
"licenses, and trial licenses also count toward this "
"threshold but cannot be discerned from the SDK, so this "
"is reported as a preventive FAIL. Running the fixer will "
"enable the feature when the tenant qualifies and will "
"fail otherwise, confirming eligibility."
)

findings.append(report)

return findings
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Fixer for Exchange Online Delicensing Resiliency."""

from prowler.lib.logger import logger
from prowler.providers.common.provider import Provider
from prowler.providers.m365.lib.powershell.m365_powershell import M365PowerShell


def fixer(resource_id: str = "") -> bool:
"""Enable Delicensing Resiliency in Exchange Online.

Args:
resource_id (str): Unused for this organization-level fixer.

Returns:
bool: True when the fixer command succeeds, False otherwise.
"""
session = None

try:
provider = Provider.get_global_provider()
if not provider:
logger.error("Unable to load the global M365 provider for Exchange Online.")
return False

credentials = getattr(provider, "credentials", None)
identity = getattr(provider, "identity", None)
if not credentials or not identity:
logger.error(
"Unable to load the M365 credentials required for Exchange Online."
)
return False

session = M365PowerShell(credentials, identity)
if not session.connect_exchange_online():
logger.error("Unable to connect to Exchange Online PowerShell.")
return False

result = session.execute(
"Set-OrganizationConfig -DelayedDelicensingEnabled $true",
timeout=30,
)
if result:
logger.error(
"PowerShell execution failed while running "
'"Set-OrganizationConfig -DelayedDelicensingEnabled $true": '
f"{result}"
)
return False
return True
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return False
finally:
if session:
session.close()
66 changes: 66 additions & 0 deletions prowler/providers/m365/services/exchange/exchange_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
from enum import Enum
from typing import Optional

Expand Down Expand Up @@ -47,6 +48,50 @@ def __init__(self, provider: M365Provider):
self.shared_mailboxes = self._get_shared_mailboxes()
self.powershell.close()

# Fetch license count via Graph API
created_loop = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True

if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True

if not loop.is_running():
total_paid_licenses = loop.run_until_complete(
self._get_total_paid_licenses()
)

if created_loop:
asyncio.set_event_loop(None)
loop.close()

if self.organization_config is not None:
self.organization_config.total_paid_licenses = total_paid_licenses

async def _get_total_paid_licenses(self) -> Optional[int]:
"""Fetch total paid license count from Microsoft Graph subscribed SKUs."""
logger.info("Microsoft365 - Getting total paid license count...")
try:
subscribed_skus = await self.client.subscribed_skus.get()
total = 0
for sku in getattr(subscribed_skus, "value", []) or []:
prepaid_units = getattr(sku, "prepaid_units", None)
if prepaid_units:
enabled = getattr(prepaid_units, "enabled", 0) or 0
total += enabled
return total
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None

def _get_organization_config(self):
logger.info("Microsoft365 - Getting Exchange Organization configuration...")
organization_config = None
Expand Down Expand Up @@ -74,6 +119,9 @@ def _get_organization_config(self):
mailtips_large_audience_threshold=organization_configuration.get(
"MailTipsLargeAudienceThreshold", 25
),
delayed_delicensing_enabled=organization_configuration.get(
"DelayedDelicensingEnabled", False
),
)
except Exception as error:
logger.error(
Expand Down Expand Up @@ -309,6 +357,22 @@ def _get_shared_mailboxes(self):


class Organization(BaseModel):
"""
Model for Exchange Online organization configuration.

Attributes:
name: Organization display name.
guid: Organization unique identifier.
audit_disabled: Whether auditing is disabled for the organization.
oauth_enabled: Whether OAuth 2.0 (Modern Authentication) is enabled.
mailtips_enabled: Whether MailTips are enabled.
mailtips_external_recipient_enabled: Whether MailTips for external recipients are enabled.
mailtips_group_metrics_enabled: Whether MailTips group metrics are enabled.
mailtips_large_audience_threshold: Threshold for large audience MailTips.
delayed_delicensing_enabled: Whether Delicensing Resiliency is enabled.
total_paid_licenses: Total paid licenses in the tenant, or None if unknown.
"""

name: str
guid: str
audit_disabled: bool
Expand All @@ -317,6 +381,8 @@ class Organization(BaseModel):
mailtips_external_recipient_enabled: bool
mailtips_group_metrics_enabled: bool
mailtips_large_audience_threshold: int
delayed_delicensing_enabled: bool = False
total_paid_licenses: Optional[int] = None


class MailboxAuditConfig(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from unittest import mock

from tests.providers.m365.m365_fixtures import set_mocked_m365_provider


class Test_exchange_organization_delicensing_resiliency_enabled_fixer:
def test_creates_new_powershell_session(self):
created_session = mock.MagicMock()
created_session.connect_exchange_online.return_value = True
created_session.execute.return_value = ""

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_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled_fixer.M365PowerShell",
return_value=created_session,
) as mocked_powershell,
):
from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled_fixer import (
fixer,
)

assert fixer()
mocked_powershell.assert_called_once()
created_session.connect_exchange_online.assert_called_once()
created_session.execute.assert_any_call(
"Set-OrganizationConfig -DelayedDelicensingEnabled $true",
timeout=30,
)
created_session.close.assert_called_once()

def test_logs_power_shell_execution_error(self):
created_session = mock.MagicMock()
created_session.connect_exchange_online.return_value = True
created_session.execute.return_value = "Access is denied."

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_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled_fixer.M365PowerShell",
return_value=created_session,
),
mock.patch(
"prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled_fixer.logger.error",
) as mocked_logger_error,
):
from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled_fixer import (
fixer,
)

assert not fixer()
mocked_logger_error.assert_any_call(
'PowerShell execution failed while running "Set-OrganizationConfig -DelayedDelicensingEnabled $true": Access is denied.'
)
created_session.close.assert_called_once()
Loading
Loading