feat(m365): add defender_safelinks_policy_enabled security check (#9832)

Co-authored-by: HugoPBrito <hugopbrit@gmail.com>
This commit is contained in:
Andoni Alonso
2026-02-11 13:03:32 +01:00
committed by GitHub
parent f5345a3982
commit c5f7e80b20
11 changed files with 902 additions and 14 deletions
+1
View File
@@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
- `defender_safelinks_policy_enabled` check for M365 provider [(#9832)](https://github.com/prowler-cloud/prowler/pull/9832)
- AI Skills: Added a skill for creating new Attack Paths queries in openCypher, compatible with Neo4j and Neptune [(#9975)](https://github.com/prowler-cloud/prowler/pull/9975)
### 🔄 Changed
+3 -1
View File
@@ -318,7 +318,9 @@
{
"Id": "2.1.1",
"Description": "Enabling Safe Links policy for Office applications allows URL's that exist inside of Office documents and email applications opened by Office, Office Online and Office mobile to be processed against Defender for Office time-of-click verification and rewritten if required.**Note:** E5 Licensing includes a number of Built-in Protection policies. When auditing policies note which policy you are viewing, and keep in mind CIS recommendations often extend the Default or Build-in Policies provided by MS. In order to **Pass** the highest priority policy must match all settings recommended.",
"Checks": [],
"Checks": [
"defender_safelinks_policy_enabled"
],
"Attributes": [
{
"Section": "2 Microsoft 365 Defender",
+3 -1
View File
@@ -339,7 +339,9 @@
{
"Id": "2.1.1",
"Description": "Safe Links for Office Applications is a feature in Microsoft 365 that provides URL scanning and rewriting of inbound email messages in mail flow, and time-of-click verification of URLs and links in email messages, other Microsoft 365 applications such as Teams, and other locations such as SharePoint Online. It can help protect organizations from malicious links used in phishing and other attacks.",
"Checks": [],
"Checks": [
"defender_safelinks_policy_enabled"
],
"Attributes": [
{
"Section": "2 Microsoft 365 Defender",
@@ -115,6 +115,7 @@
"defender_antiphishing_policy_configured",
"defender_antispam_outbound_policy_configured",
"defender_malware_policy_notifications_internal_users_malware_enabled",
"defender_safelinks_policy_enabled",
"defender_zap_for_teams_enabled",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_identity_protection_sign_in_risk_enabled",
@@ -692,6 +693,7 @@
"defender_malware_policy_common_attachments_filter_enabled",
"defender_malware_policy_comprehensive_attachments_filter_applied",
"defender_malware_policy_notifications_internal_users_malware_enabled",
"defender_safelinks_policy_enabled",
"defender_zap_for_teams_enabled",
"teams_external_domains_restricted",
"teams_external_users_cannot_start_conversations"
@@ -729,6 +731,7 @@
],
"Checks": [
"defender_antiphishing_policy_configured",
"defender_safelinks_policy_enabled",
"entra_admin_users_phishing_resistant_mfa_enabled"
]
},
@@ -833,10 +836,11 @@
}
],
"Checks": [
"teams_external_domains_restricted",
"teams_external_users_cannot_start_conversations",
"defender_safelinks_policy_enabled",
"sharepoint_external_sharing_managed",
"sharepoint_external_sharing_restricted",
"sharepoint_external_sharing_managed"
"teams_external_domains_restricted",
"teams_external_users_cannot_start_conversations"
]
},
{
+12 -1
View File
@@ -193,10 +193,21 @@ class PowerShellSession:
result = default
if error_result:
logger.error(f"PowerShell error output: {error_result}")
self._process_error(error_result)
return result
def _process_error(self, error_result: str) -> None:
"""
Process error output from the PowerShell command.
Subclasses can override this to provide custom error handling.
Args:
error_result (str): The error output from the PowerShell command.
"""
logger.error(f"PowerShell error output: {error_result}")
def json_parse_output(self, output: str) -> dict:
"""
Parse command execution output to JSON format.
@@ -1,4 +1,7 @@
import os
import re
from typing_extensions import override
from prowler.lib.logger import logger
from prowler.lib.powershell.powershell import PowerShellSession
@@ -46,6 +49,28 @@ class M365PowerShell(PowerShellSession):
self.tenant_identity = identity
self.init_credential(credentials)
@override
def _process_error(self, error_result: str) -> None:
"""
Process PowerShell errors with M365-specific handling.
Detects cmdlet not found errors which typically indicate missing licensing
(e.g., Microsoft Defender for Office 365) or insufficient permissions.
Args:
error_result (str): The error output from the PowerShell command.
"""
if "is not recognized as a name of a cmdlet" in error_result:
cmdlet_match = re.search(r"'([^']+)'.*is not recognized", error_result)
cmdlet_name = cmdlet_match.group(1) if cmdlet_match else "Unknown"
logger.warning(
f"PowerShell cmdlet '{cmdlet_name}' is not available. "
f"This may indicate missing licensing (e.g., Microsoft Defender for Office 365) "
f"or insufficient permissions. Related checks will be skipped."
)
else:
super()._process_error(error_result)
def clean_certificate_content(self, cert_content: str) -> str:
"""
Clean certificate content for PowerShell consumption.
@@ -883,6 +908,57 @@ class M365PowerShell(PowerShellSession):
json_parse=True,
)
def get_safe_links_policy(self) -> dict:
"""
Get Safe Links Policy.
Retrieves the current Safe Links policy settings for Microsoft Defender for Office 365.
Returns:
dict: Safe Links policy settings in JSON format.
Example:
>>> get_safe_links_policy()
{
"Name": "Built-In Protection Policy",
"Identity": "Built-In Protection Policy",
"EnableSafeLinksForEmail": true,
"EnableSafeLinksForTeams": true,
"EnableSafeLinksForOffice": true,
"TrackClicks": true,
"AllowClickThrough": false,
"ScanUrls": true,
"EnableForInternalSenders": true,
"DeliverMessageAfterScan": true,
"DisableUrlRewrite": false
}
"""
return self.execute(
"Get-SafeLinksPolicy | ConvertTo-Json -Depth 10", json_parse=True
)
def get_safe_links_rule(self) -> dict:
"""
Get Safe Links Rule.
Retrieves the current Safe Links rule settings for Microsoft Defender for Office 365.
Returns:
dict: Safe Links rule settings in JSON format.
Example:
>>> get_safe_links_rule()
{
"Name": "Safe Links Rule",
"State": "Enabled",
"Priority": 0,
"SafeLinksPolicy": "Policy Name"
}
"""
return self.execute(
"Get-SafeLinksRule | ConvertTo-Json -Depth 10", json_parse=True
)
# This function is used to install the required M365 PowerShell modules in Docker containers
def initialize_m365_powershell_modules():
@@ -0,0 +1,40 @@
{
"Provider": "m365",
"CheckID": "defender_safelinks_policy_enabled",
"CheckTitle": "Safe Links policy is enabled and properly configured in Microsoft Defender for Office 365.",
"CheckType": [],
"ServiceName": "defender",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Defender Safe Links Policy",
"ResourceGroup": "security",
"Description": "Safe Links is a Microsoft Defender for Office 365 feature that provides URL scanning and rewriting of inbound email messages, as well as time-of-click verification of URLs and links in email messages, Teams, and Office apps.\n\nThis check verifies that the Safe Links policy is properly configured with all recommended settings enabled.",
"Risk": "Without properly configured Safe Links protection, users may be vulnerable to **phishing attacks** and **malicious URLs** in emails, Teams messages, and Office documents.\n\nAttackers could bypass security by using URLs that redirect to malicious content after initial scanning.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/defender-office-365/safe-links-about",
"https://learn.microsoft.com/en-us/defender-office-365/safe-links-policies-configure"
],
"Remediation": {
"Code": {
"CLI": "Set-SafeLinksPolicy -Identity 'Built-In Protection Policy' -EnableSafeLinksForEmail $true -EnableSafeLinksForTeams $true -EnableSafeLinksForOffice $true -TrackClicks $true -AllowClickThrough $false -ScanUrls $true -EnableForInternalSenders $true -DeliverMessageAfterScan $true -DisableUrlRewrite $false",
"NativeIaC": "",
"Other": "1. Navigate to Microsoft 365 Defender at https://security.microsoft.com\n2. Click to expand Email & collaboration and select Policies & rules\n3. Select Threat policies\n4. Under Policies, select Safe Links\n5. Select or create a policy and configure these settings:\n - Enable Safe Links for Email: On\n - Enable Safe Links for Teams: On\n - Enable Safe Links for Office: On\n - Track user clicks: On\n - Let users click through to the original URL: Off\n - Scan URLs: On\n - Apply Safe Links to messages sent within the organization: On\n - Wait for URL scanning to complete before delivering the message: On\n - Do not rewrite URLs: Off\n6. Save the policy",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable and properly configure Safe Links policies to protect users from malicious URLs in emails, Teams, and Office applications. Ensure URL scanning and time-of-click verification are enabled across all communication channels.",
"Url": "https://hub.prowler.com/check/defender_safelinks_policy_enabled"
}
},
"Categories": [
"email-security",
"e5"
],
"DependsOn": [],
"RelatedTo": [
"defender_antiphishing_policy_configured"
],
"Notes": "Safe Links requires Microsoft Defender for Office 365 Plan 1 (P1) or higher licensing."
}
@@ -0,0 +1,121 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.defender.defender_client import defender_client
class defender_safelinks_policy_enabled(Check):
"""
Check if Safe Links policy is enabled and properly configured in Microsoft Defender for Office 365.
This check verifies that Safe Links policies have the following settings
configured according to CIS Microsoft 365 Foundations Benchmark:
- EnableSafeLinksForEmail = True
- EnableSafeLinksForTeams = True
- EnableSafeLinksForOffice = True
- TrackClicks = True
- AllowClickThrough = False
- ScanUrls = True
- EnableForInternalSenders = True
- DeliverMessageAfterScan = True
- DisableUrlRewrite = False
Note: The Built-in Protection Policy has fixed settings that cannot be changed
and always provides baseline protection.
"""
def execute(self) -> List[CheckReportM365]:
findings = []
if defender_client.safe_links_policies:
# Only Built-in Protection Policy exists (no custom policies with rules)
if not defender_client.safe_links_rules:
policy = next(iter(defender_client.safe_links_policies.values()))
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.name,
resource_id=policy.identity,
)
# Case 1: Only Built-in policy exists - always PASS (fixed settings)
report.status = "PASS"
report.status_extended = f"{policy.name} is the only Safe Links policy and provides baseline protection for all users."
findings.append(report)
# Multiple Safe Links Policies (Built-in + custom policies)
else:
for policy_name, policy in defender_client.safe_links_policies.items():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy_name,
resource_id=policy.identity,
)
if policy.is_built_in_protection:
# Case 2: Built-in policy with custom policies - always PASS
report.status = "PASS"
report.status_extended = (
f"{policy_name} provides baseline Safe Links protection, "
f"but could be overridden by a misconfigured custom policy for specific users."
)
findings.append(report)
else:
# Custom policy - check configuration
rule = defender_client.safe_links_rules.get(policy_name)
if not rule:
continue
included_resources = []
if rule.users:
included_resources.append(f"users: {', '.join(rule.users)}")
if rule.groups:
included_resources.append(
f"groups: {', '.join(rule.groups)}"
)
if rule.domains:
included_resources.append(
f"domains: {', '.join(rule.domains)}"
)
# If no users, groups, or domains specified, the policy applies to all recipients
included_resources_str = (
"; ".join(included_resources)
if included_resources
else "all users"
)
if self._is_policy_properly_configured(policy, rule):
# Case 2: Custom policy is properly configured
report.status = "PASS"
report.status_extended = (
f"Custom Safe Links policy {policy_name} is properly configured and includes {included_resources_str}, "
f"with priority {rule.priority} (0 is the highest)."
)
else:
# Case 3: Custom policy is not properly configured
report.status = "FAIL"
report.status_extended = (
f"Custom Safe Links policy {policy_name} is not properly configured and includes {included_resources_str}, "
f"with priority {rule.priority} (0 is the highest)."
)
findings.append(report)
return findings
def _is_policy_properly_configured(self, policy, rule) -> bool:
"""Check if a custom policy is properly configured according to CIS recommendations."""
return (
rule.state.lower() == "enabled"
and policy.enable_safe_links_for_email
and policy.enable_safe_links_for_teams
and policy.enable_safe_links_for_office
and policy.track_clicks
and not policy.allow_click_through
and policy.scan_urls
and policy.enable_for_internal_senders
and policy.deliver_message_after_scan
and not policy.disable_url_rewrite
)
@@ -9,18 +9,18 @@ from prowler.providers.m365.m365_provider import M365Provider
class Defender(M365Service):
"""
Microsoft 365 Defender service implementation.
Microsoft Defender for Office 365 service client.
Provides access to Microsoft Defender for Office 365 configurations including
malware policies, spam filtering, anti-phishing, and Teams protection settings.
Provides access to Defender security policies including malware filtering,
anti-spam, anti-phishing, Safe Links, Teams protection, and DKIM configurations.
"""
def __init__(self, provider: M365Provider):
"""
Initialize the Defender service.
Initialize the Defender service client.
Args:
provider: The M365 provider instance.
provider: The M365Provider instance for authentication and connection.
"""
super().__init__(provider)
self.malware_policies = []
@@ -33,6 +33,8 @@ class Defender(M365Service):
self.inbound_spam_policies = []
self.inbound_spam_rules = {}
self.report_submission_policy = None
self.safe_links_policies = {}
self.safe_links_rules = {}
self.teams_protection_policy = None
if self.powershell:
if self.powershell.connect_exchange_online():
@@ -47,6 +49,8 @@ class Defender(M365Service):
self.inbound_spam_policies = self._get_inbound_spam_filter_policy()
self.inbound_spam_rules = self._get_inbound_spam_filter_rule()
self.report_submission_policy = self._get_report_submission_policy()
self.safe_links_policies = self._get_safe_links_policy()
self.safe_links_rules = self._get_safe_links_rule()
self.teams_protection_policy = self._get_teams_protection_policy()
self.powershell.close()
@@ -366,10 +370,10 @@ class Defender(M365Service):
def _get_report_submission_policy(self):
"""
Retrieve the Defender report submission policy.
Get the Defender report submission policy.
Returns:
ReportSubmissionPolicy: The report submission policy configuration.
ReportSubmissionPolicy: The report submission policy configuration or None.
"""
logger.info("Microsoft365 - Getting Defender report submission policy...")
report_submission_policy = None
@@ -408,6 +412,84 @@ class Defender(M365Service):
)
return report_submission_policy
def _get_safe_links_policy(self):
"""
Get Safe Links policies from Microsoft Defender for Office 365.
Returns:
dict: A dictionary mapping policy names to SafeLinksPolicy objects.
"""
logger.info("Microsoft365 - Getting Defender Safe Links policies...")
safe_links_policies = {}
try:
safe_links_policy_data = self.powershell.get_safe_links_policy()
if not safe_links_policy_data:
return safe_links_policies
if isinstance(safe_links_policy_data, dict):
safe_links_policy_data = [safe_links_policy_data]
for policy in safe_links_policy_data:
if policy:
safe_links_policies[policy.get("Name", "")] = SafeLinksPolicy(
name=policy.get("Name", ""),
identity=policy.get("Identity", ""),
enable_safe_links_for_email=policy.get(
"EnableSafeLinksForEmail", False
),
enable_safe_links_for_teams=policy.get(
"EnableSafeLinksForTeams", False
),
enable_safe_links_for_office=policy.get(
"EnableSafeLinksForOffice", False
),
track_clicks=policy.get("TrackClicks", False),
allow_click_through=policy.get("AllowClickThrough", True),
scan_urls=policy.get("ScanUrls", False),
enable_for_internal_senders=policy.get(
"EnableForInternalSenders", False
),
deliver_message_after_scan=policy.get(
"DeliverMessageAfterScan", False
),
disable_url_rewrite=policy.get("DisableUrlRewrite", True),
is_built_in_protection=policy.get("IsBuiltInProtection", False),
is_default=policy.get("IsDefault", False),
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return safe_links_policies
def _get_safe_links_rule(self):
"""
Get Safe Links rules from Microsoft Defender for Office 365.
Returns:
dict: A dictionary mapping policy names to SafeLinksRule objects.
"""
logger.info("Microsoft365 - Getting Defender Safe Links rules...")
safe_links_rules = {}
try:
safe_links_rule_data = self.powershell.get_safe_links_rule()
if not safe_links_rule_data:
return safe_links_rules
if isinstance(safe_links_rule_data, dict):
safe_links_rule_data = [safe_links_rule_data]
for rule in safe_links_rule_data:
if rule:
safe_links_rules[rule.get("SafeLinksPolicy", "")] = SafeLinksRule(
state=rule.get("State", "Disabled"),
priority=rule.get("Priority", 0),
users=rule.get("SentTo", None),
groups=rule.get("SentToMemberOf", None),
domains=rule.get("RecipientDomainIs", None),
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return safe_links_rules
def _get_teams_protection_policy(self):
"""
Retrieve the Teams protection policy including ZAP settings.
@@ -513,7 +595,7 @@ class InboundSpamRule(BaseModel):
class ReportSubmissionPolicy(BaseModel):
"""Model for Defender report submission policy settings."""
"""Model for Defender Report Submission Policy configuration."""
report_junk_to_customized_address: bool
report_not_junk_to_customized_address: bool
@@ -525,6 +607,34 @@ class ReportSubmissionPolicy(BaseModel):
report_chat_message_to_customized_address_enabled: bool
class SafeLinksPolicy(BaseModel):
"""Model for Defender Safe Links Policy configuration."""
name: str
identity: str
enable_safe_links_for_email: bool
enable_safe_links_for_teams: bool
enable_safe_links_for_office: bool
track_clicks: bool
allow_click_through: bool
scan_urls: bool
enable_for_internal_senders: bool
deliver_message_after_scan: bool
disable_url_rewrite: bool
is_built_in_protection: bool
is_default: bool
class SafeLinksRule(BaseModel):
"""Model for Defender Safe Links Rule configuration."""
state: str
priority: int
users: Optional[list[str]]
groups: Optional[list[str]]
domains: Optional[list[str]]
class TeamsProtectionPolicy(BaseModel):
"""Model for Teams protection policy settings including ZAP configuration."""
@@ -0,0 +1,521 @@
from unittest import mock
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_defender_safelinks_policy_enabled:
"""Tests for the defender_safelinks_policy_enabled check."""
def test_no_safe_links_policies(self):
"""Test when no Safe Links policies exist."""
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_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.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import (
defender_safelinks_policy_enabled,
)
defender_client.safe_links_policies = {}
defender_client.safe_links_rules = {}
check = defender_safelinks_policy_enabled()
result = check.execute()
assert len(result) == 0
def test_case1_only_builtin_policy(self):
"""Case 1: Only Built-in Protection Policy exists - always PASS."""
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_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.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import (
defender_safelinks_policy_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
SafeLinksPolicy,
)
defender_client.safe_links_policies = {
"Built-In Protection Policy": SafeLinksPolicy(
name="Built-In Protection Policy",
identity="Built-In-Protection-Policy-ID",
enable_safe_links_for_email=True,
enable_safe_links_for_teams=True,
enable_safe_links_for_office=True,
track_clicks=True,
allow_click_through=False,
scan_urls=True,
enable_for_internal_senders=True,
deliver_message_after_scan=True,
disable_url_rewrite=False,
is_built_in_protection=True,
is_default=False,
)
}
defender_client.safe_links_rules = {}
check = defender_safelinks_policy_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "is the only Safe Links policy" in result[0].status_extended
assert "baseline protection for all users" in result[0].status_extended
def test_case2_builtin_and_custom_properly_configured(self):
"""Case 2: Built-in + custom policy properly configured - both PASS."""
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_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.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import (
defender_safelinks_policy_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
SafeLinksPolicy,
SafeLinksRule,
)
defender_client.safe_links_policies = {
"Built-In Protection Policy": SafeLinksPolicy(
name="Built-In Protection Policy",
identity="Built-In-Protection-Policy-ID",
enable_safe_links_for_email=True,
enable_safe_links_for_teams=True,
enable_safe_links_for_office=True,
track_clicks=True,
allow_click_through=False,
scan_urls=True,
enable_for_internal_senders=True,
deliver_message_after_scan=True,
disable_url_rewrite=False,
is_built_in_protection=True,
is_default=False,
),
"Custom Policy": SafeLinksPolicy(
name="Custom Policy",
identity="Custom-Policy-ID",
enable_safe_links_for_email=True,
enable_safe_links_for_teams=True,
enable_safe_links_for_office=True,
track_clicks=True,
allow_click_through=False,
scan_urls=True,
enable_for_internal_senders=True,
deliver_message_after_scan=True,
disable_url_rewrite=False,
is_built_in_protection=False,
is_default=False,
),
}
defender_client.safe_links_rules = {
"Custom Policy": SafeLinksRule(
state="Enabled",
priority=0,
users=["user@example.com"],
groups=["Engineering"],
domains=["example.com"],
)
}
check = defender_safelinks_policy_enabled()
result = check.execute()
assert len(result) == 2
# Built-in policy PASS
builtin_result = next(
r for r in result if r.resource_name == "Built-In Protection Policy"
)
assert builtin_result.status == "PASS"
assert (
"provides baseline Safe Links protection"
in builtin_result.status_extended
)
# Custom policy PASS
custom_result = next(
r for r in result if r.resource_name == "Custom Policy"
)
assert custom_result.status == "PASS"
assert "is properly configured" in custom_result.status_extended
assert "users: user@example.com" in custom_result.status_extended
assert "groups: Engineering" in custom_result.status_extended
assert "domains: example.com" in custom_result.status_extended
assert "priority 0" in custom_result.status_extended
def test_case3_builtin_pass_custom_misconfigured(self):
"""Case 3: Built-in PASS + custom policy misconfigured - Built-in PASS, custom FAIL."""
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_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.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import (
defender_safelinks_policy_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
SafeLinksPolicy,
SafeLinksRule,
)
defender_client.safe_links_policies = {
"Built-In Protection Policy": SafeLinksPolicy(
name="Built-In Protection Policy",
identity="Built-In-Protection-Policy-ID",
enable_safe_links_for_email=True,
enable_safe_links_for_teams=True,
enable_safe_links_for_office=True,
track_clicks=True,
allow_click_through=False,
scan_urls=True,
enable_for_internal_senders=True,
deliver_message_after_scan=True,
disable_url_rewrite=False,
is_built_in_protection=True,
is_default=False,
),
"Custom Policy": SafeLinksPolicy(
name="Custom Policy",
identity="Custom-Policy-ID",
enable_safe_links_for_email=False, # Misconfigured
enable_safe_links_for_teams=False, # Misconfigured
enable_safe_links_for_office=True,
track_clicks=True,
allow_click_through=True, # Misconfigured
scan_urls=True,
enable_for_internal_senders=True,
deliver_message_after_scan=True,
disable_url_rewrite=False,
is_built_in_protection=False,
is_default=False,
),
}
defender_client.safe_links_rules = {
"Custom Policy": SafeLinksRule(
state="Enabled",
priority=0,
users=["user@example.com"],
groups=None,
domains=None,
)
}
check = defender_safelinks_policy_enabled()
result = check.execute()
assert len(result) == 2
# Built-in policy still PASS
builtin_result = next(
r for r in result if r.resource_name == "Built-In Protection Policy"
)
assert builtin_result.status == "PASS"
# Custom policy FAIL
custom_result = next(
r for r in result if r.resource_name == "Custom Policy"
)
assert custom_result.status == "FAIL"
assert "is not properly configured" in custom_result.status_extended
assert "priority 0" in custom_result.status_extended
def test_custom_policy_without_rule_skipped(self):
"""Test that custom policies without associated rules are skipped."""
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_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.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import (
defender_safelinks_policy_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
SafeLinksPolicy,
SafeLinksRule,
)
defender_client.safe_links_policies = {
"Built-In Protection Policy": SafeLinksPolicy(
name="Built-In Protection Policy",
identity="Built-In-Protection-Policy-ID",
enable_safe_links_for_email=True,
enable_safe_links_for_teams=True,
enable_safe_links_for_office=True,
track_clicks=True,
allow_click_through=False,
scan_urls=True,
enable_for_internal_senders=True,
deliver_message_after_scan=True,
disable_url_rewrite=False,
is_built_in_protection=True,
is_default=False,
),
"Custom Policy Without Rule": SafeLinksPolicy(
name="Custom Policy Without Rule",
identity="Custom-Policy-ID",
enable_safe_links_for_email=True,
enable_safe_links_for_teams=True,
enable_safe_links_for_office=True,
track_clicks=True,
allow_click_through=False,
scan_urls=True,
enable_for_internal_senders=True,
deliver_message_after_scan=True,
disable_url_rewrite=False,
is_built_in_protection=False,
is_default=False,
),
}
# Rule for a different policy
defender_client.safe_links_rules = {
"Other Policy": SafeLinksRule(
state="Enabled",
priority=0,
users=["user@example.com"],
groups=None,
domains=None,
)
}
check = defender_safelinks_policy_enabled()
result = check.execute()
# Only Built-in policy should be in results
assert len(result) == 1
assert result[0].resource_name == "Built-In Protection Policy"
assert result[0].status == "PASS"
def test_custom_policy_with_disabled_rule(self):
"""Test when custom policy has proper settings but disabled rule (FAIL)."""
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_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.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import (
defender_safelinks_policy_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
SafeLinksPolicy,
SafeLinksRule,
)
defender_client.safe_links_policies = {
"Built-In Protection Policy": SafeLinksPolicy(
name="Built-In Protection Policy",
identity="Built-In-Protection-Policy-ID",
enable_safe_links_for_email=True,
enable_safe_links_for_teams=True,
enable_safe_links_for_office=True,
track_clicks=True,
allow_click_through=False,
scan_urls=True,
enable_for_internal_senders=True,
deliver_message_after_scan=True,
disable_url_rewrite=False,
is_built_in_protection=True,
is_default=False,
),
"Custom Policy": SafeLinksPolicy(
name="Custom Policy",
identity="Custom-Policy-ID",
enable_safe_links_for_email=True,
enable_safe_links_for_teams=True,
enable_safe_links_for_office=True,
track_clicks=True,
allow_click_through=False,
scan_urls=True,
enable_for_internal_senders=True,
deliver_message_after_scan=True,
disable_url_rewrite=False,
is_built_in_protection=False,
is_default=False,
),
}
defender_client.safe_links_rules = {
"Custom Policy": SafeLinksRule(
state="Disabled", # Disabled rule
priority=0,
users=["user@example.com"],
groups=None,
domains=None,
)
}
check = defender_safelinks_policy_enabled()
result = check.execute()
assert len(result) == 2
# Built-in policy PASS
builtin_result = next(
r for r in result if r.resource_name == "Built-In Protection Policy"
)
assert builtin_result.status == "PASS"
# Custom policy FAIL because rule is disabled
custom_result = next(
r for r in result if r.resource_name == "Custom Policy"
)
assert custom_result.status == "FAIL"
assert "is not properly configured" in custom_result.status_extended
def test_custom_policy_applies_to_all_users_when_no_scope(self):
"""Test that custom policy with no users/groups/domains shows 'all users'."""
defender_client = mock.MagicMock()
defender_client.audited_tenant = "audited_tenant"
defender_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.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled.defender_client",
new=defender_client,
),
):
from prowler.providers.m365.services.defender.defender_safelinks_policy_enabled.defender_safelinks_policy_enabled import (
defender_safelinks_policy_enabled,
)
from prowler.providers.m365.services.defender.defender_service import (
SafeLinksPolicy,
SafeLinksRule,
)
defender_client.safe_links_policies = {
"Built-In Protection Policy": SafeLinksPolicy(
name="Built-In Protection Policy",
identity="Built-In-Protection-Policy-ID",
enable_safe_links_for_email=True,
enable_safe_links_for_teams=True,
enable_safe_links_for_office=True,
track_clicks=True,
allow_click_through=False,
scan_urls=True,
enable_for_internal_senders=True,
deliver_message_after_scan=True,
disable_url_rewrite=False,
is_built_in_protection=True,
is_default=False,
),
"Houston Safe Links Policy test": SafeLinksPolicy(
name="Houston Safe Links Policy test",
identity="Houston-Policy-ID",
enable_safe_links_for_email=False, # Misconfigured
enable_safe_links_for_teams=False,
enable_safe_links_for_office=False,
track_clicks=False,
allow_click_through=True,
scan_urls=False,
enable_for_internal_senders=False,
deliver_message_after_scan=False,
disable_url_rewrite=True,
is_built_in_protection=False,
is_default=False,
),
}
defender_client.safe_links_rules = {
"Houston Safe Links Policy test": SafeLinksRule(
state="Enabled",
priority=0,
users=None, # No users specified
groups=None, # No groups specified
domains=None, # No domains specified - applies to ALL users
)
}
check = defender_safelinks_policy_enabled()
result = check.execute()
assert len(result) == 2
# Custom policy should show "all users" in status_extended
custom_result = next(
r for r in result if r.resource_name == "Houston Safe Links Policy test"
)
assert custom_result.status == "FAIL"
assert "is not properly configured" in custom_result.status_extended
assert "all users" in custom_result.status_extended
assert "priority 0" in custom_result.status_extended