From fce1e4f3d268c1f368b9947af7c449ff5a6b33f7 Mon Sep 17 00:00:00 2001 From: Andoni Alonso <14891798+andoniaf@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:42:11 +0100 Subject: [PATCH] feat(m365): add defender_safe_attachments_policy_enabled security check (#9833) Co-authored-by: HugoPBrito --- prowler/CHANGELOG.md | 1 + prowler/compliance/m365/cis_4.0_m365.json | 4 +- prowler/compliance/m365/cis_6.0_m365.json | 4 +- .../compliance/m365/iso27001_2022_m365.json | 1 + .../m365/lib/powershell/m365_powershell.py | 51 +- .../__init__.py | 0 ...e_attachments_policy_enabled.metadata.json | 40 ++ ...efender_safe_attachments_policy_enabled.py | 112 +++++ .../services/defender/defender_service.py | 125 ++++- ...er_safe_attachments_policy_enabled_test.py | 468 ++++++++++++++++++ 10 files changed, 800 insertions(+), 6 deletions(-) create mode 100644 prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/__init__.py create mode 100644 prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.metadata.json create mode 100644 prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.py create mode 100644 tests/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d7102cbd1b..50982b8028 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added +- `defender_safe_attachments_policy_enabled` check for M365 provider [(#9833)](https://github.com/prowler-cloud/prowler/pull/9833) - `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) diff --git a/prowler/compliance/m365/cis_4.0_m365.json b/prowler/compliance/m365/cis_4.0_m365.json index 928d33da63..df9b6edfb5 100644 --- a/prowler/compliance/m365/cis_4.0_m365.json +++ b/prowler/compliance/m365/cis_4.0_m365.json @@ -387,7 +387,9 @@ { "Id": "2.1.4", "Description": "The Safe Attachments policy helps protect users from malware in email attachments by scanning attachments for viruses, malware, and other malicious content. When an email attachment is received by a user, Safe Attachments will scan the attachment in a secure environment and provide a verdict on whether the attachment is safe or not.", - "Checks": [], + "Checks": [ + "defender_safe_attachments_policy_enabled" + ], "Attributes": [ { "Section": "2 Microsoft 365 Defender", diff --git a/prowler/compliance/m365/cis_6.0_m365.json b/prowler/compliance/m365/cis_6.0_m365.json index 99eb5e2940..192e49dcfd 100644 --- a/prowler/compliance/m365/cis_6.0_m365.json +++ b/prowler/compliance/m365/cis_6.0_m365.json @@ -406,7 +406,9 @@ { "Id": "2.1.4", "Description": "The Safe Attachments policy helps protect users from malware in email attachments by scanning attachments for viruses, malware, and other malicious content. When an email attachment is received by a user, Safe Attachments will scan the attachment in a secure environment and provide a verdict on whether the attachment is safe or not.", - "Checks": [], + "Checks": [ + "defender_safe_attachments_policy_enabled" + ], "Attributes": [ { "Section": "2 Microsoft 365 Defender", diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index 4778f76b75..3335fe0f19 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -693,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_safe_attachments_policy_enabled", "defender_safelinks_policy_enabled", "defender_zap_for_teams_enabled", "teams_external_domains_restricted", diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index 7f4615674e..9cc7207f20 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -65,7 +65,7 @@ class M365PowerShell(PowerShellSession): 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"This may indicate missing module, licensing (e.g., Microsoft Defender for Office 365) " f"or insufficient permissions. Related checks will be skipped." ) else: @@ -848,6 +848,55 @@ class M365PowerShell(PowerShellSession): "Get-SharingPolicy | ConvertTo-Json -Depth 10", json_parse=True ) + def get_safe_attachments_policy(self) -> dict: + """ + Get Safe Attachments Policy. + + Retrieves the Safe Attachments policy settings for Microsoft Defender for Office 365. + + Returns: + dict: Safe Attachments policy settings in JSON format. + + Example: + >>> get_safe_attachments_policy() + { + "Name": "Built-In Protection Policy", + "Identity": "Built-In Protection Policy", + "Enable": true, + "Action": "Block", + "QuarantineTag": "AdminOnlyAccessPolicy" + } + """ + return self.execute( + "Get-SafeAttachmentPolicy | ConvertTo-Json -Depth 10", json_parse=True + ) + + def get_safe_attachments_rule(self) -> dict: + """ + Get Safe Attachments Rules. + + Retrieves the Safe Attachments rules that define which users, groups, + and domains are targeted by Safe Attachments policies. + + Returns: + dict: Safe Attachments rules in JSON format. + + Example: + >>> get_safe_attachments_rule() + { + "Name": "Custom Safe Attachments Rule", + "SafeAttachmentPolicy": "Custom Policy", + "State": "Enabled", + "Priority": 0, + "SentTo": ["user@contoso.com"], + "SentToMemberOf": ["group@contoso.com"], + "RecipientDomainIs": ["contoso.com"] + } + """ + return self.execute( + "Get-SafeAttachmentRule | ConvertTo-Json -Depth 10", json_parse=True + ) + def get_advanced_threat_protection_policy(self) -> dict: """ Get Advanced Threat Protection Policy. diff --git a/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/__init__.py b/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.metadata.json b/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.metadata.json new file mode 100644 index 0000000000..0a4176a0df --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "defender_safe_attachments_policy_enabled", + "CheckTitle": "Safe Attachments policy is enabled with secure configuration", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Defender Safe Attachments Policy", + "ResourceGroup": "security", + "Description": "Safe Attachments provides an additional layer of protection by scanning email attachments in a secure environment before delivering them to recipients.\n\nThe Built-In Protection Policy should have **Enable=True**, **Action=Block**, and **QuarantineTag=AdminOnlyAccessPolicy** to ensure malicious attachments are blocked and quarantined with admin-only access.", + "Risk": "Without properly configured Safe Attachments policies, malicious email attachments could reach users' mailboxes and potentially compromise the organization through:\n\n- **Malware delivery** via infected documents\n- **Ransomware attacks** through weaponized attachments\n- **Data exfiltration** using malicious scripts", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/defender-office-365/safe-attachments-about", + "https://learn.microsoft.com/en-us/defender-office-365/preset-security-policies" + ], + "Remediation": { + "Code": { + "CLI": "Set-SafeAttachmentPolicy -Identity 'Built-In Protection Policy' -Enable $true -Action Block -QuarantineTag AdminOnlyAccessPolicy", + "NativeIaC": "", + "Other": "1. Go to Microsoft 365 Defender portal (https://security.microsoft.com)\n2. Navigate to Email & collaboration > Policies & rules > Threat policies\n3. Select Safe Attachments under Policies\n4. Click on Built-In Protection Policy\n5. Set Enable to True, Action to Block, and QuarantineTag to AdminOnlyAccessPolicy\n6. Save the configuration", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable Safe Attachments with the **Block** action to prevent malicious attachments from reaching users. Use **AdminOnlyAccessPolicy** for the quarantine tag to ensure only administrators can release quarantined items, following the principle of least privilege.", + "Url": "https://hub.prowler.com/check/defender_safe_attachments_policy_enabled" + } + }, + "Categories": [ + "email-security", + "e5" + ], + "DependsOn": [], + "RelatedTo": [ + "defender_malware_policy_common_attachments_filter_enabled" + ], + "Notes": "This check requires Microsoft Defender for Office 365 Plan 1 or higher license." +} diff --git a/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.py b/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.py new file mode 100644 index 0000000000..68714882f1 --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.py @@ -0,0 +1,112 @@ +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_safe_attachments_policy_enabled(Check): + """ + Check if Safe Attachments policy is properly configured in Microsoft Defender for Office 365. + + This check verifies that Safe Attachments policies have the following settings + configured according to CIS Microsoft 365 Foundations Benchmark: + + - Enable = True + - Action = Block + - QuarantineTag = AdminOnlyAccessPolicy + + 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_attachments_policies: + # Only Built-in Protection Policy exists (no custom policies with rules) + if not defender_client.safe_attachments_rules: + policy = next(iter(defender_client.safe_attachments_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 Attachments policy and provides baseline protection for all users." + findings.append(report) + + # Multiple Safe Attachments Policies (Built-in + custom policies) + else: + for ( + policy_name, + policy, + ) in defender_client.safe_attachments_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 Attachments 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_attachments_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 Attachments 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 Attachments 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 + and policy.action == "Block" + and policy.quarantine_tag == "AdminOnlyAccessPolicy" + ) diff --git a/prowler/providers/m365/services/defender/defender_service.py b/prowler/providers/m365/services/defender/defender_service.py index 596b1b4c7b..ba377e2b79 100644 --- a/prowler/providers/m365/services/defender/defender_service.py +++ b/prowler/providers/m365/services/defender/defender_service.py @@ -9,11 +9,12 @@ from prowler.providers.m365.m365_provider import M365Provider class Defender(M365Service): """ - Microsoft 365 Defender service class. + Microsoft Defender for Office 365 service class. This class provides methods to retrieve and manage Microsoft Defender for Office 365 security policies and configurations, including malware filters, spam policies, - anti-phishing settings, ATP (Advanced Threat Protection), and Teams protection policies. + anti-phishing settings, Safe Attachments, Safe Links, ATP (Advanced Threat Protection), + and Teams protection policies. Attributes: malware_policies (list): List of malware filter policies. @@ -26,7 +27,11 @@ class Defender(M365Service): inbound_spam_policies (list): List of inbound spam filter policies. inbound_spam_rules (dict): Dictionary of inbound spam filter rules. report_submission_policy: Report submission policy configuration. + safe_attachments_policies (dict): Dictionary of Safe Attachments policies. + safe_attachments_rules (dict): Dictionary of Safe Attachments rules. advanced_threat_protection_policy: Advanced Threat Protection policy configuration. + safe_links_policies (dict): Dictionary of Safe Links policies. + safe_links_rules (dict): Dictionary of Safe Links rules. teams_protection_policy: Teams protection policy configuration. """ @@ -35,7 +40,7 @@ class Defender(M365Service): Initialize the Defender service client. Args: - provider: The M365Provider instance for authentication and connection. + provider: The M365Provider instance for authentication and configuration. """ super().__init__(provider) self.malware_policies = [] @@ -48,6 +53,8 @@ class Defender(M365Service): self.inbound_spam_policies = [] self.inbound_spam_rules = {} self.report_submission_policy = None + self.safe_attachments_policies = {} + self.safe_attachments_rules = {} self.advanced_threat_protection_policy = None self.safe_links_policies = {} self.safe_links_rules = {} @@ -65,6 +72,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_attachments_policies = self._get_safe_attachments_policies() + self.safe_attachments_rules = self._get_safe_attachments_rules() self.advanced_threat_protection_policy = ( self._get_advanced_threat_protection_policy() ) @@ -431,6 +440,72 @@ class Defender(M365Service): ) return report_submission_policy + def _get_safe_attachments_policies(self): + """ + Retrieve Safe Attachments policies from Microsoft Defender for Office 365. + + Returns: + dict[str, SafeAttachmentsPolicy]: A dictionary of Safe Attachments policies keyed by name. + """ + logger.info("Microsoft365 - Getting Defender Safe Attachments policies...") + safe_attachments_policies = {} + try: + policies_data = self.powershell.get_safe_attachments_policy() + if not policies_data: + return safe_attachments_policies + if isinstance(policies_data, dict): + policies_data = [policies_data] + for policy in policies_data: + if policy: + policy_name = policy.get("Name", "") + is_built_in = policy_name == "Built-In Protection Policy" + safe_attachments_policies[policy_name] = SafeAttachmentsPolicy( + name=policy_name, + identity=policy.get("Identity", ""), + enable=policy.get("Enable", False), + action=policy.get("Action", ""), + quarantine_tag=policy.get("QuarantineTag", ""), + redirect=policy.get("Redirect", False), + redirect_address=policy.get("RedirectAddress", ""), + is_built_in_protection=is_built_in, + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return safe_attachments_policies + + def _get_safe_attachments_rules(self): + """ + Retrieve Safe Attachments rules from Microsoft Defender for Office 365. + + Returns: + dict[str, SafeAttachmentsRule]: A dictionary of Safe Attachments rules keyed by policy name. + """ + logger.info("Microsoft365 - Getting Defender Safe Attachments rules...") + safe_attachments_rules = {} + try: + rules_data = self.powershell.get_safe_attachments_rule() + if not rules_data: + return safe_attachments_rules + if isinstance(rules_data, dict): + rules_data = [rules_data] + for rule in rules_data: + if rule: + policy_name = rule.get("SafeAttachmentPolicy", "") + safe_attachments_rules[policy_name] = SafeAttachmentsRule( + state=rule.get("State", ""), + priority=rule.get("Priority", 0), + users=rule.get("SentTo"), + groups=rule.get("SentToMemberOf"), + domains=rule.get("RecipientDomainIs"), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return safe_attachments_rules + def _get_advanced_threat_protection_policy(self): """ Get the Advanced Threat Protection policy. @@ -655,6 +730,50 @@ class ReportSubmissionPolicy(BaseModel): report_chat_message_to_customized_address_enabled: bool +class SafeAttachmentsPolicy(BaseModel): + """ + Data model for Safe Attachments policy settings. + + Attributes: + name: The name of the policy. + identity: The unique identifier of the policy. + enable: Whether the policy is enabled. + action: The action to take on malicious attachments (Allow, Block, Replace, DynamicDelivery). + quarantine_tag: The quarantine policy applied to detected messages. + redirect: Whether to redirect messages with detected attachments. + redirect_address: The email address to redirect messages to. + is_built_in_protection: Whether this is the Built-in Protection Policy. + """ + + name: str + identity: str + enable: bool + action: str + quarantine_tag: str + redirect: bool + redirect_address: str + is_built_in_protection: bool = False + + +class SafeAttachmentsRule(BaseModel): + """ + Data model for Safe Attachments rule settings. + + Attributes: + state: The state of the rule (Enabled/Disabled). + priority: The priority of the rule (0 is highest). + users: List of users the rule applies to. + groups: List of groups the rule applies to. + domains: List of domains the rule applies to. + """ + + state: str + priority: int + users: Optional[list[str]] + groups: Optional[list[str]] + domains: Optional[list[str]] + + class AdvancedThreatProtectionPolicy(BaseModel): """ Model for Advanced Threat Protection policy. diff --git a/tests/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled_test.py b/tests/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled_test.py new file mode 100644 index 0000000000..b9bcc45ee1 --- /dev/null +++ b/tests/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled_test.py @@ -0,0 +1,468 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_defender_safe_attachments_policy_enabled: + """Tests for the defender_safe_attachments_policy_enabled check.""" + + def test_no_safe_attachments_policies(self): + """Test when no Safe Attachments 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_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + + defender_client.safe_attachments_policies = {} + defender_client.safe_attachments_rules = {} + + check = defender_safe_attachments_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_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeAttachmentsPolicy, + ) + + defender_client.safe_attachments_policies = { + "Built-In Protection Policy": SafeAttachmentsPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=True, + ) + } + defender_client.safe_attachments_rules = {} + + check = defender_safe_attachments_policy_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "is the only Safe Attachments 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_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeAttachmentsPolicy, + SafeAttachmentsRule, + ) + + defender_client.safe_attachments_policies = { + "Built-In Protection Policy": SafeAttachmentsPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=True, + ), + "Custom Policy": SafeAttachmentsPolicy( + name="Custom Policy", + identity="Custom-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=False, + ), + } + defender_client.safe_attachments_rules = { + "Custom Policy": SafeAttachmentsRule( + state="Enabled", + priority=0, + users=["user@example.com"], + groups=["Engineering"], + domains=["example.com"], + ) + } + + check = defender_safe_attachments_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 Attachments 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_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeAttachmentsPolicy, + SafeAttachmentsRule, + ) + + defender_client.safe_attachments_policies = { + "Built-In Protection Policy": SafeAttachmentsPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=True, + ), + "Custom Policy": SafeAttachmentsPolicy( + name="Custom Policy", + identity="Custom-Policy-ID", + enable=False, # Misconfigured + action="Allow", # Misconfigured + quarantine_tag="DefaultFullAccessPolicy", # Misconfigured + redirect=False, + redirect_address="", + is_built_in_protection=False, + ), + } + defender_client.safe_attachments_rules = { + "Custom Policy": SafeAttachmentsRule( + state="Enabled", + priority=0, + users=["user@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_safe_attachments_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_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeAttachmentsPolicy, + SafeAttachmentsRule, + ) + + defender_client.safe_attachments_policies = { + "Built-In Protection Policy": SafeAttachmentsPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=True, + ), + "Custom Policy Without Rule": SafeAttachmentsPolicy( + name="Custom Policy Without Rule", + identity="Custom-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=False, + ), + } + # Rule for a different policy + defender_client.safe_attachments_rules = { + "Other Policy": SafeAttachmentsRule( + state="Enabled", + priority=0, + users=["user@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_safe_attachments_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_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeAttachmentsPolicy, + SafeAttachmentsRule, + ) + + defender_client.safe_attachments_policies = { + "Built-In Protection Policy": SafeAttachmentsPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=True, + ), + "Custom Policy": SafeAttachmentsPolicy( + name="Custom Policy", + identity="Custom-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=False, + ), + } + defender_client.safe_attachments_rules = { + "Custom Policy": SafeAttachmentsRule( + state="Disabled", # Disabled rule + priority=0, + users=["user@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_safe_attachments_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_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_safe_attachments_policy_enabled.defender_safe_attachments_policy_enabled import ( + defender_safe_attachments_policy_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + SafeAttachmentsPolicy, + SafeAttachmentsRule, + ) + + defender_client.safe_attachments_policies = { + "Built-In Protection Policy": SafeAttachmentsPolicy( + name="Built-In Protection Policy", + identity="Built-In-Protection-Policy-ID", + enable=True, + action="Block", + quarantine_tag="AdminOnlyAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=True, + ), + "Houston Safe Attachments Policy test": SafeAttachmentsPolicy( + name="Houston Safe Attachments Policy test", + identity="Houston-Policy-ID", + enable=False, # Misconfigured + action="Allow", + quarantine_tag="DefaultFullAccessPolicy", + redirect=False, + redirect_address="", + is_built_in_protection=False, + ), + } + defender_client.safe_attachments_rules = { + "Houston Safe Attachments Policy test": SafeAttachmentsRule( + 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_safe_attachments_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 Attachments 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