From e710ebff1c9a77b8eff4bedd1cf83db4df14693f Mon Sep 17 00:00:00 2001 From: Jasmine Date: Tue, 9 Jun 2026 22:24:25 +0800 Subject: [PATCH] feat(m365): add `exchange_mailbox_primary_smtp_custom_domain` check (#11215) Co-authored-by: Jasmine Sullivan <20147180@tafe.wa.edu.au> Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + .../m365/lib/powershell/m365_powershell.py | 26 ++ .../__init__.py | 0 ...mary_smtp_uses_custom_domain.metadata.json | 39 +++ ...mailbox_primary_smtp_uses_custom_domain.py | 79 +++++ .../services/exchange/exchange_service.py | 74 +++++ ...ox_primary_smtp_uses_custom_domain_test.py | 300 ++++++++++++++++++ .../exchange/exchange_service_test.py | 98 ++++++ 8 files changed, 617 insertions(+) create mode 100644 prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/__init__.py create mode 100644 prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json create mode 100644 prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py create mode 100644 tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 69fe6276b7..42c794a49f 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - External multi-provider compliance frameworks can be registered via the `prowler.compliance.universal` entry point group [(#11490)](https://github.com/prowler-cloud/prowler/pull/11490) - AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475) - `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070) +- `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215) ### 🐞 Fixed diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index 1bc8aa4075..4dae094d90 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -950,6 +950,32 @@ class M365PowerShell(PowerShellSession): "Get-TeamsProtectionPolicy | ConvertTo-Json -Depth 10", json_parse=True ) + def get_mailboxes(self) -> dict: + """ + Get Exchange Online Recipient-Facing Mailboxes. + + Retrieves all recipient-facing mailboxes from Exchange Online with the + properties needed to evaluate primary SMTP domain policy. + + Returns: + dict: Mailbox information in JSON format. + + Example: + >>> get_mailboxes() + [ + { + "Identity": "user1@contoso.com", + "DisplayName": "User One", + "PrimarySmtpAddress": "user1@contoso.com", + "RecipientTypeDetails": "UserMailbox" + } + ] + """ + return self.execute( + "Get-EXOMailbox -ResultSize Unlimited | Select-Object Identity, DisplayName, PrimarySmtpAddress, RecipientTypeDetails | ConvertTo-Json -Depth 10", + json_parse=True, + ) + def get_shared_mailboxes(self) -> dict: """ Get Exchange Online Shared Mailboxes. diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/__init__.py b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json new file mode 100644 index 0000000000..66ce055152 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "m365", + "CheckID": "exchange_mailbox_primary_smtp_uses_custom_domain", + "CheckTitle": "Mailbox primary SMTP address must use a custom domain", + "CheckType": [], + "ServiceName": "exchange", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "**Exchange Online mailboxes** should use a custom domain as their primary SMTP address, not the default **\\*.onmicrosoft.com** routing domain assigned by Microsoft on tenant creation. This check verifies that the **PrimarySmtpAddress** of every user-facing mailbox does not end with `.onmicrosoft.com`.", + "Risk": "Mailboxes still using **.onmicrosoft.com** as their primary SMTP address leak the internal **tenant identifier** in every From: header, helping attackers fingerprint the tenant for spear-phishing. They also bypass **DMARC/DKIM** hardening that organisations deploy on their custom domains and are frequently treated as low-trust by recipient anti-phishing engines.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/add-domain", + "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/domains-faq", + "https://learn.microsoft.com/en-us/powershell/module/exchange/get-mailbox", + "https://learn.microsoft.com/en-us/exchange/recipients-in-exchange-online/manage-user-mailboxes/manage-user-mailboxes" + ], + "Remediation": { + "Code": { + "CLI": "Get-Mailbox -ResultSize Unlimited | Where-Object { $_.PrimarySmtpAddress -like '*.onmicrosoft.com' } | ForEach-Object { Set-Mailbox -Identity $_.Identity -PrimarySmtpAddress '@' }", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft 365 admin center (https://admin.microsoft.com/)\n2. Go to Users > Active users and select the affected user\n3. Under the Aliases section, add the custom domain email address\n4. Set the custom domain address as the primary SMTP address\n5. Save changes and repeat for all affected mailboxes", + "Terraform": "" + }, + "Recommendation": { + "Text": "Update the primary SMTP address of all affected mailboxes to use a custom domain. Ensure your custom domain is verified in the Microsoft 365 admin center before making this change.", + "Url": "https://hub.prowler.com/check/exchange_mailbox_primary_smtp_uses_custom_domain" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py new file mode 100644 index 0000000000..d34d091145 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py @@ -0,0 +1,79 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.exchange.exchange_client import exchange_client + + +class exchange_mailbox_primary_smtp_uses_custom_domain(Check): + """ + Verify that every Exchange Online mailbox uses a custom domain as its + primary SMTP address, not the default .onmicrosoft.com routing domain. + + The .onmicrosoft.com domain is assigned by Microsoft on tenant creation + and is not intended for ongoing mail. Mailboxes still using it leak the + internal tenant identifier in every From: header (aiding spear-phishing), + bypass DMARC/DKIM hardening on custom domains and are often treated as + low-trust by recipient anti-phishing engines. + + - PASS: Primary SMTP address does not use the .onmicrosoft.com domain. + - FAIL: Primary SMTP address uses the .onmicrosoft.com domain. + - MANUAL: Exchange Online PowerShell unavailable; check cannot run. + """ + + def execute(self) -> List[CheckReportM365]: + """ + Execute the check against all recipient-facing Exchange Online mailboxes. + + Returns: + List[CheckReportM365]: A report for each mailbox with its SMTP + domain status, or a single MANUAL report if PowerShell was + unavailable. + """ + findings = [] + + # mailboxes is None when Exchange Online PowerShell could not be + # reached or the cmdlet raised. An empty list means PowerShell ran + # but the tenant has no recipient-facing mailboxes (no findings). + if exchange_client.mailboxes is None: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Exchange Online Mailboxes", + resource_id="exchange_mailboxes", + ) + report.status = "MANUAL" + report.status_extended = ( + "Exchange Online PowerShell is unavailable. " + "Enable EXO PowerShell access to run this check." + ) + findings.append(report) + return findings + + for mailbox in exchange_client.mailboxes: + report = CheckReportM365( + metadata=self.metadata(), + resource=mailbox, + resource_name=mailbox.name or mailbox.identity, + resource_id=mailbox.identity, + ) + + if mailbox.primary_smtp_address.endswith(".onmicrosoft.com"): + report.status = "FAIL" + report.status_extended = ( + f"Mailbox {mailbox.identity} " + f"({mailbox.recipient_type_details}) has primary SMTP " + f"address {mailbox.primary_smtp_address} using the " + f".onmicrosoft.com domain instead of a custom domain." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Mailbox {mailbox.identity} " + f"({mailbox.recipient_type_details}) has primary SMTP " + f"address {mailbox.primary_smtp_address} using a " + f"custom domain." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/exchange/exchange_service.py b/prowler/providers/m365/services/exchange/exchange_service.py index 9b3ee47da9..3ce6528aa9 100644 --- a/prowler/providers/m365/services/exchange/exchange_service.py +++ b/prowler/providers/m365/services/exchange/exchange_service.py @@ -8,6 +8,15 @@ from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service from prowler.providers.m365.m365_provider import M365Provider +SYSTEM_MAILBOX_TYPES = { + "DiscoveryMailbox", + "ArbitrationMailbox", + "AuditLogMailbox", + "MonitoringMailbox", + "AuxAuditLogMailbox", + "SystemMailbox", +} + class Exchange(M365Service): """ @@ -34,6 +43,7 @@ class Exchange(M365Service): self.role_assignment_policies = [] self.mailbox_audit_properties = [] self.shared_mailboxes = [] + self.mailboxes = None if self.powershell: if self.powershell.connect_exchange_online(): @@ -46,6 +56,7 @@ 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.mailboxes = self._get_mailboxes() self.powershell.close() # Fetch license count via Graph API @@ -355,6 +366,50 @@ class Exchange(M365Service): ) return shared_mailboxes + def _get_mailboxes(self) -> Optional[list["Mailbox"]]: + """ + Get all recipient-facing mailboxes from Exchange Online. + + Retrieves mailboxes of types UserMailbox, SharedMailbox, RoomMailbox + and EquipmentMailbox. System-managed mailbox types are excluded as + they are controlled by Microsoft and are not subject to domain policy. + + Returns: + list[Mailbox]: List of mailboxes with their primary SMTP address + and recipient type details. Returns ``None`` when the + underlying PowerShell cmdlet raises, so callers can + distinguish "PowerShell unavailable" from "empty tenant". + """ + logger.info("Microsoft365 - Getting mailboxes...") + mailboxes = [] + try: + mailboxes_data = self.powershell.get_mailboxes() + if not mailboxes_data: + return mailboxes + # PowerShell can return a single dict instead of a list when only + # one result is returned; normalize to a list for uniform handling. + if isinstance(mailboxes_data, dict): + mailboxes_data = [mailboxes_data] + for mailbox in mailboxes_data: + if mailbox: + recipient_type = mailbox.get("RecipientTypeDetails", "") + if recipient_type in SYSTEM_MAILBOX_TYPES: + continue + mailboxes.append( + Mailbox( + identity=mailbox.get("Identity", ""), + name=mailbox.get("DisplayName", ""), + primary_smtp_address=mailbox.get("PrimarySmtpAddress", ""), + recipient_type_details=recipient_type, + ) + ) + return mailboxes + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + class Organization(BaseModel): """ @@ -497,3 +552,22 @@ class SharedMailbox(BaseModel): user_principal_name: str external_directory_object_id: str identity: str + + +class Mailbox(BaseModel): + """ + Model for an Exchange Online recipient-facing mailbox. + + Attributes: + identity: The unique identity of the mailbox in Exchange. + name: Display name of the mailbox. + primary_smtp_address: The primary SMTP address used for outbound mail + and the From: header. This is the address the check evaluates. + recipient_type_details: The mailbox type (e.g., UserMailbox, + SharedMailbox, RoomMailbox, EquipmentMailbox). + """ + + identity: str + name: str + primary_smtp_address: str + recipient_type_details: str diff --git a/tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py b/tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py new file mode 100644 index 0000000000..9ec073a922 --- /dev/null +++ b/tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py @@ -0,0 +1,300 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_exchange_mailbox_primary_smtp_uses_custom_domain: + + def test_powershell_unavailable_manual(self): + """MANUAL: Exchange Online PowerShell unavailable (mailboxes is None).""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.mailboxes = 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_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "PowerShell" in result[0].status_extended + assert result[0].resource_name == "Exchange Online Mailboxes" + assert result[0].resource_id == "exchange_mailboxes" + + def test_empty_tenant_no_findings(self): + """Empty tenant (no mailboxes) produces zero findings, not MANUAL.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.mailboxes = [] + + 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_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert result == [] + + def test_custom_domain_passes(self): + """PASS: Mailbox primary SMTP uses a custom domain.""" + 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_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="user1@contoso.com", + name="User One", + primary_smtp_address="user1@contoso.com", + recipient_type_details="UserMailbox", + ) + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "custom domain" in result[0].status_extended + assert result[0].resource_name == "User One" + assert result[0].resource_id == "user1@contoso.com" + assert result[0].location == "global" + + def test_onmicrosoft_domain_fails(self): + """FAIL: Mailbox primary SMTP uses .onmicrosoft.com.""" + 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_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="user1@contoso.onmicrosoft.com", + name="User One", + primary_smtp_address="user1@contoso.onmicrosoft.com", + recipient_type_details="UserMailbox", + ) + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ".onmicrosoft.com" in result[0].status_extended + assert result[0].resource_name == "User One" + assert result[0].resource_id == "user1@contoso.onmicrosoft.com" + assert result[0].location == "global" + + def test_mixed_mailboxes(self): + """Test multiple mailboxes with mixed domain status.""" + 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_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="user1@contoso.com", + name="User One", + primary_smtp_address="user1@contoso.com", + recipient_type_details="UserMailbox", + ), + Mailbox( + identity="shared@contoso.onmicrosoft.com", + name="Shared Mailbox", + primary_smtp_address="shared@contoso.onmicrosoft.com", + recipient_type_details="SharedMailbox", + ), + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 2 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Mailbox user1@contoso.com (UserMailbox) has primary SMTP address user1@contoso.com using a custom domain." + ) + assert result[1].status == "FAIL" + assert ( + result[1].status_extended + == "Mailbox shared@contoso.onmicrosoft.com (SharedMailbox) has primary SMTP address shared@contoso.onmicrosoft.com using the .onmicrosoft.com domain instead of a custom domain." + ) + + def test_room_mailbox_custom_domain(self): + """PASS: Room mailbox using a custom domain.""" + 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_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="boardroom@contoso.com", + name="Board Room", + primary_smtp_address="boardroom@contoso.com", + recipient_type_details="RoomMailbox", + ) + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "boardroom@contoso.com" + assert result[0].location == "global" + + def test_equipment_mailbox_onmicrosoft(self): + """FAIL: Equipment mailbox using .onmicrosoft.com.""" + 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_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="projector@contoso.onmicrosoft.com", + name="Projector", + primary_smtp_address="projector@contoso.onmicrosoft.com", + recipient_type_details="EquipmentMailbox", + ) + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "projector@contoso.onmicrosoft.com" + assert result[0].location == "global" diff --git a/tests/providers/m365/services/exchange/exchange_service_test.py b/tests/providers/m365/services/exchange/exchange_service_test.py index 8eed77ca99..8d812e3bb3 100644 --- a/tests/providers/m365/services/exchange/exchange_service_test.py +++ b/tests/providers/m365/services/exchange/exchange_service_test.py @@ -495,6 +495,104 @@ class Test_Exchange_Service: exchange_client.powershell.close() + @patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_mailboxes", + return_value=[ + { + "Identity": "user1@contoso.com", + "DisplayName": "User One", + "PrimarySmtpAddress": "user1@contoso.com", + "RecipientTypeDetails": "UserMailbox", + }, + { + "Identity": "room@contoso.com", + "DisplayName": "Boardroom", + "PrimarySmtpAddress": "room@contoso.com", + "RecipientTypeDetails": "RoomMailbox", + }, + { + "Identity": "DiscoverySearchMailbox{D919BA05}", + "DisplayName": "Discovery Search Mailbox", + "PrimarySmtpAddress": "DiscoverySearchMailbox@contoso.onmicrosoft.com", + "RecipientTypeDetails": "DiscoveryMailbox", + }, + { + "Identity": "SystemMailbox{1f05a927}", + "DisplayName": "Microsoft Exchange", + "PrimarySmtpAddress": "SystemMailbox@contoso.onmicrosoft.com", + "RecipientTypeDetails": "SystemMailbox", + }, + ], + ) + def test_get_mailboxes_excludes_system_types(self, _mock_get_mailboxes): + with ( + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online", + return_value=True, + ), + ): + exchange_client = Exchange( + set_mocked_m365_provider( + identity=M365IdentityInfo(tenant_domain=DOMAIN) + ) + ) + mailboxes = exchange_client.mailboxes + assert mailboxes is not None + assert len(mailboxes) == 2 + identities = {m.identity for m in mailboxes} + assert identities == {"user1@contoso.com", "room@contoso.com"} + assert all( + m.recipient_type_details not in {"DiscoveryMailbox", "SystemMailbox"} + for m in mailboxes + ) + exchange_client.powershell.close() + + @patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_mailboxes", + return_value={ + "Identity": "user1@contoso.com", + "DisplayName": "User One", + "PrimarySmtpAddress": "user1@contoso.com", + "RecipientTypeDetails": "UserMailbox", + }, + ) + def test_get_mailboxes_single_dict(self, _mock_get_mailboxes): + with ( + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online", + return_value=True, + ), + ): + exchange_client = Exchange( + set_mocked_m365_provider( + identity=M365IdentityInfo(tenant_domain=DOMAIN) + ) + ) + mailboxes = exchange_client.mailboxes + assert mailboxes is not None + assert len(mailboxes) == 1 + assert mailboxes[0].identity == "user1@contoso.com" + exchange_client.powershell.close() + + @patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_mailboxes", + side_effect=Exception("Get-EXOMailbox failed"), + ) + def test_get_mailboxes_returns_none_on_exception(self, _mock_get_mailboxes): + with ( + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online", + return_value=True, + ), + ): + exchange_client = Exchange( + set_mocked_m365_provider( + identity=M365IdentityInfo(tenant_domain=DOMAIN) + ) + ) + assert exchange_client.mailboxes is None + exchange_client.powershell.close() + def test_get_total_paid_licenses_none(self): with ( mock.patch(