mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-11 04:08:34 +00:00
Compare commits
1 Commits
master
...
feat/prowl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fda4818063 |
@@ -12,6 +12,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- `awslambda_function_no_dead_letter_queue`, `awslambda_function_using_cross_account_layers`, and `awslambda_function_env_vars_not_encrypted_with_cmk` checks for AWS Lambda [(#10381)](https://github.com/prowler-cloud/prowler/pull/10381)
|
||||
- `entra_conditional_access_policy_mdm_compliant_device_required` check for M365 provider [(#10220)](https://github.com/prowler-cloud/prowler/pull/10220)
|
||||
- `directory_super_admin_only_admin_roles` check for Google Workspace provider [(#10488)](https://github.com/prowler-cloud/prowler/pull/10488)
|
||||
- `exchange_organization_delicensing_resiliency_enabled` check for m365 provider [(#10608)](https://github.com/prowler-cloud/prowler/pull/10608)
|
||||
- `ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip` check for AWS provider using `ipaddress.is_global` for accurate public IP detection [(#10335)](https://github.com/prowler-cloud/prowler/pull/10335)
|
||||
- `entra_conditional_access_policy_block_o365_elevated_insider_risk` check for M365 provider [(#10232)](https://github.com/prowler-cloud/prowler/pull/10232)
|
||||
- `--resource-group` and `--list-resource-groups` CLI flags to filter checks by resource group across all providers [(#10479)](https://github.com/prowler-cloud/prowler/pull/10479)
|
||||
|
||||
@@ -425,6 +425,7 @@
|
||||
],
|
||||
"Checks": [
|
||||
"admincenter_groups_not_public_visibility",
|
||||
"exchange_organization_delicensing_resiliency_enabled",
|
||||
"teams_meeting_recording_disabled"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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",
|
||||
"https://maester.dev/docs/tests/MT.1083"
|
||||
],
|
||||
"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; tenants below this threshold are automatically marked as compliant."
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"""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 = "MANUAL"
|
||||
report.status_extended = (
|
||||
"Delicensing Resiliency is not enabled for Exchange Online. "
|
||||
"This feature is only available to tenants with 5000 or more "
|
||||
"paid licenses. Verify whether the tenant qualifies and "
|
||||
"enable Delicensing Resiliency if applicable."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -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()
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
@@ -47,6 +48,50 @@ class Exchange(M365Service):
|
||||
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
|
||||
@@ -74,6 +119,9 @@ class Exchange(M365Service):
|
||||
mailtips_large_audience_threshold=organization_configuration.get(
|
||||
"MailTipsLargeAudienceThreshold", 25
|
||||
),
|
||||
delayed_delicensing_enabled=organization_configuration.get(
|
||||
"DelayedDelicensingEnabled", False
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
@@ -309,6 +357,22 @@ class Exchange(M365Service):
|
||||
|
||||
|
||||
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
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,278 @@
|
||||
from unittest import mock
|
||||
|
||||
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
|
||||
|
||||
ORGANIZATION_KWARGS = dict(
|
||||
name="test-org",
|
||||
guid="org-guid",
|
||||
audit_disabled=False,
|
||||
oauth_enabled=True,
|
||||
mailtips_enabled=True,
|
||||
mailtips_external_recipient_enabled=False,
|
||||
mailtips_group_metrics_enabled=True,
|
||||
mailtips_large_audience_threshold=25,
|
||||
)
|
||||
|
||||
|
||||
class Test_exchange_organization_delicensing_resiliency_enabled:
|
||||
def test_no_organization(self):
|
||||
exchange_client = mock.MagicMock()
|
||||
exchange_client.audited_tenant = "audited_tenant"
|
||||
exchange_client.audited_domain = DOMAIN
|
||||
exchange_client.organization_config = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client",
|
||||
new=exchange_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import (
|
||||
exchange_organization_delicensing_resiliency_enabled,
|
||||
)
|
||||
|
||||
check = exchange_organization_delicensing_resiliency_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
def test_delicensing_resiliency_disabled_above_threshold(self):
|
||||
"""Disabled + >= 5000 total licenses -> MANUAL (can't confirm paid count)."""
|
||||
exchange_client = mock.MagicMock()
|
||||
exchange_client.audited_tenant = "audited_tenant"
|
||||
exchange_client.audited_domain = DOMAIN
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client",
|
||||
new=exchange_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import (
|
||||
exchange_organization_delicensing_resiliency_enabled,
|
||||
)
|
||||
from prowler.providers.m365.services.exchange.exchange_service import (
|
||||
Organization,
|
||||
)
|
||||
|
||||
exchange_client.organization_config = Organization(
|
||||
**ORGANIZATION_KWARGS,
|
||||
delayed_delicensing_enabled=False,
|
||||
total_paid_licenses=6000,
|
||||
)
|
||||
|
||||
check = exchange_organization_delicensing_resiliency_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "Verify whether the tenant qualifies" in result[0].status_extended
|
||||
assert result[0].resource_name == "test-org"
|
||||
assert result[0].resource_id == "org-guid"
|
||||
assert result[0].location == "global"
|
||||
|
||||
def test_delicensing_resiliency_disabled_at_threshold(self):
|
||||
"""Disabled + exactly 5000 total licenses -> MANUAL."""
|
||||
exchange_client = mock.MagicMock()
|
||||
exchange_client.audited_tenant = "audited_tenant"
|
||||
exchange_client.audited_domain = DOMAIN
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client",
|
||||
new=exchange_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import (
|
||||
exchange_organization_delicensing_resiliency_enabled,
|
||||
)
|
||||
from prowler.providers.m365.services.exchange.exchange_service import (
|
||||
Organization,
|
||||
)
|
||||
|
||||
exchange_client.organization_config = Organization(
|
||||
**ORGANIZATION_KWARGS,
|
||||
delayed_delicensing_enabled=False,
|
||||
total_paid_licenses=5000,
|
||||
)
|
||||
|
||||
check = exchange_organization_delicensing_resiliency_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
|
||||
def test_delicensing_resiliency_disabled_below_threshold(self):
|
||||
"""Disabled + < 5000 total licenses -> PASS (not applicable)."""
|
||||
exchange_client = mock.MagicMock()
|
||||
exchange_client.audited_tenant = "audited_tenant"
|
||||
exchange_client.audited_domain = DOMAIN
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client",
|
||||
new=exchange_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import (
|
||||
exchange_organization_delicensing_resiliency_enabled,
|
||||
)
|
||||
from prowler.providers.m365.services.exchange.exchange_service import (
|
||||
Organization,
|
||||
)
|
||||
|
||||
exchange_client.organization_config = Organization(
|
||||
**ORGANIZATION_KWARGS,
|
||||
delayed_delicensing_enabled=False,
|
||||
total_paid_licenses=4999,
|
||||
)
|
||||
|
||||
check = exchange_organization_delicensing_resiliency_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "not applicable" in result[0].status_extended
|
||||
assert "4999 total licenses" in result[0].status_extended
|
||||
|
||||
def test_delicensing_resiliency_disabled_licenses_unknown(self):
|
||||
"""Disabled + unknown license count -> MANUAL."""
|
||||
exchange_client = mock.MagicMock()
|
||||
exchange_client.audited_tenant = "audited_tenant"
|
||||
exchange_client.audited_domain = DOMAIN
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client",
|
||||
new=exchange_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import (
|
||||
exchange_organization_delicensing_resiliency_enabled,
|
||||
)
|
||||
from prowler.providers.m365.services.exchange.exchange_service import (
|
||||
Organization,
|
||||
)
|
||||
|
||||
exchange_client.organization_config = Organization(
|
||||
**ORGANIZATION_KWARGS,
|
||||
delayed_delicensing_enabled=False,
|
||||
total_paid_licenses=None,
|
||||
)
|
||||
|
||||
check = exchange_organization_delicensing_resiliency_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "Verify whether the tenant qualifies" in result[0].status_extended
|
||||
|
||||
def test_delicensing_resiliency_enabled(self):
|
||||
"""Enabled -> PASS regardless of license count."""
|
||||
exchange_client = mock.MagicMock()
|
||||
exchange_client.audited_tenant = "audited_tenant"
|
||||
exchange_client.audited_domain = DOMAIN
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client",
|
||||
new=exchange_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import (
|
||||
exchange_organization_delicensing_resiliency_enabled,
|
||||
)
|
||||
from prowler.providers.m365.services.exchange.exchange_service import (
|
||||
Organization,
|
||||
)
|
||||
|
||||
exchange_client.organization_config = Organization(
|
||||
**ORGANIZATION_KWARGS,
|
||||
delayed_delicensing_enabled=True,
|
||||
total_paid_licenses=6000,
|
||||
)
|
||||
|
||||
check = exchange_organization_delicensing_resiliency_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "is enabled" in result[0].status_extended
|
||||
assert result[0].resource == exchange_client.organization_config.dict()
|
||||
assert result[0].resource_name == "test-org"
|
||||
assert result[0].resource_id == "org-guid"
|
||||
assert result[0].location == "global"
|
||||
|
||||
def test_delicensing_resiliency_enabled_below_threshold(self):
|
||||
"""Enabled + below threshold -> still PASS (enabled always wins)."""
|
||||
exchange_client = mock.MagicMock()
|
||||
exchange_client.audited_tenant = "audited_tenant"
|
||||
exchange_client.audited_domain = DOMAIN
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled.exchange_client",
|
||||
new=exchange_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.exchange.exchange_organization_delicensing_resiliency_enabled.exchange_organization_delicensing_resiliency_enabled import (
|
||||
exchange_organization_delicensing_resiliency_enabled,
|
||||
)
|
||||
from prowler.providers.m365.services.exchange.exchange_service import (
|
||||
Organization,
|
||||
)
|
||||
|
||||
exchange_client.organization_config = Organization(
|
||||
**ORGANIZATION_KWARGS,
|
||||
delayed_delicensing_enabled=True,
|
||||
total_paid_licenses=100,
|
||||
)
|
||||
|
||||
check = exchange_organization_delicensing_resiliency_enabled()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "is enabled" in result[0].status_extended
|
||||
@@ -161,6 +161,18 @@ def mock_exchange_get_shared_mailboxes(_):
|
||||
]
|
||||
|
||||
|
||||
async def mock_exchange_get_total_paid_licenses(_):
|
||||
return 6000
|
||||
|
||||
|
||||
async def mock_exchange_get_total_paid_licenses_none(_):
|
||||
return None
|
||||
|
||||
|
||||
@patch(
|
||||
"prowler.providers.m365.services.exchange.exchange_service.Exchange._get_total_paid_licenses",
|
||||
new=mock_exchange_get_total_paid_licenses,
|
||||
)
|
||||
class Test_Exchange_Service:
|
||||
def test_get_client(self):
|
||||
with (
|
||||
@@ -201,6 +213,7 @@ class Test_Exchange_Service:
|
||||
assert organization_config.mailtips_external_recipient_enabled is False
|
||||
assert organization_config.mailtips_group_metrics_enabled is True
|
||||
assert organization_config.mailtips_large_audience_threshold == 25
|
||||
assert organization_config.total_paid_licenses == 6000
|
||||
|
||||
exchange_client.powershell.close()
|
||||
|
||||
@@ -481,3 +494,27 @@ class Test_Exchange_Service:
|
||||
assert shared_mailboxes[1].identity == "info@contoso.com"
|
||||
|
||||
exchange_client.powershell.close()
|
||||
|
||||
def test_get_total_paid_licenses_none(self):
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
|
||||
),
|
||||
mock.patch.object(
|
||||
Exchange,
|
||||
"_get_organization_config",
|
||||
mock_exchange_get_organization_config,
|
||||
),
|
||||
mock.patch.object(
|
||||
Exchange,
|
||||
"_get_total_paid_licenses",
|
||||
mock_exchange_get_total_paid_licenses_none,
|
||||
),
|
||||
):
|
||||
exchange_client = Exchange(
|
||||
set_mocked_m365_provider(
|
||||
identity=M365IdentityInfo(tenant_domain=DOMAIN)
|
||||
)
|
||||
)
|
||||
assert exchange_client.organization_config.total_paid_licenses is None
|
||||
exchange_client.powershell.close()
|
||||
|
||||
Reference in New Issue
Block a user