mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-09 04:38:49 +00:00
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 <danielbo2001@gmail.com>
This commit is contained in:
8 files changed
+617
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Whitespace-only changes.
+39
@@ -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 '<user>@<customdomain.com>' }",
|
||||
"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": ""
|
||||
}
|
||||
+79
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user