diff --git a/prowler/changelog.d/cis-m365-v7-automated-checks.added.md b/prowler/changelog.d/cis-m365-v7-automated-checks.added.md new file mode 100644 index 0000000000..a26cc4da92 --- /dev/null +++ b/prowler/changelog.d/cis-m365-v7-automated-checks.added.md @@ -0,0 +1 @@ +37 M365 checks covering the CIS Microsoft 365 Foundations Benchmark v7.0.0 Automated controls across the `entra`, `admincenter`, `exchange`, `teams`, `intune`, and `defender` services diff --git a/prowler/compliance/m365/cis_7.0_m365.json b/prowler/compliance/m365/cis_7.0_m365.json index 941a33b187..4bcd2802b2 100644 --- a/prowler/compliance/m365/cis_7.0_m365.json +++ b/prowler/compliance/m365/cis_7.0_m365.json @@ -170,7 +170,10 @@ { "Id": "1.3.2", "Description": "Idle session timeout allows the configuration of a setting which will timeout inactive users after a pre-determined amount of time. When a user reaches the set idle timeout session, they'll get a notification that they're about to be signed out. They must choose to stay signed in or they'll be automatically signed out of all Microsoft 365 web apps. Combined with a Conditional Access rule this will only impact unmanaged devices. A managed device is considered a device managed by Intune MDM or joined to a domain (Entra ID or Hybrid joined). The following Microsoft 365 web apps are supported. - Outlook Web App - OneDrive - SharePoint - Microsoft Fabric - Microsoft365.com and other start pages - Microsoft 365 web apps (Word, Excel, PowerPoint) - Microsoft 365 Admin Center - M365 Defender Portal - Microsoft Purview Compliance Portal The recommended setting is 3 hours (or less) for unmanaged devices. Note: Idle session timeout doesn't affect Microsoft 365 desktop and mobile apps.", - "Checks": [], + "Checks": [ + "entra_idle_session_timeout_configured", + "entra_conditional_access_policy_app_enforced_restrictions" + ], "Attributes": [ { "Section": "1 Microsoft 365 admin center", @@ -214,7 +217,9 @@ { "Id": "1.3.4", "Description": "By default, users can install add-ins in their Microsoft Word, Excel, and PowerPoint applications, allowing data access within the application. Do not allow users to install add-ins in Word, Excel, or PowerPoint.", - "Checks": [], + "Checks": [ + "admincenter_office_store_apps_restricted" + ], "Attributes": [ { "Section": "1 Microsoft 365 admin center", @@ -235,7 +240,9 @@ { "Id": "1.3.5", "Description": "Microsoft Forms can be used for phishing attacks by asking personal or sensitive information and collecting the results. Microsoft 365 has built-in protection that will proactively scan for phishing attempt in forms such personal information request.", - "Checks": [], + "Checks": [ + "admincenter_forms_phishing_protection_enabled" + ], "Attributes": [ { "Section": "1 Microsoft 365 admin center", @@ -323,7 +330,9 @@ { "Id": "1.3.9", "Description": "Shared Bookings allows you to invite your team members and create booking pages and let your customers book time with you and your team. It contains various settings to define services, manage staff members, configure schedules and availability, business hours and customize how appointments are scheduled. These pages can be customized to fit the diverse needs of your organization. It is an extension of Person Bookings. The recommended state is to restrict the OwaMailboxPolicy-Default policy or disable at the organization level.", - "Checks": [], + "Checks": [ + "admincenter_shared_bookings_disabled" + ], "Attributes": [ { "Section": "1 Microsoft 365 admin center", @@ -768,7 +777,9 @@ { "Id": "2.4.1", "Description": "Identify priority accounts to utilize Microsoft 365's advanced custom security features. This is an essential tool to bolster protection for users who are frequently targeted due to their critical positions, such as executives, leaders, managers, or others who have access to sensitive, confidential, financial, or high-priority information. Once these accounts are identified, several services and features can be enabled, including threat policies, enhanced sign-in protection through conditional access policies, and alert policies, enabling faster response times for incident response teams.", - "Checks": [], + "Checks": [ + "defender_priority_account_protection_enabled" + ], "Attributes": [ { "Section": "2 Microsoft Defender", @@ -789,7 +800,9 @@ { "Id": "2.4.2", "Description": "Preset security policies have been established by Microsoft, utilizing observations and experiences within datacenters to strike a balance between the exclusion of malicious content from users and limiting unwarranted disruptions. These policies can apply to all, or select users and encompass recommendations for addressing spam, malware, and phishing threats. The policy parameters are pre-determined and non-adjustable. Strict protection has the most aggressive protection of the 3 presets. - EOP: Anti-spam, Anti-malware and Anti-phishing - Defender: Spoof protection, Impersonation protection and Advanced phishing - Defender: Safe Links and Safe Attachments NOTE: The preset security polices cannot target Priority account TAGS currently, groups should be used instead.", - "Checks": [], + "Checks": [ + "defender_strict_preset_security_policy_enabled" + ], "Attributes": [ { "Section": "2 Microsoft Defender", @@ -1005,7 +1018,9 @@ { "Id": "4.2", "Description": "Device enrollment restrictions let you restrict devices from enrolling in Intune based on certain device attributes such as device limit, device platform, OS Version, manufacturer or device ownership (Personally owned devices). The recommended state is to Block personally owned devices from enrollment.", - "Checks": [], + "Checks": [ + "intune_device_enrollment_personal_device_restricted" + ], "Attributes": [ { "Section": "4 Microsoft Intune admin center", @@ -1160,7 +1175,9 @@ { "Id": "5.1.3.1", "Description": "This setting allows users in the organization to create new security groups and add members to these groups in the Azure portal, API, or PowerShell. These new groups also show up in the Access Panel for all other users. If the policy setting on the group allows it, other users can create requests to join these groups. The recommended state is Users can create security groups in Azure portals, API or PowerShell set to No.", - "Checks": [], + "Checks": [ + "entra_policy_default_user_cannot_create_security_groups" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1223,7 +1240,9 @@ { "Id": "5.1.3.4", "Description": "All users within a Microsoft Entra organization are permitted to create new Microsoft 365 groups and add members to those groups through the Azure portal, API, or PowerShell. Newly created groups also appear in the Access Panel for all other users. When the applicable group policy settings allow it, users can submit requests to join these groups. The recommended state is No.", - "Checks": [], + "Checks": [ + "entra_policy_default_user_cannot_create_m365_groups" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1244,7 +1263,9 @@ { "Id": "5.1.4.1", "Description": "This setting enables you to select the users who can register their devices as Microsoft Entra joined devices. The recommended state is Selected or None. Note: This setting is applicable only to Microsoft Entra join on Windows 10 or newer. This setting doesn't apply to Microsoft Entra hybrid joined devices, Microsoft Entra joined VMs in Azure, or Microsoft Entra joined devices that use Windows Autopilot self- deployment mode because these methods work in a userless context.", - "Checks": [], + "Checks": [ + "entra_device_registration_join_restricted" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1265,7 +1286,9 @@ { "Id": "5.1.4.2", "Description": "This setting defines the maximum number of Microsoft Entra joined or registered devices that a user can have in Microsoft Entra ID. Once this limit is reached, no additional devices can be added until existing ones are removed. Values above 100 are automatically capped at 100. The recommended state is 10 or less.", - "Checks": [], + "Checks": [ + "entra_device_registration_max_devices_per_user_limited" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1286,7 +1309,9 @@ { "Id": "5.1.4.3", "Description": "This setting controls whether the Global Administrator role is automatically added to the local administrators group on a device during the Microsoft Entra join process. The recommended state is No.", - "Checks": [], + "Checks": [ + "entra_device_registration_global_admins_not_local_admins" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1307,7 +1332,9 @@ { "Id": "5.1.4.4", "Description": "This setting determines if the Microsoft Entra user registering their device as Microsoft Entra join will be added to the local administrators group. This setting applies only once during the actual registration of the device as Microsoft Entra join. The recommended state is Selected or None.", - "Checks": [], + "Checks": [ + "entra_device_registration_registering_user_not_local_admin" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1328,7 +1355,9 @@ { "Id": "5.1.4.5", "Description": "Local Administrator Password Solution (LAPS) is the management of local account passwords on Windows devices. LAPS provides a solution to securely manage and retrieve the built-in local admin password. With cloud version of LAPS, customers can enable storing and rotation of local admin passwords for both Microsoft Entra and Microsoft Entra hybrid join devices The recommended state is Yes.", - "Checks": [], + "Checks": [ + "entra_device_registration_laps_enabled" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1349,7 +1378,9 @@ { "Id": "5.1.4.6", "Description": "This setting determines if users can self-service recover their BitLocker key(s). 'Yes' restricts non-admin users from being able to see the BitLocker key(s) for their owned devices if there are any. 'No' allows all users to recover their BitLocker key(s). The recommended state is Yes.", - "Checks": [], + "Checks": [ + "entra_policy_default_user_cannot_read_bitlocker_keys" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1508,7 +1539,9 @@ { "Id": "5.1.6.1", "Description": "B2B collaboration is a feature within Microsoft Entra External ID that allows for guest invitations to an organization. Ensure users can only send invitations to specified domains. Note: This list works independently from OneDrive for Business and SharePoint Online allow/block lists. To restrict individual file sharing in SharePoint Online, set up an allow or blocklist for OneDrive for Business and SharePoint Online. For instance, in SharePoint or OneDrive users can still share with external users from prohibited domains by using Anyone links if they haven't been disabled.", - "Checks": [], + "Checks": [ + "entra_policy_guest_invitations_restricted_to_allowed_domains" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1759,7 +1792,9 @@ { "Id": "5.2.2.8", "Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy", - "Checks": [], + "Checks": [ + "entra_conditional_access_policy_block_high_medium_sign_in_risk" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1872,7 +1907,9 @@ { "Id": "5.2.2.13", "Description": "Sign-in frequency defines the time period before a user is asked to sign in again when attempting to access a resource. The Microsoft Entra ID default configuration for user sign-in frequency is a rolling window of 90 days. The recommended state for all users is to enforce periodic reauthentication for 7 days or less.", - "Checks": [], + "Checks": [ + "entra_conditional_access_policy_sign_in_frequency_all_users" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1893,7 +1930,9 @@ { "Id": "5.2.2.14", "Description": "Microsoft Entra ID Conditional Access allows an organization to configure Named locations and configure whether those locations are trusted or untrusted. These settings provide organizations the means to specify Geographical locations for use in conditional access policies, or define actual IP addresses and IP ranges and whether or not those IP addresses and/or ranges are trusted by the organization. The recommended state is to define at least one trusted, IP range named location.", - "Checks": [], + "Checks": [ + "entra_conditional_access_trusted_named_location_exists" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1914,7 +1953,9 @@ { "Id": "5.2.2.15", "Description": "Conditional Access Policies can be used to block access from geographic locations that are deemed out-of-scope for your organization or application. The scope and variables for this policy should be carefully examined and defined. The recommended state is to configure at least one policy to block access from untrusted locations.", - "Checks": [], + "Checks": [ + "entra_conditional_access_policy_untrusted_locations_blocked" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1935,7 +1976,9 @@ { "Id": "5.2.2.16", "Description": "Token Protection is a Conditional Access session control that attempts to reduce token replay attacks by ensuring only device bound sign-in session tokens, like Primary Refresh Tokens (PRTs), are accepted by Microsoft Entra ID when applications request access to protected resources. When a user registers a supported device with Microsoft Entra, a PRT is issued and cryptographically bound to that device. This binding ensures that even if a threat actor steals the token, it can't be used from another device. With Token Protection enforced, Microsoft Entra validates that only these bound sign-in session tokens are used by supported applications. The recommended state is to enforce Token Protection for Office 365 Exchange Online, Office 365 SharePoint Online and Microsoft Teams Services.", - "Checks": [], + "Checks": [ + "entra_conditional_access_policy_token_protection_enforced" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1956,7 +1999,9 @@ { "Id": "5.2.2.17", "Description": "Authentication transfer is a flow that lets users seamlessly transfer authenticated state from one device to another. For example, users might see a QR code in the desktop version of Outlook that, when scanned on their mobile device, transfers their authenticated state to the mobile device. The recommended state is to block Authentication transfer.", - "Checks": [], + "Checks": [ + "entra_conditional_access_policy_authentication_transfer_blocked" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1977,7 +2022,9 @@ { "Id": "5.2.3.1", "Description": "Microsoft provides supporting settings to enhance the configuration of the Microsoft Authenticator application. These settings provide users with additional information and context when they receive MFA passwordless and push requests, including the geographic location of the request, the requesting application, and a requirement for number matching. The recommended state is Enabled for the following: - Show application name in push and passwordless notifications - Show geographic location in push and passwordless notifications Note: On February 27, 2023 Microsoft started enforcing number matching tenant-wide for all users using Microsoft Authenticator.", - "Checks": [], + "Checks": [ + "entra_authentication_method_authenticator_show_context" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -1998,7 +2045,9 @@ { "Id": "5.2.3.2", "Description": "With Entra Password Protection, default global banned password lists are automatically applied to all users in an Entra ID tenant. To support business and security needs, custom banned password lists can be defined. When users change or reset their passwords, these banned password lists are checked to enforce the use of strong passwords. A custom banned password list should include some of the following examples: - Brand names - Product names - Locations, such as company headquarters - Company-specific internal terms - Abbreviations that have specific company meaning", - "Checks": [], + "Checks": [ + "entra_password_protection_custom_banned_list_enforced" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -2019,7 +2068,9 @@ { "Id": "5.2.3.3", "Description": "Microsoft Entra Password Protection provides a global and custom banned password list. A password change request fails if there's a match in these banned password list. To protect on-premises Active Directory Domain Services (AD DS) environment, install and configure Entra Password Protection. Note: This recommendation applies to Hybrid deployments only and will have no impact unless working with on-premises Active Directory.", - "Checks": [], + "Checks": [ + "entra_password_protection_on_premises_enforced" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -2086,7 +2137,9 @@ { "Id": "5.2.3.6", "Description": "System-preferred multifactor authentication (MFA) prompts users to sign in by using the most secure method they registered. The user is prompted to sign-in with the most secure method according to the below order. The order of authentication methods is dynamic. It's updated by Microsoft as the security landscape changes, and as better authentication methods emerge. 1. Temporary Access Pass 2. Passkey (FIDO2) 3. Microsoft Authenticator notifications 4. External authentication methods 5. Time-based one-time password (TOTP) 6. Telephony 7. Certificate-based authentication The recommended state is Enabled.", - "Checks": [], + "Checks": [ + "entra_authentication_method_system_preferred_mfa_enabled" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -2107,7 +2160,9 @@ { "Id": "5.2.3.7", "Description": "Authentication methods support a wide variety of scenarios for signing in to Microsoft 365 resources. Some of these methods are inherently more secure than others but require more investment in time to get users enrolled and operational. The email one-time passcode feature is a way to authenticate B2B collaboration users when they can't be authenticated through other means, such as Microsoft Entra ID, Microsoft account (MSA), or social identity providers. When a B2B guest user tries to redeem your invitation or sign in to your shared resources, they can request a temporary passcode, which is sent to their email address. Then they enter this passcode to continue signing in. The recommended state is to Disable email OTP.", - "Checks": [], + "Checks": [ + "entra_authentication_method_email_otp_disabled" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -2128,7 +2183,9 @@ { "Id": "5.2.3.8", "Description": "The account lockout threshold determines how many failed login attempts are permitted prior to placing the account in a locked-out state and initiating a variable lockout duration. The recommended Lockout threshold is 10 or less.", - "Checks": [], + "Checks": [ + "entra_password_protection_lockout_threshold_limited" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -2149,7 +2206,9 @@ { "Id": "5.2.3.9", "Description": "The account lockout duration value determines how long an account retains the status of lockout, and therefore how long before a user can continue to attempt to login after passing the lockout threshold. The recommended state is Lockout duration in seconds is at least 60.", - "Checks": [], + "Checks": [ + "entra_password_protection_lockout_duration_configured" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -2170,7 +2229,9 @@ { "Id": "5.2.3.10", "Description": "Microsoft Entra ID includes a feature called Authenticator Lite, which embeds a subset of Microsoft Authenticator functionality into companion applications such as Outlook mobile. When enabled, users can satisfy MFA requirements using push notifications or time-based one-time passcodes (TOTP) directly from the companion application without installing the standalone Microsoft Authenticator app. The recommended state is Microsoft Authenticator on companion applications set to Disabled.", - "Checks": [], + "Checks": [ + "entra_authentication_method_authenticator_companion_app_disabled" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -2317,7 +2378,9 @@ { "Id": "5.3.2", "Description": "Access reviews enable administrators to establish an efficient automated process for reviewing group memberships, access to enterprise applications, and role assignments. These reviews can be scheduled to recur regularly, with flexible options for delegating the task of reviewing membership to different members of the organization. When configured for guest users, access reviews can automatically remove access if no reviewer responds within the review period, enforcing a fail-closed posture for external identities.", - "Checks": [], + "Checks": [ + "entra_access_review_guest_users_configured" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -2338,7 +2401,9 @@ { "Id": "5.3.3", "Description": "Access reviews in Microsoft Entra Privileged Identity Management (PIM) enable administrators to periodically validate whether users still require their privileged role assignments. These reviews can be scheduled to recur on a regular cadence and can be delegated to reviewers other than the role holders themselves, such as security auditors.", - "Checks": [], + "Checks": [ + "entra_access_review_privileged_roles_configured" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -2359,7 +2424,9 @@ { "Id": "5.3.4", "Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Requiring approval before activation allows one of the selected approvers to first review and then approve the activation prior to PIM granted the role. The approver doesn't have to be a group member or owner. The recommended state is Require approval to activate for the Global Administrator role.", - "Checks": [], + "Checks": [ + "entra_pim_global_administrator_approval_required" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -2380,7 +2447,9 @@ { "Id": "5.3.5", "Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Requiring approval before activation allows one of the selected approvers to first review and then approve the activation prior to PIM granted the role. The approver doesn't have to be a group member or owner. The recommended state is Require approval to activate for the Privileged Role Administrator role.", - "Checks": [], + "Checks": [ + "entra_pim_privileged_role_administrator_approval_required" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", @@ -2571,7 +2640,9 @@ { "Id": "6.3.2", "Description": "Outlook on the web (OWA) mailbox policies include two settings that control personal account integration in Outlook. PersonalAccountsEnabled controls whether users can add personal email accounts (e.g., Outlook.com, Gmail, Yahoo) in the new Outlook for Windows. PersonalAccountCalendarsEnabled controls whether users can connect personal Outlook.com or Google calendars in Outlook on the web. Neither setting applies to classic Outlook for Windows, Outlook for Mac, or Outlook mobile apps. The recommended state for the default OWA Mailbox Policy is: - PersonalAccountsEnabled is set to False - PersonalAccountCalendarsEnabled is set to False", - "Checks": [], + "Checks": [ + "exchange_owa_mailbox_policy_personal_accounts_disabled" + ], "Attributes": [ { "Section": "6 Exchange admin center", @@ -2692,7 +2763,9 @@ { "Id": "6.5.5", "Description": "Direct Send is a method used to send emails directly to an Exchange Online customer's hosted mailboxes from on-premises devices, applications, or third-party cloud services using the customer's own accepted domain. This method does not require any form of authentication because, by its nature, it mimics incoming anonymous emails from the internet, apart from the sender domain. The recommended state is to configure RejectDirectSend to True.", - "Checks": [], + "Checks": [ + "exchange_organization_reject_direct_send_enabled" + ], "Attributes": [ { "Section": "6 Exchange admin center", @@ -3088,7 +3161,9 @@ { "Id": "8.2.4", "Description": "This setting controls the organization's external access with Teams \"trial-only\" tenants. These are tenants that don't have any purchased seats. When set to Blocked, users from these trial-only tenants aren't able to search and contact your users via chats, Teams calls, and meetings (using the users' authenticated identities) and your users aren't able to reach users in these trial-only tenants. Users from the trial-only tenant are also removed from existing chats. The recommended state for People in my organization can communicate with accounts in trial Teams tenant is Off.", - "Checks": [], + "Checks": [ + "teams_external_access_trial_tenants_blocked" + ], "Attributes": [ { "Section": "8 Microsoft Teams admin center", diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index d9cbdcee90..2836dc78ac 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -402,6 +402,39 @@ class M365PowerShell(PowerShellSession): "Get-MalwareFilterPolicy | ConvertTo-Json -Depth 10", json_parse=True ) + def get_eop_protection_policy_rule(self) -> dict: + """ + Get Exchange Online Protection (EOP) preset security policy rules. + + Returns: + dict: EOP protection policy rules in JSON format. + """ + return self.execute( + "Get-EOPProtectionPolicyRule | ConvertTo-Json -Depth 10", json_parse=True + ) + + def get_atp_protection_policy_rule(self) -> dict: + """ + Get Defender for Office 365 (ATP) preset security policy rules. + + Returns: + dict: ATP protection policy rules in JSON format. + """ + return self.execute( + "Get-ATPProtectionPolicyRule | ConvertTo-Json -Depth 10", json_parse=True + ) + + def get_email_tenant_settings(self) -> dict: + """ + Get Defender email tenant settings. + + Returns: + dict: Email tenant settings (e.g. EnablePriorityAccountProtection). + """ + return self.execute( + "Get-EmailTenantSettings | ConvertTo-Json -Depth 10", json_parse=True + ) + def get_malware_filter_rule(self) -> dict: """ Get Defender Malware Filter Rule. diff --git a/prowler/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/__init__.py b/prowler/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/admincenter_forms_phishing_protection_enabled.metadata.json b/prowler/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/admincenter_forms_phishing_protection_enabled.metadata.json new file mode 100644 index 0000000000..073e95eb44 --- /dev/null +++ b/prowler/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/admincenter_forms_phishing_protection_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "admincenter_forms_phishing_protection_enabled", + "CheckTitle": "Microsoft Forms internal phishing protection is enabled", + "CheckType": [], + "ServiceName": "admincenter", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The Microsoft Forms organization settings should have **internal phishing protection** (**isInOrgFormsPhishingScanEnabled**) enabled. This scans forms for common phishing keywords and blocks the collection of responses while suspicious content is present.", + "Risk": "Without internal **phishing** protection, malicious users (internal or compromised accounts) can use **Microsoft Forms** to create convincing phishing forms that harvest credentials or sensitive information from other employees.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-forms/administrator-settings-microsoft-forms" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft 365 admin center at https://admin.microsoft.com/\n2. Go to **Settings** > **Org settings** > **Services** > **Microsoft Forms**\n3. Under **Phishing protection**, check **Add internal phishing protection**\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable internal phishing protection for Microsoft Forms so forms are automatically scanned for phishing keywords.", + "Url": "https://hub.prowler.com/check/admincenter_forms_phishing_protection_enabled" + } + }, + "Categories": [ + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/admincenter_forms_phishing_protection_enabled.py b/prowler/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/admincenter_forms_phishing_protection_enabled.py new file mode 100644 index 0000000000..0dcca28fd1 --- /dev/null +++ b/prowler/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/admincenter_forms_phishing_protection_enabled.py @@ -0,0 +1,44 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.admincenter.admincenter_client import ( + admincenter_client, +) + + +class admincenter_forms_phishing_protection_enabled(Check): + """Check if Microsoft Forms internal phishing protection is enabled. + + The Microsoft Forms org settings should have internal phishing protection + (``isInOrgFormsPhishingScanEnabled``) enabled so forms are scanned for phishing + keywords. + + - PASS: Microsoft Forms internal phishing protection is enabled. + - FAIL: Microsoft Forms internal phishing protection is disabled. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + settings = admincenter_client.forms_settings + if not settings: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=settings, + resource_name="Microsoft Forms Settings", + resource_id="formsSettings", + ) + report.status = "FAIL" + report.status_extended = ( + "Microsoft Forms internal phishing protection is not enabled." + ) + + if settings.in_org_forms_phishing_scan_enabled: + report.status = "PASS" + report.status_extended = ( + "Microsoft Forms internal phishing protection is enabled." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/__init__.py b/prowler/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/admincenter_office_store_apps_restricted.metadata.json b/prowler/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/admincenter_office_store_apps_restricted.metadata.json new file mode 100644 index 0000000000..64dea3b142 --- /dev/null +++ b/prowler/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/admincenter_office_store_apps_restricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "admincenter_office_store_apps_restricted", + "CheckTitle": "Users cannot access the Office Store or start trials", + "CheckType": [], + "ServiceName": "admincenter", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The org-wide apps and services settings should disable **Let users access the Office Store** (**isOfficeStoreEnabled**) and **Let users start trials on behalf of your organization** (**isAppAndServicesTrialEnabled**). This prevents users from installing arbitrary store add-ins or provisioning trial services without administrative oversight.", + "Risk": "Allowing users to install **Office Store** add-ins or start service trials introduces unvetted third-party code and services into the tenant, which can access user data (mail, files) and expand the attack surface without administrator review.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/admin/misc/user-owned-apps-and-services" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft 365 admin center at https://admin.microsoft.com/\n2. Go to **Settings** > **Org settings** > **Services** > **User owned apps and services**\n3. Uncheck **Let users access the Office Store**\n4. Uncheck **Let users start trials on behalf of your organization**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable Office Store access and organization trials so that only administrators can introduce add-ins and services into the tenant.", + "Url": "https://hub.prowler.com/check/admincenter_office_store_apps_restricted" + } + }, + "Categories": [ + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/admincenter_office_store_apps_restricted.py b/prowler/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/admincenter_office_store_apps_restricted.py new file mode 100644 index 0000000000..de391ae8d4 --- /dev/null +++ b/prowler/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/admincenter_office_store_apps_restricted.py @@ -0,0 +1,47 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.admincenter.admincenter_client import ( + admincenter_client, +) + + +class admincenter_office_store_apps_restricted(Check): + """Check if users are restricted from accessing the Office Store and trials. + + The org-wide apps and services settings should have both **Office Store access** + (``isOfficeStoreEnabled``) and **trials on behalf of the organization** + (``isAppAndServicesTrialEnabled``) disabled. + + - PASS: Both Office Store access and trials are disabled. + - FAIL: Office Store access and/or trials are enabled. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + settings = admincenter_client.apps_and_services_settings + if not settings: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=settings, + resource_name="Apps and Services Settings", + resource_id="appsAndServicesSettings", + ) + report.status = "FAIL" + report.status_extended = ( + "Users are allowed to access the Office Store and/or start trials." + ) + + if ( + not settings.office_store_enabled + and not settings.app_and_services_trial_enabled + ): + report.status = "PASS" + report.status_extended = ( + "Users are not allowed to access the Office Store or start trials." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/admincenter/admincenter_service.py b/prowler/providers/m365/services/admincenter/admincenter_service.py index 3899dced67..b92a2aeb5a 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_service.py +++ b/prowler/providers/m365/services/admincenter/admincenter_service.py @@ -1,4 +1,5 @@ import asyncio +import json from typing import List, Optional from pydantic.v1 import BaseModel @@ -14,10 +15,12 @@ class AdminCenter(M365Service): self.organization_config = None self.sharing_policy = None + self.mailbox_policies = [] if self.powershell: if self.powershell.connect_exchange_online(): self.organization_config = self._get_organization_config() self.sharing_policy = self._get_sharing_policy() + self.mailbox_policies = self._get_mailbox_policy() self.powershell.close() created_loop = False @@ -46,12 +49,16 @@ class AdminCenter(M365Service): self._get_directory_roles(), self._get_groups(), self._get_password_policy(), + self._get_apps_and_services_settings(), + self._get_forms_settings(), ) ) self.directory_roles = attributes[0] self.groups = attributes[1] self.password_policy = attributes[2] + self.apps_and_services_settings = attributes[3] + self.forms_settings = attributes[4] if created_loop: asyncio.set_event_loop(None) @@ -69,6 +76,9 @@ class AdminCenter(M365Service): customer_lockbox_enabled=organization_configuration.get( "CustomerLockboxEnabled", False ), + bookings_enabled=organization_configuration.get( + "BookingsEnabled", True + ), ) except Exception as error: logger.error( @@ -76,6 +86,31 @@ class AdminCenter(M365Service): ) return organization_config + def _get_mailbox_policy(self): + logger.info("Microsoft365 - Getting OWA mailbox policy configuration...") + mailbox_policies = [] + try: + policies_data = self.powershell.get_mailbox_policy() + if policies_data: + if isinstance(policies_data, dict): + policies_data = [policies_data] + for policy in policies_data: + if policy: + mailbox_policies.append( + OwaMailboxPolicy( + id=policy.get("Id", ""), + is_default=policy.get("IsDefault", False), + bookings_mailbox_creation_enabled=policy.get( + "BookingsMailboxCreationEnabled", True + ), + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return mailbox_policies + def _get_sharing_policy(self): logger.info("M365 - Getting sharing policy...") sharing_policy = None @@ -225,6 +260,71 @@ class AdminCenter(M365Service): ) return password_policy + async def _get_apps_and_services_settings(self): + """Retrieve the org-wide apps and services settings (beta Graph). + + Fetches ``admin/appsAndServices/settings`` from the beta Graph endpoint to + read whether users can access the Office Store and start trials. + + Returns: + Optional[AppsAndServicesSettings]: The parsed settings, or None on error. + """ + logger.info("M365 - Getting apps and services settings...") + settings = None + try: + builder = self.client.admin.with_url( + "https://graph.microsoft.com/beta/admin/appsAndServices/settings" + ) + request_info = builder.to_get_request_information() + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if response: + data = json.loads(response) + settings = AppsAndServicesSettings( + office_store_enabled=data.get("isOfficeStoreEnabled", True), + app_and_services_trial_enabled=data.get( + "isAppAndServicesTrialEnabled", True + ), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return settings + + async def _get_forms_settings(self): + """Retrieve the Microsoft Forms org settings (beta Graph). + + Fetches ``admin/forms/settings`` from the beta Graph endpoint to read whether + internal phishing protection is enabled. + + Returns: + Optional[FormsSettings]: The parsed settings, or None on error. + """ + logger.info("M365 - Getting Microsoft Forms settings...") + settings = None + try: + builder = self.client.admin.with_url( + "https://graph.microsoft.com/beta/admin/forms/settings" + ) + request_info = builder.to_get_request_information() + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if response: + data = json.loads(response) + settings = FormsSettings( + in_org_forms_phishing_scan_enabled=data.get( + "isInOrgFormsPhishingScanEnabled", False + ), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return settings + class User(BaseModel): id: str @@ -255,9 +355,25 @@ class Organization(BaseModel): name: str guid: str customer_lockbox_enabled: bool + bookings_enabled: bool = True + + +class OwaMailboxPolicy(BaseModel): + id: str + is_default: bool = False + bookings_mailbox_creation_enabled: bool = True class SharingPolicy(BaseModel): name: str guid: str enabled: bool + + +class AppsAndServicesSettings(BaseModel): + office_store_enabled: bool = True + app_and_services_trial_enabled: bool = True + + +class FormsSettings(BaseModel): + in_org_forms_phishing_scan_enabled: bool = False diff --git a/prowler/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/__init__.py b/prowler/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/admincenter_shared_bookings_disabled.metadata.json b/prowler/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/admincenter_shared_bookings_disabled.metadata.json new file mode 100644 index 0000000000..292c160d8f --- /dev/null +++ b/prowler/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/admincenter_shared_bookings_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "admincenter_shared_bookings_disabled", + "CheckTitle": "Microsoft Shared Bookings is disabled", + "CheckType": [], + "ServiceName": "admincenter", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "Microsoft Bookings (Shared Bookings) should be turned off unless the organization has a business need for it. Bookings lets users create public booking pages so customers can schedule time with staff. It is considered compliant when it is disabled at the tenant level (**BookingsEnabled**) or when the default OWA mailbox policy blocks Bookings mailbox creation (**BookingsMailboxCreationEnabled**).", + "Risk": "**Bookings** pages are internet-facing and can expose staff names, email addresses, and availability, and may allow uncontrolled creation of Bookings mailboxes. Leaving Bookings enabled without a business need increases the organization's external attack surface and information disclosure risk.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/bookings/turn-bookings-on-or-off" + ], + "Remediation": { + "Code": { + "CLI": "Set-OrganizationConfig -BookingsEnabled $false", + "NativeIaC": "", + "Other": "1. Connect to Exchange Online PowerShell using Connect-ExchangeOnline\n2. To disable at the tenant level, run: Set-OrganizationConfig -BookingsEnabled $false\n3. Alternatively, disable in the default OWA mailbox policy: Set-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default -BookingsMailboxCreationEnabled $false", + "Terraform": "" + }, + "Recommendation": { + "Text": "Turn off Shared Bookings at the tenant level or in the default OWA mailbox policy unless there is a documented business need, in which case restrict who can create Bookings mailboxes.", + "Url": "https://hub.prowler.com/check/admincenter_shared_bookings_disabled" + } + }, + "Categories": [ + "internet-exposed", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/admincenter_shared_bookings_disabled.py b/prowler/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/admincenter_shared_bookings_disabled.py new file mode 100644 index 0000000000..69dbeaf2fe --- /dev/null +++ b/prowler/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/admincenter_shared_bookings_disabled.py @@ -0,0 +1,71 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.admincenter.admincenter_client import ( + admincenter_client, +) + + +class admincenter_shared_bookings_disabled(Check): + """Check if Microsoft Bookings (Shared Bookings) is disabled. + + Bookings is considered disabled and compliant when either it is turned off at the + tenant level (OrganizationConfig BookingsEnabled) or the default OWA mailbox + policy prevents creation of Bookings mailboxes (BookingsMailboxCreationEnabled). + + - PASS: Bookings is disabled at the tenant level or in the default OWA mailbox + policy. + - FAIL: Bookings is enabled at the tenant level and allowed by the default OWA + mailbox policy. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the check for Shared Bookings. + + Returns: + List[CheckReportM365]: A list of reports containing the result of the check. + """ + findings = [] + organization_config = admincenter_client.organization_config + if not organization_config: + return findings + + default_policy = next( + ( + policy + for policy in admincenter_client.mailbox_policies + if policy and policy.is_default + ), + None, + ) + + report = CheckReportM365( + metadata=self.metadata(), + resource=organization_config, + resource_name=organization_config.name, + resource_id=organization_config.guid, + ) + report.status = "FAIL" + report.status_extended = ( + "Shared Bookings is enabled at the tenant level and in the default OWA " + "mailbox policy." + ) + + tenant_disabled = not organization_config.bookings_enabled + policy_disabled = bool( + default_policy and not default_policy.bookings_mailbox_creation_enabled + ) + + if tenant_disabled or policy_disabled: + report.status = "PASS" + if tenant_disabled: + report.status_extended = ( + "Shared Bookings is disabled at the tenant level." + ) + else: + report.status_extended = ( + "Shared Bookings is disabled in the default OWA mailbox policy." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/defender/defender_antispam_connection_filter_policy_safe_list_off/defender_antispam_connection_filter_policy_safe_list_off.metadata.json b/prowler/providers/m365/services/defender/defender_antispam_connection_filter_policy_safe_list_off/defender_antispam_connection_filter_policy_safe_list_off.metadata.json index fc99632352..9f9d306322 100644 --- a/prowler/providers/m365/services/defender/defender_antispam_connection_filter_policy_safe_list_off/defender_antispam_connection_filter_policy_safe_list_off.metadata.json +++ b/prowler/providers/m365/services/defender/defender_antispam_connection_filter_policy_safe_list_off/defender_antispam_connection_filter_policy_safe_list_off.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "NotDefined", "ResourceGroup": "security", "Description": "**Microsoft Defender for Office 365 connection filter policy** safe list setting is evaluated. When enabled, mail from Microsoft-managed IPs skips spam filtering and some sender authentication. The finding indicates whether this implicit bypass is turned off.", - "Risk": "With the safe list on, inbound mail can bypass SPF/DKIM/DMARC and spam heuristics, allowing spoofed or phishing messages to reach inboxes. This risks credential theft (confidentiality), enables account takeover and tampering (integrity), and may lead to malware-driven outages (availability).", + "Risk": "With the safe list on, inbound mail can bypass SPF/DKIM/DMARC and spam heuristics, allowing spoofed or **phishing** messages to reach inboxes. This risks credential theft (confidentiality), enables account takeover and tampering (integrity), and may lead to malware-driven outages (availability).", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/defender-office-365/connection-filter-policies-configure", diff --git a/prowler/providers/m365/services/defender/defender_priority_account_protection_enabled/__init__.py b/prowler/providers/m365/services/defender/defender_priority_account_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/defender/defender_priority_account_protection_enabled/defender_priority_account_protection_enabled.metadata.json b/prowler/providers/m365/services/defender/defender_priority_account_protection_enabled/defender_priority_account_protection_enabled.metadata.json new file mode 100644 index 0000000000..ddcf95ff89 --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_priority_account_protection_enabled/defender_priority_account_protection_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "defender_priority_account_protection_enabled", + "CheckTitle": "Priority account protection is enabled", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "Priority account protection applies enhanced monitoring and protection to high-value accounts. The tenant-level flag **EnablePriorityAccountProtection** (from Get-EmailTenantSettings) should be enabled. This check evaluates the tenant-level enablement flag; tagging priority accounts and configuring alert policies must be verified separately.", + "Risk": "Without **priority account** protection, high-value targets such as executives receive the same protection as standard users, despite being far more likely to be targeted by **phishing** and **business email compromise**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/defender-office-365/priority-accounts-security-recommendations" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft Defender at https://security.microsoft.com/\n2. Go to **System** > **Settings** > **Email & collaboration** > **Priority account protection**\n3. Set **Priority account protection** to **On**\n4. Tag priority accounts and configure the associated alert policies", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable priority account protection, tag your high-value accounts as priority accounts, and configure the associated alert policies for enhanced monitoring.", + "Url": "https://hub.prowler.com/check/defender_priority_account_protection_enabled" + } + }, + "Categories": [ + "email-security", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Covers the tenant-level EnablePriorityAccountProtection flag only; alert-policy verification (Get-ProtectionAlert) requires a Security & Compliance PowerShell session that Prowler does not currently establish." +} diff --git a/prowler/providers/m365/services/defender/defender_priority_account_protection_enabled/defender_priority_account_protection_enabled.py b/prowler/providers/m365/services/defender/defender_priority_account_protection_enabled/defender_priority_account_protection_enabled.py new file mode 100644 index 0000000000..1a4b1be910 --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_priority_account_protection_enabled/defender_priority_account_protection_enabled.py @@ -0,0 +1,46 @@ +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_priority_account_protection_enabled(Check): + """Check if priority account protection is enabled. + + Priority account protection applies enhanced monitoring and protection to + high-value accounts. Its tenant-level flag ``EnablePriorityAccountProtection`` + (from Get-EmailTenantSettings) should be enabled. + + Note: This check covers the tenant-level enablement flag only. The full control + also requires priority accounts to be tagged and alert policies to be configured, + which must be verified manually. + + - PASS: Priority account protection is enabled at the tenant level. + - FAIL: Priority account protection is disabled at the tenant level. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + settings = defender_client.email_tenant_settings + if not settings: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=settings, + resource_name="Email Tenant Settings", + resource_id="emailTenantSettings", + ) + report.status = "FAIL" + report.status_extended = ( + "Priority account protection is not enabled at the tenant level." + ) + + if settings.priority_account_protection_enabled: + report.status = "PASS" + report.status_extended = ( + "Priority account protection is enabled at the tenant level." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/defender/defender_service.py b/prowler/providers/m365/services/defender/defender_service.py index ba377e2b79..19a7453d29 100644 --- a/prowler/providers/m365/services/defender/defender_service.py +++ b/prowler/providers/m365/services/defender/defender_service.py @@ -59,6 +59,9 @@ class Defender(M365Service): self.safe_links_policies = {} self.safe_links_rules = {} self.teams_protection_policy = None + self.eop_protection_policy_rules = [] + self.atp_protection_policy_rules = [] + self.email_tenant_settings = None if self.powershell: if self.powershell.connect_exchange_online(): self.malware_policies = self._get_malware_filter_policy() @@ -80,8 +83,88 @@ class Defender(M365Service): 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.eop_protection_policy_rules = ( + self._get_eop_protection_policy_rules() + ) + self.atp_protection_policy_rules = ( + self._get_atp_protection_policy_rules() + ) + self.email_tenant_settings = self._get_email_tenant_settings() self.powershell.close() + def _parse_protection_policy_rules(self, rules_data): + """Parse preset security policy rules into PresetSecurityPolicyRule models.""" + rules = [] + if not rules_data: + return rules + if isinstance(rules_data, dict): + rules_data = [rules_data] + for rule in rules_data: + if rule: + rules.append( + PresetSecurityPolicyRule( + name=rule.get("Name", rule.get("Identity", "")), + state=rule.get("State", ""), + sent_to=self._normalize_list(rule.get("SentTo")), + sent_to_member_of=self._normalize_list( + rule.get("SentToMemberOf") + ), + recipient_domain_is=self._normalize_list( + rule.get("RecipientDomainIs") + ), + ) + ) + return rules + + @staticmethod + def _normalize_list(value): + """Normalize a PowerShell scalar/list/None value into a list.""" + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + def _get_eop_protection_policy_rules(self): + logger.info("M365 - Getting Defender EOP protection policy rules...") + try: + return self._parse_protection_policy_rules( + self.powershell.get_eop_protection_policy_rule() + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return [] + + def _get_atp_protection_policy_rules(self): + logger.info("M365 - Getting Defender ATP protection policy rules...") + try: + return self._parse_protection_policy_rules( + self.powershell.get_atp_protection_policy_rule() + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return [] + + def _get_email_tenant_settings(self): + logger.info("M365 - Getting Defender email tenant settings...") + try: + data = self.powershell.get_email_tenant_settings() + if data: + return EmailTenantSettings( + priority_account_protection_enabled=data.get( + "EnablePriorityAccountProtection", False + ), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + def _get_malware_filter_policy(self): logger.info("M365 - Getting Defender malware filter policy...") malware_policies = [] @@ -826,3 +909,24 @@ class TeamsProtectionPolicy(BaseModel): identity: str zap_enabled: bool + + +class PresetSecurityPolicyRule(BaseModel): + """Model for a preset security policy rule (EOP or ATP).""" + + name: str = "" + state: str = "" + sent_to: list = [] + sent_to_member_of: list = [] + recipient_domain_is: list = [] + + @property + def has_recipients(self) -> bool: + """True if the rule targets at least one recipient scope.""" + return bool(self.sent_to or self.sent_to_member_of or self.recipient_domain_is) + + +class EmailTenantSettings(BaseModel): + """Model for Defender email tenant settings.""" + + priority_account_protection_enabled: bool = False diff --git a/prowler/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/__init__.py b/prowler/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/defender_strict_preset_security_policy_enabled.metadata.json b/prowler/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/defender_strict_preset_security_policy_enabled.metadata.json new file mode 100644 index 0000000000..e7a4af69c7 --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/defender_strict_preset_security_policy_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "defender_strict_preset_security_policy_enabled", + "CheckTitle": "Strict Preset Security Policy is enabled", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "The **Strict Preset Security Policy** applies Microsoft's recommended strict protection settings and should be enabled for both **Exchange Online Protection** (anti-phishing, anti-spam, anti-malware) and **Defender for Office 365** (Safe Attachments, Safe Links). The rules are exposed via Get-EOPProtectionPolicyRule and Get-ATPProtectionPolicyRule.", + "Risk": "Without the **Strict Preset Security Policy** enabled, mailboxes rely on weaker default or custom protection settings, increasing exposure to **phishing**, malware, and malicious links and attachments.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/defender-office-365/preset-security-policies" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft Defender at https://security.microsoft.com/\n2. Go to **Email & collaboration** > **Policies & rules** > **Threat policies** > **Preset security policies**\n3. Turn on the **Strict protection** preset and assign it to the appropriate users, groups, or domains (including priority accounts)", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the Strict Preset Security Policy for both Exchange Online Protection and Defender for Office 365 and assign it to your users and priority accounts.", + "Url": "https://hub.prowler.com/check/defender_strict_preset_security_policy_enabled" + } + }, + "Categories": [ + "email-security", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/defender_strict_preset_security_policy_enabled.py b/prowler/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/defender_strict_preset_security_policy_enabled.py new file mode 100644 index 0000000000..3dc8457b8d --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/defender_strict_preset_security_policy_enabled.py @@ -0,0 +1,66 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.defender.defender_client import defender_client + +STRICT_PRESET_NAME = "Strict Preset Security Policy" + + +class defender_strict_preset_security_policy_enabled(Check): + """Check if the Strict Preset Security Policy is enabled for EOP and Defender. + + The Strict Preset Security Policy applies Microsoft's recommended strict + protection settings. It should be enabled for both Exchange Online Protection + (anti-phishing, anti-spam, anti-malware) and Defender for Office 365 (Safe + Attachments, Safe Links). + + - PASS: The Strict Preset Security Policy is enabled for both EOP and Defender. + - FAIL: The Strict Preset Security Policy is not enabled for EOP and/or Defender. + """ + + def _has_enabled_strict_preset(self, rules) -> bool: + return any( + rule.name == STRICT_PRESET_NAME + and rule.state == "Enabled" + and rule.has_recipients + for rule in rules + ) + + def execute(self) -> List[CheckReportM365]: + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={ + "eop": [ + rule.dict() for rule in defender_client.eop_protection_policy_rules + ], + "atp": [ + rule.dict() for rule in defender_client.atp_protection_policy_rules + ], + }, + resource_name="Strict Preset Security Policy", + resource_id="strictPresetSecurityPolicy", + ) + + eop_enabled = self._has_enabled_strict_preset( + defender_client.eop_protection_policy_rules + ) + atp_enabled = self._has_enabled_strict_preset( + defender_client.atp_protection_policy_rules + ) + + if eop_enabled and atp_enabled: + report.status = "PASS" + report.status_extended = ( + "The Strict Preset Security Policy is enabled for both Exchange " + "Online Protection and Defender for Office 365." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "The Strict Preset Security Policy is not enabled for both Exchange " + "Online Protection and Defender for Office 365." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/__init__.py b/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.metadata.json b/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.metadata.json new file mode 100644 index 0000000000..a774ceee96 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_access_review_guest_users_configured", + "CheckTitle": "Access review for guest users is configured", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "An **access review** scoped to **guest users** should exist and be active so that external guest access is periodically recertified by reviewers. Requires a Microsoft Entra ID P2 license.", + "Risk": "Without recurring access reviews for guests, external accounts accumulate over time and retain access long after it is needed, expanding the attack surface and the risk of orphaned or abused guest accounts.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/id-governance/create-access-review" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **ID Governance** > **Access reviews** > **New access review**\n3. Set the scope to **Guest users only**\n4. Assign at least one non-guest reviewer, enable mail notifications and reminders\n5. Set a recurring schedule and create the review", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create a recurring access review scoped to guest users with assigned reviewers so guest access is periodically recertified.", + "Url": "https://hub.prowler.com/check/entra_access_review_guest_users_configured" + } + }, + "Categories": [ + "identity-access", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Requires Microsoft Entra ID P2." +} diff --git a/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.py b/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.py new file mode 100644 index 0000000000..a402b13436 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.py @@ -0,0 +1,64 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +ACTIVE_STATUSES = {"InProgress"} + + +class entra_access_review_guest_users_configured(Check): + """Check if an access review for guest users is configured and fail-closed. + + An access review scoped to guest users should exist, be active, and be + fail-closed: if reviewers do not respond, access is removed + (``defaultDecision`` = Deny with ``autoApplyDecisionsEnabled``), with mail + notifications and reminders enabled. + + - PASS: An active, fail-closed access review scoped to guest users exists. + - FAIL: No such access review exists (missing, inactive, or not fail-closed). + """ + + def _is_fail_closed(self, definition) -> bool: + return ( + definition.default_decision == "Deny" + and definition.auto_apply_enabled + and definition.mail_notifications_enabled + and definition.reminders_enabled + ) + + def execute(self) -> List[CheckReportM365]: + findings = [] + definitions = entra_client.access_review_definitions + + report = CheckReportM365( + metadata=self.metadata(), + resource=definitions if definitions else {}, + resource_name="Access Review Definitions", + resource_id="accessReviewDefinitions", + ) + report.status = "FAIL" + report.status_extended = ( + "No active fail-closed access review scoped to guest users is configured." + ) + + for definition in definitions: + if ( + definition.status in ACTIVE_STATUSES + and "guest" in definition.scope_query.lower() + and self._is_fail_closed(definition) + ): + report = CheckReportM365( + metadata=self.metadata(), + resource=definition, + resource_name=definition.display_name or "Access Review", + resource_id=definition.id, + ) + report.status = "PASS" + report.status_extended = ( + f"Access review '{definition.display_name or definition.id}' for " + "guest users is active and fail-closed." + ) + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/__init__.py b/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.metadata.json b/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.metadata.json new file mode 100644 index 0000000000..9c8e3a42a0 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_access_review_privileged_roles_configured", + "CheckTitle": "Access review for privileged roles is configured", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "An **access review** scoped to **privileged (PIM) directory roles** should exist and be active so privileged role assignments are periodically recertified by reviewers. Requires a Microsoft Entra ID P2 license.", + "Risk": "Without recurring access reviews of privileged roles, standing privileged assignments accumulate and persist beyond their need, increasing the risk of privilege misuse and expanding the impact of a compromised administrator account.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-create-roles-and-resource-roles-review" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **ID Governance** > **Privileged Identity Management** > **Microsoft Entra Roles** > **Access reviews** > **New**\n3. Scope the review to the privileged roles, set the assignment type to Eligible and Active\n4. Assign reviewers, enable notifications, and set a recurring schedule\n5. Create the review", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create a recurring access review scoped to privileged roles with assigned reviewers so privileged role assignments are periodically recertified.", + "Url": "https://hub.prowler.com/check/entra_access_review_privileged_roles_configured" + } + }, + "Categories": [ + "identity-access", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Requires Microsoft Entra ID P2." +} diff --git a/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.py b/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.py new file mode 100644 index 0000000000..28c10a6c2a --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.py @@ -0,0 +1,76 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +ACTIVE_STATUSES = {"InProgress"} +# Markers that indicate the review targets directory role assignments (PIM roles). +PRIVILEGED_SCOPE_MARKERS = ("roledefinition", "rolemanagement", "roleassignment") + + +class entra_access_review_privileged_roles_configured(Check): + """Check if an access review for privileged roles is configured and fail-closed. + + An access review scoped to privileged (PIM) directory roles should exist, be + active, and be fail-closed: if reviewers do not respond, access is removed + (``defaultDecision`` = Deny with ``autoApplyDecisionsEnabled``), with mail + notifications and reminders enabled. + + - PASS: An active, fail-closed access review scoped to privileged roles exists. + - FAIL: No such access review exists (missing, inactive, or not fail-closed). + """ + + def _targets_privileged_roles(self, definition) -> bool: + # For PIM role reviews the role reference lives in the resource scopes. + queries = [definition.scope_query] + definition.resource_scope_queries + return any( + marker in query.lower() + for query in queries + for marker in PRIVILEGED_SCOPE_MARKERS + ) + + def _is_fail_closed(self, definition) -> bool: + return ( + definition.default_decision == "Deny" + and definition.auto_apply_enabled + and definition.mail_notifications_enabled + and definition.reminders_enabled + ) + + def execute(self) -> List[CheckReportM365]: + findings = [] + definitions = entra_client.access_review_definitions + + report = CheckReportM365( + metadata=self.metadata(), + resource=definitions if definitions else {}, + resource_name="Access Review Definitions", + resource_id="accessReviewDefinitions", + ) + report.status = "FAIL" + report.status_extended = ( + "No active fail-closed access review scoped to privileged roles is " + "configured." + ) + + for definition in definitions: + if ( + definition.status in ACTIVE_STATUSES + and self._targets_privileged_roles(definition) + and self._is_fail_closed(definition) + ): + report = CheckReportM365( + metadata=self.metadata(), + resource=definition, + resource_name=definition.display_name or "Access Review", + resource_id=definition.id, + ) + report.status = "PASS" + report.status_extended = ( + f"Access review '{definition.display_name or definition.id}' for " + "privileged roles is active and fail-closed." + ) + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/__init__.py b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/entra_authentication_method_authenticator_companion_app_disabled.metadata.json b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/entra_authentication_method_authenticator_companion_app_disabled.metadata.json new file mode 100644 index 0000000000..10c3afeed5 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/entra_authentication_method_authenticator_companion_app_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_authentication_method_authenticator_companion_app_disabled", + "CheckTitle": "Microsoft Authenticator on companion applications is disabled", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The Microsoft Authenticator method should have **Authenticator Lite on companion applications** (**featureSettings.companionAppAllowedState**) set to **disabled**. Authenticator Lite embeds a subset of Authenticator functionality into companion apps such as Outlook mobile, expanding where approvals can occur.", + "Risk": "Allowing **MFA** approvals from **companion applications** broadens the set of apps and devices that can approve sign-ins, increasing the attack surface for MFA fatigue and reducing the assurance provided by the dedicated Authenticator app.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-mfa-authenticator-lite" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Authentication methods** > **Policies** > **Microsoft Authenticator**\n3. Select **Configure**\n4. Set **Microsoft Authenticator on companion applications** to **Disabled**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable Microsoft Authenticator on companion applications (Authenticator Lite) so that MFA approvals require the full Microsoft Authenticator app.", + "Url": "https://hub.prowler.com/check/entra_authentication_method_authenticator_companion_app_disabled" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/entra_authentication_method_authenticator_companion_app_disabled.py b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/entra_authentication_method_authenticator_companion_app_disabled.py new file mode 100644 index 0000000000..9b038ec89f --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/entra_authentication_method_authenticator_companion_app_disabled.py @@ -0,0 +1,43 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_authentication_method_authenticator_companion_app_disabled(Check): + """Check if Microsoft Authenticator on companion applications is disabled. + + Authenticator Lite embeds a subset of Microsoft Authenticator functionality into + companion applications such as Outlook mobile. The + ``featureSettings.companionAppAllowedState`` should be disabled so MFA is bound to + the full Authenticator app. + + - PASS: Microsoft Authenticator on companion applications is disabled. + - FAIL: Microsoft Authenticator on companion applications is not disabled. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + settings = entra_client.authentication_methods_policy_settings + if not settings: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=settings, + resource_name="Microsoft Authenticator Method", + resource_id="microsoftAuthenticator", + ) + report.status = "FAIL" + report.status_extended = ( + "Microsoft Authenticator on companion applications is not disabled." + ) + + if settings.authenticator_companion_app_state == "disabled": + report.status = "PASS" + report.status_extended = ( + "Microsoft Authenticator on companion applications is disabled." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/__init__.py b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.metadata.json b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.metadata.json new file mode 100644 index 0000000000..77fbc3b7cf --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_authentication_method_authenticator_show_context", + "CheckTitle": "Microsoft Authenticator shows application name and geographic location", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The Microsoft Authenticator authentication method should have the **Show application name** (**displayAppInformationRequiredState**) and **Show geographic location** (**displayLocationInformationRequiredState**) feature settings enabled. This adds context to push and passwordless notifications so users can detect and reject fraudulent approval requests.", + "Risk": "Without application-name and location context, users approving **MFA** push notifications cannot tell a legitimate sign-in from an attacker-initiated one, making the tenant more vulnerable to MFA fatigue and prompt-bombing attacks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-mfa-additional-context" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Authentication methods** > **Policies** > **Microsoft Authenticator**\n3. Select **Configure**\n4. Set **Show application name in push and passwordless notifications** to **Enabled**, Target **All users**\n5. Set **Show geographic location in push and passwordless notifications** to **Enabled**, Target **All users**\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the application-name and geographic-location context settings for Microsoft Authenticator so users receive additional information to identify and reject fraudulent sign-in approvals.", + "Url": "https://hub.prowler.com/check/entra_authentication_method_authenticator_show_context" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.py b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.py new file mode 100644 index 0000000000..772278143b --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.py @@ -0,0 +1,48 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_authentication_method_authenticator_show_context(Check): + """Check if Microsoft Authenticator shows application name and geographic location. + + The Microsoft Authenticator method should be enabled with the + ``displayAppInformationRequiredState`` and + ``displayLocationInformationRequiredState`` feature settings enabled, so users see + the app name and sign-in location context in push and passwordless notifications. + + - PASS: Both application name and geographic location context are shown. + - FAIL: Application name and/or geographic location context is not shown. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + settings = entra_client.authentication_methods_policy_settings + if not settings: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=settings, + resource_name="Microsoft Authenticator Method", + resource_id="microsoftAuthenticator", + ) + report.status = "FAIL" + report.status_extended = ( + "Microsoft Authenticator does not show both application name and " + "geographic location in notifications." + ) + + if ( + settings.authenticator_display_app_information_state == "enabled" + and settings.authenticator_display_location_information_state == "enabled" + ): + report.status = "PASS" + report.status_extended = ( + "Microsoft Authenticator shows application name and geographic " + "location in notifications." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/__init__.py b/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.metadata.json b/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.metadata.json new file mode 100644 index 0000000000..b57c9a0431 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_authentication_method_email_otp_disabled", + "CheckTitle": "Email One-Time Passcode authentication method is disabled in the tenant", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra tenant's authentication methods policy should have the **Email OTP** authentication method disabled. Email one-time passcodes depend on the security of the recipient mailbox, which is typically a lower-assurance channel, and should not be used as a primary or fallback multi-factor authentication method for members.", + "Risk": "**Email OTP** is vulnerable to mailbox compromise and **phishing**. If an attacker gains access to a user's mailbox, they can intercept one-time passcodes and bypass **MFA**, gaining unauthorized access to the tenant.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-authentication-email-otp" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Authentication methods** > **Policies**\n3. Select **Email OTP** and set its status to **Disabled**, then click **Save**\n4. Ensure users have phishing-resistant MFA methods configured (e.g., FIDO2, Microsoft Authenticator)", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable the Email OTP authentication method and require phishing-resistant MFA methods such as FIDO2 security keys or Microsoft Authenticator across the tenant.", + "Url": "https://hub.prowler.com/check/entra_authentication_method_email_otp_disabled" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.py b/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.py new file mode 100644 index 0000000000..3a9545ba38 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.py @@ -0,0 +1,51 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_authentication_method_email_otp_disabled(Check): + """ + Ensure that the Email One-Time Passcode (OTP) authentication method is disabled. + + This check verifies that the tenant's authentication methods policy has the Email OTP + method disabled. Email OTP relies on the security of the mailbox, which is often a + lower-assurance channel and is unsuitable as a primary or fallback MFA method. + + - PASS: Email OTP authentication method is disabled. + - FAIL: Email OTP authentication method is enabled. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the Email OTP authentication method check. + + Returns: + A list with a single report containing the result of the check. + """ + findings = [] + configs = entra_client.authentication_method_configurations + + email_config = configs.get("Email") + + if email_config: + report = CheckReportM365( + metadata=self.metadata(), + resource=email_config, + resource_name="Email OTP Authentication Method", + resource_id=entra_client.tenant_domain, + ) + + if email_config.state == "enabled": + report.status = "FAIL" + report.status_extended = ( + "Email OTP authentication method is enabled in the tenant." + ) + else: + report.status = "PASS" + report.status_extended = ( + "Email OTP authentication method is disabled in the tenant." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/__init__.py b/prowler/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/entra_authentication_method_system_preferred_mfa_enabled.metadata.json b/prowler/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/entra_authentication_method_system_preferred_mfa_enabled.metadata.json new file mode 100644 index 0000000000..19fff1cd6b --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/entra_authentication_method_system_preferred_mfa_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_authentication_method_system_preferred_mfa_enabled", + "CheckTitle": "System-preferred multifactor authentication is enabled for all users", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant authentication methods policy should have **system-preferred multifactor authentication** (**systemCredentialPreferences**) enabled and targeting **all users**. System-preferred MFA prompts users to authenticate with the most secure method they have registered.", + "Risk": "Without **system-preferred** **MFA**, users may default to weaker registered methods (such as SMS) instead of stronger ones (such as Authenticator push or passwordless), lowering the overall assurance of multifactor authentication.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-system-preferred-multifactor-authentication" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Authentication methods** > **Settings**\n3. Set **System-preferred multifactor authentication** to **Enabled** and Target **All users**\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable system-preferred multifactor authentication for all users so Entra prompts each user to sign in with the most secure method they have registered.", + "Url": "https://hub.prowler.com/check/entra_authentication_method_system_preferred_mfa_enabled" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/entra_authentication_method_system_preferred_mfa_enabled.py b/prowler/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/entra_authentication_method_system_preferred_mfa_enabled.py new file mode 100644 index 0000000000..e1556cfe26 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/entra_authentication_method_system_preferred_mfa_enabled.py @@ -0,0 +1,51 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +ALL_USERS_TARGETS = {"all_users", "all users"} + + +class entra_authentication_method_system_preferred_mfa_enabled(Check): + """Check if system-preferred multifactor authentication is enabled for all users. + + System-preferred MFA prompts users to sign in with the most secure method they + have registered. Its ``systemCredentialPreferences.state`` should be enabled and + target all users. + + - PASS: System-preferred MFA is enabled and targets all users. + - FAIL: System-preferred MFA is disabled or does not target all users. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + settings = entra_client.authentication_methods_policy_settings + if not settings: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=settings, + resource_name="System-preferred MFA", + resource_id="systemCredentialPreferences", + ) + report.status = "FAIL" + report.status_extended = ( + "System-preferred multifactor authentication is not enabled for all users." + ) + + targets = { + str(target).lower() + for target in settings.system_preferred_mfa_include_targets + } + if ( + settings.system_preferred_mfa_state == "enabled" + and targets & ALL_USERS_TARGETS + ): + report.status = "PASS" + report.status_extended = ( + "System-preferred multifactor authentication is enabled for all users." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked.metadata.json new file mode 100644 index 0000000000..e18bd0f60d --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_authentication_transfer_blocked", + "CheckTitle": "Conditional Access policy blocks authentication transfer", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "At least one enabled Conditional Access policy should target the **authentication transfer** flow for **all users** and **all resources** and set the grant control to **Block**. Authentication transfer lets a user move an authenticated session from one device to another (for example, by scanning a QR code), which can be abused to move a session onto an attacker-controlled device.", + "Risk": "If authentication transfer is not blocked, an attacker who can present a transfer prompt (e.g., a malicious QR code) may capture an authenticated session and gain access to the victim's resources without needing their credentials or **MFA**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-conditions" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include **All users** (exclude only documented break-glass accounts)\n4. Under **Target resources**, select **All resources**\n5. Under **Conditions** > **Authentication flows**, enable and select **Authentication transfer**\n6. Under **Grant**, select **Block access**\n7. Set the policy to **On** and click **Create**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create a Conditional Access policy that blocks the authentication transfer flow for all users and all resources, excluding only documented break-glass accounts.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_authentication_transfer_blocked" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked.py new file mode 100644 index 0000000000..58f3149c5d --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked.py @@ -0,0 +1,81 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + TransferMethod, +) + + +class entra_conditional_access_policy_authentication_transfer_blocked(Check): + """Check if at least one Conditional Access policy blocks authentication transfer. + + This check ensures that at least one enabled Conditional Access policy targets + the authentication transfer flow and blocks access, preventing an authenticated + session from being seamlessly transferred to another (potentially attacker + controlled) device. + + - PASS: An enabled Conditional Access policy blocks authentication transfer. + - FAIL: No Conditional Access policy restricts authentication transfer. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check to verify authentication transfer is blocked by a Conditional Access policy. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = ( + "No Conditional Access Policy blocks authentication transfer." + ) + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + if not policy.conditions.authentication_flows: + continue + + if ( + TransferMethod.AUTHENTICATION_TRANSFER + not in policy.conditions.authentication_flows.transfer_methods + ): + continue + + if ( + ConditionalAccessGrantControl.BLOCK + in policy.grant_controls.built_in_controls + ): + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' reports authentication transfer but does not block it." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks authentication transfer." + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk.metadata.json new file mode 100644 index 0000000000..3eb32f2730 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_block_high_medium_sign_in_risk", + "CheckTitle": "Conditional Access policy blocks high and medium sign-in risk", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "At least one enabled Conditional Access policy should target **all users** and **all resources** with the **sign-in risk** condition set to **High** and **Medium** and the grant control set to **Block**. Microsoft Entra ID Protection evaluates sign-in risk in real time; blocking risky sign-ins prevents access from sessions that are likely not performed by the legitimate account owner.", + "Risk": "Without a **sign-in risk** based **Conditional Access** policy, sign-ins flagged as high or medium risk (indicative of compromised credentials, anomalous locations, or malicious IPs) are allowed, giving attackers a path to access resources with stolen credentials.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/id-protection/concept-identity-protection-policies", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-conditions#sign-in-risk" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include **All users** (exclude only documented break-glass accounts)\n4. Under **Target resources**, select **All resources**\n5. Under **Conditions** > **Sign-in risk**, select **High** and **Medium**\n6. Under **Grant**, select **Block access**\n7. Set the policy to **On** and click **Create**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create a risk-based Conditional Access policy that blocks high and medium sign-in risk for all users and all resources, excluding only documented break-glass accounts. Microsoft recommends configuring risk-based policies in Conditional Access rather than in the legacy Identity Protection policies.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_block_high_medium_sign_in_risk" + } + }, + "Categories": [ + "identity-access", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk.py new file mode 100644 index 0000000000..99a2328eb0 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk.py @@ -0,0 +1,80 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + RiskLevel, +) + + +class entra_conditional_access_policy_block_high_medium_sign_in_risk(Check): + """Check if a Conditional Access policy blocks high and medium sign-in risk. + + This check ensures that at least one enabled Conditional Access policy targets + all users and all resources with the sign-in risk condition set to High and + Medium, and blocks access, mitigating risky sign-in attempts detected by + Microsoft Entra ID Protection. + + - PASS: An enabled Conditional Access policy blocks high and medium sign-in risk. + - FAIL: No Conditional Access policy blocks high and medium sign-in risk. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check to verify high and medium sign-in risk is blocked. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = ( + "No Conditional Access Policy blocks high and medium sign-in risk." + ) + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + if policy.conditions.application_conditions.excluded_applications: + continue + + if not {RiskLevel.HIGH, RiskLevel.MEDIUM}.issubset( + set(policy.conditions.sign_in_risk_levels) + ): + continue + + if ( + ConditionalAccessGrantControl.BLOCK + in policy.grant_controls.built_in_controls + ): + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' reports high and medium sign-in risk but does not block it." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks high and medium sign-in risk." + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required.metadata.json index fe2985fa09..e49749af41 100644 --- a/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required.metadata.json +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "Microsoft Entra **Conditional Access** policies can require **multifactor authentication (MFA)** for **device registration** operations.\n\nThis control ensures users must complete MFA before **registering or joining devices** to the directory, reducing the likelihood that compromised credentials can be used to register rogue devices.", - "Risk": "Without MFA for device registration, attackers with stolen credentials could register unauthorized devices into the directory, gain persistence, and bypass compliance-based Conditional Access protections that rely on trusted device state.", + "Risk": "Without **MFA** for device registration, attackers with stolen credentials could register unauthorized devices into the directory, gain persistence, and bypass compliance-based **Conditional Access** protections that rely on trusted device state.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-all-users-device-registration", diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded.metadata.json index b7266f1f56..6ac5281620 100644 --- a/prowler/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded.metadata.json +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_directory_sync_account_excluded/entra_conditional_access_policy_directory_sync_account_excluded.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "Conditional Access policies scoped to **all users** and **all cloud applications** are evaluated to confirm the **Directory Synchronization Accounts** role is explicitly excluded. The Microsoft Entra Connect Sync Account does not support multifactor authentication, so it must be excluded from restrictive policies to maintain directory synchronization.", - "Risk": "If the Directory Synchronization Accounts role is not excluded from Conditional Access policies requiring MFA or blocking access, the Entra Connect Sync Account will be unable to authenticate. This breaks hybrid identity synchronization between on-premises Active Directory and Entra ID, potentially causing authentication failures and identity inconsistencies.", + "Risk": "If the Directory Synchronization Accounts role is not excluded from **Conditional Access** policies requiring **MFA** or blocking access, the Entra Connect Sync Account will be unable to authenticate. This breaks hybrid identity synchronization between **on-premises** Active Directory and Entra ID, potentially causing authentication failures and identity inconsistencies.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-all-users-mfa", diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.metadata.json index c352854003..3c748762b6 100644 --- a/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.metadata.json +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "Microsoft Entra **Conditional Access** is verified to have at least one **enabled** policy that explicitly includes the **Azure DevOps** cloud application. Policies targeting **All** cloud apps do not satisfy this check because the goal is to verify that Azure DevOps has been deliberately considered.", - "Risk": "Without an explicit Conditional Access policy for Azure DevOps, organizations may rely on broad policies that do not account for Azure DevOps-specific access patterns such as CLI, IDE plug-ins, PAT-based workflows, source code access, build pipelines, secrets, and service connections.", + "Risk": "Without an explicit **Conditional Access** policy for Azure DevOps, organizations may rely on broad policies that do not account for Azure DevOps-specific access patterns such as CLI, IDE plug-ins, PAT-based workflows, source code access, build pipelines, secrets, and service connections.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0", diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json index 42b3acaeb6..6bcef2c25b 100644 --- a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "Every object identifier referenced by any Conditional Access policy under conditions.users (includeUsers, excludeUsers, includeGroups, excludeGroups, includeRoles, excludeRoles) must resolve to an existing Microsoft Entra object. This check audits all Conditional Access policies regardless of state and reports any whose user, group, or role references no longer resolve in the directory.", - "Risk": "When a user, group, or directory role referenced by a Conditional Access policy stops resolving (account or group deleted, role template removed), the reference becomes orphaned. include* references silently shrink the policy's enforcement scope; exclude* references can cause the policy to evaluate unexpectedly. This is a common root cause of MFA-not-applied incidents.", + "Risk": "When a user, group, or directory role referenced by a **Conditional Access** policy stops resolving (account or group deleted, role template removed), the reference becomes orphaned. include* references silently shrink the policy's enforcement scope; exclude* references can cause the policy to evaluate unexpectedly. This is a common root cause of **MFA**-not-applied incidents.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0", diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users.metadata.json new file mode 100644 index 0000000000..44acb444d8 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_sign_in_frequency_all_users", + "CheckTitle": "Conditional Access policy enforces sign-in frequency for all users", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "At least one enabled Conditional Access policy should target **all users** and **all resources** and enforce a **sign-in frequency of 7 days or less**. Sign-in frequency defines how long an authenticated session remains valid before the user must reauthenticate. The Microsoft Entra default is a rolling 90-day window, which is too permissive.", + "Risk": "Long-lived sessions increase the window in which a stolen token or hijacked session can be used. Without an enforced reauthentication interval, a compromised session can remain valid for up to 90 days, extending an attacker's access.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-session-lifetime" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include **All users** (exclude only documented break-glass accounts)\n4. Under **Target resources**, select **All resources**\n5. Under **Session**, enable **Sign-in frequency** and set it to **7 days** or less\n6. Set the policy to **On** and click **Create**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create a Conditional Access policy that enforces a sign-in frequency of 7 days or less for all users and all resources, excluding only documented break-glass accounts.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_sign_in_frequency_all_users" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users.py new file mode 100644 index 0000000000..e6b0de7950 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users.py @@ -0,0 +1,101 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicyState, + SignInFrequencyInterval, + SignInFrequencyType, +) + +# Maximum allowed reauthentication window (CIS: 7 days or less). +MAX_SIGN_IN_FREQUENCY_DAYS = 7 +MAX_SIGN_IN_FREQUENCY_HOURS = MAX_SIGN_IN_FREQUENCY_DAYS * 24 + + +class entra_conditional_access_policy_sign_in_frequency_all_users(Check): + """Check if a Conditional Access policy enforces sign-in frequency for all users. + + This check ensures that at least one enabled Conditional Access policy targets + all users and all resources and enforces a sign-in frequency of 7 days or less, + limiting how long an authenticated session remains valid before reauthentication. + + - PASS: An enabled Conditional Access policy enforces sign-in frequency of 7 days + or less for all users. + - FAIL: No Conditional Access policy enforces sign-in frequency of 7 days or less + for all users. + """ + + def _is_within_limit(self, sign_in_frequency) -> bool: + """Return True if the sign-in frequency is 7 days or less.""" + if not sign_in_frequency or not sign_in_frequency.is_enabled: + return False + + if sign_in_frequency.interval == SignInFrequencyInterval.EVERY_TIME: + return True + + if sign_in_frequency.frequency is None: + return False + + if sign_in_frequency.type == SignInFrequencyType.DAYS: + return sign_in_frequency.frequency <= MAX_SIGN_IN_FREQUENCY_DAYS + + if sign_in_frequency.type == SignInFrequencyType.HOURS: + return sign_in_frequency.frequency <= MAX_SIGN_IN_FREQUENCY_HOURS + + return False + + def execute(self) -> list[CheckReportM365]: + """Execute the check to verify sign-in frequency is enforced for all users. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = "No Conditional Access Policy enforces a sign-in frequency of 7 days or less for all users." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + # A policy scoped to risky sign-ins/users does not enforce periodic + # reauthentication for all sessions, so it does not satisfy this control. + if ( + policy.conditions.sign_in_risk_levels + or policy.conditions.user_risk_levels + ): + continue + + if not self._is_within_limit(policy.session_controls.sign_in_frequency): + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces sign-in frequency but is in report-only mode." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces a sign-in frequency of 7 days or less for all users." + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced.metadata.json new file mode 100644 index 0000000000..2a1c1ec562 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_token_protection_enforced", + "CheckTitle": "Conditional Access policy enforces Token Protection", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "At least one enabled Conditional Access policy should enforce **Token Protection** (the **secureSignInSession** session control) for targeted users. Token Protection binds sign-in session tokens (such as Primary Refresh Tokens) to the device, reducing the effectiveness of token theft and replay attacks.", + "Risk": "Without **Token Protection**, stolen sign-in tokens can be replayed from an attacker's device to impersonate the user and bypass authentication, including **MFA**, because the token is not cryptographically bound to the original device.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-token-protection" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include the targeted users (exclude only documented break-glass accounts)\n4. Under **Target resources**, select the supported applications (e.g., Exchange Online, SharePoint Online)\n5. Under **Conditions** > **Device platforms**, include Windows\n6. Under **Session**, enable **Require token protection for sign-in sessions**\n7. Set the policy to **On** and click **Create**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create a Conditional Access policy that enforces Token Protection (secure sign-in session) for supported applications on Windows devices to mitigate token theft and replay.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_token_protection_enforced" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced.py new file mode 100644 index 0000000000..496405ee21 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced.py @@ -0,0 +1,99 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ClientAppType, + ConditionalAccessPolicyState, +) + +# Applications that must be covered by the Token Protection policy. +EXCHANGE_ONLINE_APP_ID = "00000002-0000-0ff1-ce00-000000000000" +SHAREPOINT_ONLINE_APP_ID = "00000003-0000-0ff1-ce00-000000000000" +TEAMS_APP_ID = "cc15fd57-2c6c-4117-a88c-83b1d56b4bbe" +REQUIRED_APP_IDS = {EXCHANGE_ONLINE_APP_ID, SHAREPOINT_ONLINE_APP_ID, TEAMS_APP_ID} + + +class entra_conditional_access_policy_token_protection_enforced(Check): + """Check if a Conditional Access policy enforces Token Protection. + + Token Protection is a Conditional Access session control that reduces token + replay attacks by requiring device-bound sign-in session tokens. At least one + enabled Conditional Access policy should enable the ``secureSignInSession`` + session control for the supported applications (Exchange Online, SharePoint + Online, Teams), target the Windows platform, and apply to mobile apps and desktop + clients. + + - PASS: An enabled Conditional Access policy enforces Token Protection for the + supported applications on Windows desktop/mobile clients. + - FAIL: No Conditional Access policy enforces Token Protection with the required + scope. + """ + + def _applications_covered(self, included_applications) -> bool: + if "All" in included_applications: + return True + return REQUIRED_APP_IDS.issubset(set(included_applications)) + + def _windows_targeted(self, conditions) -> bool: + platform_conditions = conditions.platform_conditions + if not platform_conditions: + return False + return "windows" in platform_conditions.include_platforms + + def _desktop_clients_targeted(self, conditions) -> bool: + client_app_types = conditions.client_app_types or [] + return ( + ClientAppType.ALL in client_app_types + or ClientAppType.MOBILE_APPS_AND_DESKTOP_CLIENTS in client_app_types + ) + + def execute(self) -> list[CheckReportM365]: + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = ( + "No Conditional Access Policy enforces Token Protection for the supported " + "applications on Windows desktop and mobile clients." + ) + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if not policy.conditions.user_conditions.included_users: + continue + + if not policy.session_controls.secure_sign_in_session_enabled: + continue + + if not self._applications_covered( + policy.conditions.application_conditions.included_applications + ): + continue + + if not self._windows_targeted(policy.conditions): + continue + + if not self._desktop_clients_targeted(policy.conditions): + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' enables Token Protection but is in report-only mode." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces Token Protection for the supported applications on Windows desktop and mobile clients." + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked.metadata.json new file mode 100644 index 0000000000..1dbede3d19 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_untrusted_locations_blocked", + "CheckTitle": "Conditional Access policy blocks access from untrusted locations", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "At least one enabled Conditional Access policy should target **all users** and **all resources**, include **all network locations**, exclude **trusted locations**, and set the grant control to **Block**. This blocks access originating from locations that are not explicitly trusted by the organization.", + "Risk": "Without a location-based block policy, users can authenticate from any geography or network, including regions where the organization has no presence, giving attackers operating from those locations a viable path to access resources with stolen credentials.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-assignment-network" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include **All users** (exclude only documented break-glass accounts)\n4. Under **Target resources**, select **All resources**\n5. Under **Network**, include **Any location** and exclude **All trusted locations** (or selected trusted named locations)\n6. Under **Grant**, select **Block access**\n7. Set the policy to **On** and click **Create**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create a Conditional Access policy that blocks access from all locations except trusted named locations, targeting all users and resources, excluding only documented break-glass accounts.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_untrusted_locations_blocked" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked.py new file mode 100644 index 0000000000..4069da9417 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked.py @@ -0,0 +1,97 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, +) + + +class entra_conditional_access_policy_untrusted_locations_blocked(Check): + """Check if a Conditional Access policy blocks access from untrusted locations. + + At least one enabled Conditional Access policy should target all users and all + resources, include all network locations, exclude trusted locations, and block + access, so that access from untrusted locations is denied. + + - PASS: An enabled Conditional Access policy blocks access from untrusted + locations. + - FAIL: No Conditional Access policy blocks access from untrusted locations. + """ + + def _excludes_only_trusted(self, exclude_locations, trusted_location_ids) -> bool: + """Return True if the excluded locations are all trusted.""" + if not exclude_locations: + return False + if "AllTrusted" in exclude_locations: + return True + return all( + location_id in trusted_location_ids for location_id in exclude_locations + ) + + def execute(self) -> list[CheckReportM365]: + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = ( + "No Conditional Access Policy blocks access from untrusted locations." + ) + + trusted_location_ids = { + location.id + for location in entra_client.named_locations + if location.is_trusted + } + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + locations = policy.conditions.locations + if not locations: + continue + + if "All" not in locations.include_locations: + continue + + # A trusted-location exclusion must exist so trusted networks keep access. + if not self._excludes_only_trusted( + locations.exclude_locations, trusted_location_ids + ): + continue + + if ( + ConditionalAccessGrantControl.BLOCK + not in policy.grant_controls.built_in_controls + ): + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks untrusted locations but is in report-only mode." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks access from untrusted locations." + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists.metadata.json new file mode 100644 index 0000000000..2772ebd6ea --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_trusted_named_location_exists", + "CheckTitle": "A trusted IP-range named location is defined", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Conditional Access **named locations** let an organization define **trusted** IP ranges that can be referenced by Conditional Access policies. At least one **IP-range** named location should be marked as **Trusted** and have at least one IP range defined.", + "Risk": "Without a trusted named location, **Conditional Access** policies cannot differentiate corporate networks from untrusted networks, limiting the ability to build location-aware controls such as blocking access from untrusted locations.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-assignment-network" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Named locations**\n3. Create a **new IP ranges location**, add the organization's public IP ranges\n4. Mark the location as **Trusted**\n5. Click **Create**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Define at least one trusted IP-range named location representing the organization's corporate networks so it can be referenced by location-aware Conditional Access policies.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_trusted_named_location_exists" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists.py b/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists.py new file mode 100644 index 0000000000..f16191c742 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists.py @@ -0,0 +1,53 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_conditional_access_trusted_named_location_exists(Check): + """Check if at least one trusted IP-range named location is defined. + + Conditional Access named locations let an organization define trusted IP ranges. + At least one IP-range named location should be marked as trusted and have at + least one IP range defined. + + - PASS: A trusted IP-range named location with at least one IP range exists. + - FAIL: No trusted IP-range named location with an IP range is defined. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + named_locations = entra_client.named_locations + + report = CheckReportM365( + metadata=self.metadata(), + resource=named_locations if named_locations else {}, + resource_name="Named Locations", + resource_id="namedLocations", + ) + report.status = "FAIL" + report.status_extended = ( + "No trusted IP-range named location with at least one IP range is defined." + ) + + for location in named_locations: + if ( + location.is_ip_location + and location.is_trusted + and location.ip_ranges_count >= 1 + ): + report = CheckReportM365( + metadata=self.metadata(), + resource=location, + resource_name=location.display_name or "Named Location", + resource_id=location.id, + ) + report.status = "PASS" + report.status_extended = ( + f"Trusted IP-range named location '{location.display_name or location.id}' " + f"is defined with {location.ip_ranges_count} IP range(s)." + ) + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/__init__.py b/prowler/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/entra_device_registration_global_admins_not_local_admins.metadata.json b/prowler/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/entra_device_registration_global_admins_not_local_admins.metadata.json new file mode 100644 index 0000000000..aa9da8ddb1 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/entra_device_registration_global_admins_not_local_admins.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_device_registration_global_admins_not_local_admins", + "CheckTitle": "Global Administrators are not added as local administrators during Entra join", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant device registration policy should not automatically add the **Global Administrator** role to the local administrators group of a device during the Microsoft Entra join process (**azureADJoin.localAdmins.enableGlobalAdmins** should be false).", + "Risk": "Automatically granting Global Administrators local admin rights on every Entra-joined device broadens the blast radius of a device compromise and violates least privilege, since local admin rights on endpoints are rarely required for directory administration.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/devices/assign-local-admin" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Devices** > **Device settings**\n3. Set **Global administrator role is added as local administrator on the device during Microsoft Entra join** to **No**\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable the automatic addition of Global Administrators to the local administrators group during Microsoft Entra join and grant local admin rights only where required.", + "Url": "https://hub.prowler.com/check/entra_device_registration_global_admins_not_local_admins" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/entra_device_registration_global_admins_not_local_admins.py b/prowler/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/entra_device_registration_global_admins_not_local_admins.py new file mode 100644 index 0000000000..2e638c4c53 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/entra_device_registration_global_admins_not_local_admins.py @@ -0,0 +1,44 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_device_registration_global_admins_not_local_admins(Check): + """Check if Global Administrators are not added as local admins on Entra join. + + The device registration policy should not automatically add the Global + Administrator role to the local administrators group of a device during the + Microsoft Entra join process. + + - PASS: Global Administrators are not added as local administrators on Entra join. + - FAIL: Global Administrators are added as local administrators on Entra join. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + policy = entra_client.device_registration_policy + if not policy: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name="Device Registration Policy", + resource_id="deviceRegistrationPolicy", + ) + report.status = "FAIL" + report.status_extended = ( + "Global Administrators are added as local administrators on devices " + "during Microsoft Entra join." + ) + + if policy.azure_ad_join_global_admins_enabled is False: + report.status = "PASS" + report.status_extended = ( + "Global Administrators are not added as local administrators on " + "devices during Microsoft Entra join." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_device_registration_join_restricted/__init__.py b/prowler/providers/m365/services/entra/entra_device_registration_join_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_device_registration_join_restricted/entra_device_registration_join_restricted.metadata.json b/prowler/providers/m365/services/entra/entra_device_registration_join_restricted/entra_device_registration_join_restricted.metadata.json new file mode 100644 index 0000000000..b70c8d785d --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_device_registration_join_restricted/entra_device_registration_join_restricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_device_registration_join_restricted", + "CheckTitle": "Users allowed to join devices to Microsoft Entra are restricted", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant device registration policy should restrict who can register devices as **Microsoft Entra joined** to **Selected** users or **None**. Allowing all users to join devices increases the number of devices that establish a trust relationship with the tenant.", + "Risk": "When all users can Entra-join devices, an attacker who compromises any account can register a device, potentially satisfying device-based **Conditional Access** controls and expanding their foothold in the tenant.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/devices/device-join-plan" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Devices** > **Device settings**\n3. Set **Users may join devices to Microsoft Entra** to **Selected** (and choose the allowed users/groups) or **None**\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict Entra device join to a selected set of users or disable it entirely unless there is a business need for all users to join devices.", + "Url": "https://hub.prowler.com/check/entra_device_registration_join_restricted" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_device_registration_join_restricted/entra_device_registration_join_restricted.py b/prowler/providers/m365/services/entra/entra_device_registration_join_restricted/entra_device_registration_join_restricted.py new file mode 100644 index 0000000000..cc7af8d3b3 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_device_registration_join_restricted/entra_device_registration_join_restricted.py @@ -0,0 +1,51 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + DeviceRegistrationMembershipType, +) + +RESTRICTED_MEMBERSHIP_TYPES = { + DeviceRegistrationMembershipType.ENUMERATED.value, + DeviceRegistrationMembershipType.NONE.value, +} + + +class entra_device_registration_join_restricted(Check): + """Check if the users allowed to join devices to Entra are restricted. + + The device registration policy should restrict who can register devices as + Microsoft Entra joined to Selected users or None, rather than allowing all + users. + + - PASS: Only selected users or no users may join devices to Entra. + - FAIL: All users may join devices to Entra. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + policy = entra_client.device_registration_policy + if not policy: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name="Device Registration Policy", + resource_id="deviceRegistrationPolicy", + ) + report.status = "FAIL" + report.status_extended = ( + "All users are allowed to join devices to Microsoft Entra." + ) + + if policy.azure_ad_join_allowed_to_join_type in RESTRICTED_MEMBERSHIP_TYPES: + report.status = "PASS" + report.status_extended = ( + "Only selected users or no users are allowed to join devices to " + "Microsoft Entra." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_device_registration_laps_enabled/__init__.py b/prowler/providers/m365/services/entra/entra_device_registration_laps_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_device_registration_laps_enabled/entra_device_registration_laps_enabled.metadata.json b/prowler/providers/m365/services/entra/entra_device_registration_laps_enabled/entra_device_registration_laps_enabled.metadata.json new file mode 100644 index 0000000000..6f76a9af3f --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_device_registration_laps_enabled/entra_device_registration_laps_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_device_registration_laps_enabled", + "CheckTitle": "Microsoft Entra Local Administrator Password Solution (LAPS) is enabled", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant device registration policy should enable **Microsoft Entra Local Administrator Password Solution (LAPS)** (**localAdminPassword.isEnabled**). LAPS securely manages and rotates the built-in local administrator password on Windows devices and stores it for controlled retrieval.", + "Risk": "Without **LAPS**, local administrator passwords are often static and shared across devices, enabling **lateral movement**: an attacker who recovers one device's local admin password can reuse it across the fleet.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/devices/howto-manage-local-admin-passwords" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Devices** > **Device settings**\n3. Set **Enable Microsoft Entra Local Administrator Password Solution (LAPS)** to **Yes**\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable Microsoft Entra LAPS and deploy a matching Intune policy so local administrator passwords are unique per device, rotated automatically, and retrievable only by authorized roles.", + "Url": "https://hub.prowler.com/check/entra_device_registration_laps_enabled" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_device_registration_laps_enabled/entra_device_registration_laps_enabled.py b/prowler/providers/m365/services/entra/entra_device_registration_laps_enabled/entra_device_registration_laps_enabled.py new file mode 100644 index 0000000000..91dd5da8f2 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_device_registration_laps_enabled/entra_device_registration_laps_enabled.py @@ -0,0 +1,42 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_device_registration_laps_enabled(Check): + """Check if Microsoft Entra Local Administrator Password Solution (LAPS) is enabled. + + The device registration policy should enable LAPS so that the built-in local + administrator password on Windows devices is securely managed and rotated. + + - PASS: LAPS is enabled. + - FAIL: LAPS is disabled. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + policy = entra_client.device_registration_policy + if not policy: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name="Device Registration Policy", + resource_id="deviceRegistrationPolicy", + ) + report.status = "FAIL" + report.status_extended = ( + "Microsoft Entra Local Administrator Password Solution (LAPS) is disabled." + ) + + if policy.local_admin_password_enabled: + report.status = "PASS" + report.status_extended = ( + "Microsoft Entra Local Administrator Password Solution (LAPS) is " + "enabled." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/__init__.py b/prowler/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/entra_device_registration_max_devices_per_user_limited.metadata.json b/prowler/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/entra_device_registration_max_devices_per_user_limited.metadata.json new file mode 100644 index 0000000000..625a1539ba --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/entra_device_registration_max_devices_per_user_limited.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_device_registration_max_devices_per_user_limited", + "CheckTitle": "Maximum number of devices per user is limited", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant device registration policy should limit the maximum number of Microsoft Entra joined or registered devices per user to **10 or less** (**userDeviceQuota**). Once the limit is reached, no additional devices can be added until existing ones are removed.", + "Risk": "An unbounded or high per-user device quota lets a compromised account register many devices, increasing the number of trusted endpoints an attacker controls and complicating device lifecycle governance.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/devices/device-join-plan" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Devices** > **Device settings**\n3. Set **Maximum number of devices per user** to **10** or less\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the maximum number of devices per user to 10 or less to bound the number of trusted endpoints each identity can register.", + "Url": "https://hub.prowler.com/check/entra_device_registration_max_devices_per_user_limited" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/entra_device_registration_max_devices_per_user_limited.py b/prowler/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/entra_device_registration_max_devices_per_user_limited.py new file mode 100644 index 0000000000..1f41f355b5 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/entra_device_registration_max_devices_per_user_limited.py @@ -0,0 +1,47 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +# CIS recommends a maximum of 10 devices per user (or less). +MAX_DEVICES_PER_USER = 10 + + +class entra_device_registration_max_devices_per_user_limited(Check): + """Check if the maximum number of devices per user is limited. + + The device registration policy should set the maximum number of Entra joined or + registered devices per user to 10 or less. + + - PASS: The maximum number of devices per user is 10 or less. + - FAIL: The maximum number of devices per user is greater than 10 (or unlimited). + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + policy = entra_client.device_registration_policy + if not policy: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name="Device Registration Policy", + resource_id="deviceRegistrationPolicy", + ) + quota = policy.user_device_quota + report.status = "FAIL" + report.status_extended = ( + f"The maximum number of devices per user is {quota}, which exceeds the " + f"recommended limit of {MAX_DEVICES_PER_USER}." + ) + + if quota is not None and quota <= MAX_DEVICES_PER_USER: + report.status = "PASS" + report.status_extended = ( + f"The maximum number of devices per user is {quota}, within the " + f"recommended limit of {MAX_DEVICES_PER_USER}." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/__init__.py b/prowler/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/entra_device_registration_registering_user_not_local_admin.metadata.json b/prowler/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/entra_device_registration_registering_user_not_local_admin.metadata.json new file mode 100644 index 0000000000..0c0e8d30af --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/entra_device_registration_registering_user_not_local_admin.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_device_registration_registering_user_not_local_admin", + "CheckTitle": "Registering user is not added as local administrator during Entra join", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant device registration policy should restrict which registering users are added to the local administrators group during Microsoft Entra join to **Selected** users or **None** (**azureADJoin.localAdmins.registeringUsers**), rather than granting local admin to every user who registers a device.", + "Risk": "Automatically granting the registering user local administrator rights gives standard users elevated control over their devices, increasing the impact of endpoint compromise and enabling local privilege abuse.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/devices/assign-local-admin" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Devices** > **Device settings**\n3. Set **Registering user is added as local administrator on the device during Microsoft Entra join** to **Selected** or **None**\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict or disable the automatic addition of the registering user to the local administrators group during Microsoft Entra join, granting local admin rights only to selected users where required.", + "Url": "https://hub.prowler.com/check/entra_device_registration_registering_user_not_local_admin" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/entra_device_registration_registering_user_not_local_admin.py b/prowler/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/entra_device_registration_registering_user_not_local_admin.py new file mode 100644 index 0000000000..76190ccdcc --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/entra_device_registration_registering_user_not_local_admin.py @@ -0,0 +1,53 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + DeviceRegistrationMembershipType, +) + +RESTRICTED_MEMBERSHIP_TYPES = { + DeviceRegistrationMembershipType.ENUMERATED.value, + DeviceRegistrationMembershipType.NONE.value, +} + + +class entra_device_registration_registering_user_not_local_admin(Check): + """Check if the registering user is not added as local admin on Entra join. + + The device registration policy should restrict which registering users are added + to the local administrators group during Microsoft Entra join to Selected users + or None, rather than all registering users. + + - PASS: Registering users are restricted (Selected or None) from becoming local + administrators on Entra join. + - FAIL: All registering users are added as local administrators on Entra join. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + policy = entra_client.device_registration_policy + if not policy: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name="Device Registration Policy", + resource_id="deviceRegistrationPolicy", + ) + report.status = "FAIL" + report.status_extended = ( + "All registering users are added as local administrators on devices " + "during Microsoft Entra join." + ) + + if policy.azure_ad_join_registering_users_type in RESTRICTED_MEMBERSHIP_TYPES: + report.status = "PASS" + report.status_extended = ( + "Registering users are restricted from being added as local " + "administrators on devices during Microsoft Entra join." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json index 0cc14e2965..ce488be572 100644 --- a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json +++ b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "When on-premises directory synchronization is enabled, both blockSoftMatchEnabled and blockCloudObjectTakeoverThroughHardMatchEnabled must be true. Without these blocks, an attacker who can write to on-premises AD can craft an object that matches a privileged cloud account and take it over.", - "Risk": "An attacker with write access to on-premises Active Directory can create an object whose UPN, SMTP address, or ImmutableID matches an existing cloud-only account (e.g. Global Administrator). When the sync engine processes this object, it merges the on-premises identity into the cloud account, effectively granting the attacker full control of that privileged account.", + "Risk": "An attacker with write access to **on-premises** Active Directory can create an object whose UPN, SMTP address, or ImmutableID matches an existing cloud-only account (e.g. **Global Administrator**). When the sync engine processes this object, it merges the on-premises identity into the cloud account, effectively granting the attacker full control of that privileged account.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/graph/api/resources/onpremisesdirectorysynchronization?view=graph-rest-1.0", diff --git a/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/__init__.py b/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured.metadata.json b/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured.metadata.json new file mode 100644 index 0000000000..f2fdcdc957 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_idle_session_timeout_configured", + "CheckTitle": "Idle session timeout of 3 hours or less is configured", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "An activity-based timeout policy should sign out inactive users from Microsoft 365 web apps after a period of inactivity. The web session idle timeout (**WebSessionIdleTimeout**) should be set to **3 hours or less** so that unattended sessions are terminated.", + "Risk": "Without an idle session timeout, an unattended and unlocked session on a shared or public device remains authenticated indefinitely, allowing anyone with physical access to act as the signed-in user.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/admin/manage/idle-session-timeout-web-apps" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft 365 admin center at https://admin.microsoft.com/\n2. Go to **Settings** > **Org settings** > **Security & Privacy** > **Idle session timeout**\n3. Turn on the setting and set the period of inactivity to **3 hours** or less\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure an idle session timeout of 3 hours or less so inactive users are automatically signed out of Microsoft 365 web apps.", + "Url": "https://hub.prowler.com/check/entra_idle_session_timeout_configured" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured.py b/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured.py new file mode 100644 index 0000000000..02c82d69cf --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured.py @@ -0,0 +1,57 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +# CIS recommends an idle session timeout of 3 hours or less. +MAX_IDLE_TIMEOUT_SECONDS = 3 * 60 * 60 + + +class entra_idle_session_timeout_configured(Check): + """Check if an idle session timeout of 3 hours or less is configured. + + An activity-based timeout policy should sign out inactive users from Microsoft + 365 web apps after a period of inactivity. The web session idle timeout should be + set to 3 hours or less. + + - PASS: An activity-based timeout policy enforces an idle timeout of 3 hours or + less. + - FAIL: No activity-based timeout policy enforces an idle timeout of 3 hours or + less. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + policies = entra_client.activity_based_timeout_policies + + report = CheckReportM365( + metadata=self.metadata(), + resource=policies if policies else {}, + resource_name="Activity Based Timeout Policies", + resource_id="activityBasedTimeoutPolicies", + ) + report.status = "FAIL" + report.status_extended = ( + "No activity-based timeout policy enforces an idle session timeout of 3 " + "hours or less." + ) + + for policy in policies: + timeout = policy.web_session_idle_timeout_seconds + if timeout is not None and timeout <= MAX_IDLE_TIMEOUT_SECONDS: + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name + or "Activity Based Timeout Policy", + resource_id=policy.id, + ) + report.status = "PASS" + report.status_extended = ( + f"Activity-based timeout policy '{policy.display_name or policy.id}' " + f"enforces an idle session timeout of {timeout // 60} minutes." + ) + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/__init__.py b/prowler/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/entra_password_protection_custom_banned_list_enforced.metadata.json b/prowler/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/entra_password_protection_custom_banned_list_enforced.metadata.json new file mode 100644 index 0000000000..c33e6106b5 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/entra_password_protection_custom_banned_list_enforced.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_password_protection_custom_banned_list_enforced", + "CheckTitle": "Entra custom banned password list is enforced", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant Password Rule Settings should enforce a **custom banned password list** (**EnableBannedPasswordCheck** true with a non-empty **BannedPasswordList**). This blocks organization-specific weak or predictable passwords (e.g., company name, products, locations) in addition to Microsoft's global banned list.", + "Risk": "Without a custom **banned password** list, users can choose passwords that are predictable for the specific organization (brand names, local terms), which are easy targets for **password spraying** and guessing attacks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-password-ban-bad" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Authentication methods** > **Password protection**\n3. Set **Enforce custom list** to **Yes**\n4. Add organization-specific terms to the **Custom banned password list**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the custom banned password list and populate it with terms relevant to the organization to strengthen protection against weak passwords beyond the global banned list.", + "Url": "https://hub.prowler.com/check/entra_password_protection_custom_banned_list_enforced" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/entra_password_protection_custom_banned_list_enforced.py b/prowler/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/entra_password_protection_custom_banned_list_enforced.py new file mode 100644 index 0000000000..90bbd91a98 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/entra_password_protection_custom_banned_list_enforced.py @@ -0,0 +1,51 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + PASSWORD_RULE_SETTINGS_TEMPLATE_ID, +) + + +class entra_password_protection_custom_banned_list_enforced(Check): + """Check if the Entra custom banned password list is enforced. + + The Password Rule Settings directory setting should enforce a custom banned + password list (EnableBannedPasswordCheck) with a non-empty BannedPasswordList so + that organization-specific weak passwords are rejected in addition to the global + banned list. + + - PASS: The custom banned password list is enforced and non-empty. + - FAIL: The custom banned password list is not enforced or is empty. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + settings = entra_client.directory_settings.get( + PASSWORD_RULE_SETTINGS_TEMPLATE_ID + ) + + report = CheckReportM365( + metadata=self.metadata(), + resource=settings or {}, + resource_name="Password Rule Settings", + resource_id=PASSWORD_RULE_SETTINGS_TEMPLATE_ID, + ) + report.status = "FAIL" + report.status_extended = ( + "The custom banned password list is not enforced in the tenant." + ) + + if settings: + enforced = ( + str(settings.get("EnableBannedPasswordCheck", "")).lower() == "true" + ) + banned_list = settings.get("BannedPasswordList", "") or "" + if enforced and banned_list.strip(): + report.status = "PASS" + report.status_extended = ( + "The custom banned password list is enforced in the tenant." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/__init__.py b/prowler/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/entra_password_protection_lockout_duration_configured.metadata.json b/prowler/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/entra_password_protection_lockout_duration_configured.metadata.json new file mode 100644 index 0000000000..1a712d1ebc --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/entra_password_protection_lockout_duration_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_password_protection_lockout_duration_configured", + "CheckTitle": "Smart lockout duration is set to 60 seconds or more", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant Password Rule Settings should set the smart **LockoutDurationInSeconds** to **60 or more**. The lockout duration determines how long an account remains locked out before the user can attempt to sign in again.", + "Risk": "A short lockout duration allows attackers to resume **brute-force** or **password-spray** attempts sooner, reducing the effectiveness of **smart lockout** as a throttling control.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-password-smart-lockout" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Authentication methods** > **Password protection**\n3. Set **Lockout duration in seconds** to **60** or higher\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the smart lockout duration to 60 seconds or more so locked-out accounts remain locked long enough to throttle automated password attacks.", + "Url": "https://hub.prowler.com/check/entra_password_protection_lockout_duration_configured" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/entra_password_protection_lockout_duration_configured.py b/prowler/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/entra_password_protection_lockout_duration_configured.py new file mode 100644 index 0000000000..b7f4ad11d7 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/entra_password_protection_lockout_duration_configured.py @@ -0,0 +1,54 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + PASSWORD_RULE_SETTINGS_TEMPLATE_ID, +) + +# CIS recommends a lockout duration of 60 seconds or more. +MIN_LOCKOUT_DURATION_SECONDS = 60 + + +class entra_password_protection_lockout_duration_configured(Check): + """Check if the smart lockout duration is set to 60 seconds or more. + + The Password Rule Settings directory setting should set LockoutDurationInSeconds + to 60 or more so a locked-out account remains locked long enough to slow down + automated attacks. + + - PASS: The lockout duration is 60 seconds or more. + - FAIL: The lockout duration is less than 60 seconds or not configured. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + settings = entra_client.directory_settings.get( + PASSWORD_RULE_SETTINGS_TEMPLATE_ID + ) + + report = CheckReportM365( + metadata=self.metadata(), + resource=settings or {}, + resource_name="Password Rule Settings", + resource_id=PASSWORD_RULE_SETTINGS_TEMPLATE_ID, + ) + report.status = "FAIL" + report.status_extended = ( + "The smart lockout duration is not set to 60 seconds or more." + ) + + if settings: + try: + duration = int(settings.get("LockoutDurationInSeconds")) + except (TypeError, ValueError): + duration = None + if duration is not None and duration >= MIN_LOCKOUT_DURATION_SECONDS: + report.status = "PASS" + report.status_extended = ( + f"The smart lockout duration is set to {duration} seconds, at or " + f"above the recommended minimum of {MIN_LOCKOUT_DURATION_SECONDS}." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/__init__.py b/prowler/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/entra_password_protection_lockout_threshold_limited.metadata.json b/prowler/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/entra_password_protection_lockout_threshold_limited.metadata.json new file mode 100644 index 0000000000..dc04e37582 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/entra_password_protection_lockout_threshold_limited.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_password_protection_lockout_threshold_limited", + "CheckTitle": "Smart lockout threshold is set to 10 or less", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant Password Rule Settings should set the smart **LockoutThreshold** to **10 or less**. The lockout threshold determines how many failed sign-in attempts are permitted before an account is placed in a locked-out state.", + "Risk": "A high lockout threshold gives attackers more attempts per account during **password spraying** and **brute-force** attacks before lockout is triggered, increasing the chance of a successful credential compromise.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-password-smart-lockout" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Authentication methods** > **Password protection**\n3. Set **Lockout threshold** to **10** or less\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the smart lockout threshold to 10 or less so accounts lock after a small number of failed sign-in attempts, limiting brute-force and password-spray attacks.", + "Url": "https://hub.prowler.com/check/entra_password_protection_lockout_threshold_limited" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/entra_password_protection_lockout_threshold_limited.py b/prowler/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/entra_password_protection_lockout_threshold_limited.py new file mode 100644 index 0000000000..d5a2694749 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/entra_password_protection_lockout_threshold_limited.py @@ -0,0 +1,51 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + PASSWORD_RULE_SETTINGS_TEMPLATE_ID, +) + +# CIS recommends a smart lockout threshold of 10 or less. +MAX_LOCKOUT_THRESHOLD = 10 + + +class entra_password_protection_lockout_threshold_limited(Check): + """Check if the smart lockout threshold is set to 10 or less. + + The Password Rule Settings directory setting should set LockoutThreshold to 10 or + less so that accounts are locked after a small number of failed sign-in attempts. + + - PASS: The lockout threshold is 10 or less. + - FAIL: The lockout threshold is greater than 10 or not configured. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + settings = entra_client.directory_settings.get( + PASSWORD_RULE_SETTINGS_TEMPLATE_ID + ) + + report = CheckReportM365( + metadata=self.metadata(), + resource=settings or {}, + resource_name="Password Rule Settings", + resource_id=PASSWORD_RULE_SETTINGS_TEMPLATE_ID, + ) + report.status = "FAIL" + report.status_extended = "The smart lockout threshold is not set to 10 or less." + + if settings: + try: + threshold = int(settings.get("LockoutThreshold")) + except (TypeError, ValueError): + threshold = None + if threshold is not None and threshold <= MAX_LOCKOUT_THRESHOLD: + report.status = "PASS" + report.status_extended = ( + f"The smart lockout threshold is set to {threshold}, within the " + f"recommended limit of {MAX_LOCKOUT_THRESHOLD}." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_password_protection_on_premises_enforced/__init__.py b/prowler/providers/m365/services/entra/entra_password_protection_on_premises_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_password_protection_on_premises_enforced/entra_password_protection_on_premises_enforced.metadata.json b/prowler/providers/m365/services/entra/entra_password_protection_on_premises_enforced/entra_password_protection_on_premises_enforced.metadata.json new file mode 100644 index 0000000000..5140a45b0e --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_password_protection_on_premises_enforced/entra_password_protection_on_premises_enforced.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_password_protection_on_premises_enforced", + "CheckTitle": "Entra password protection is enforced on on-premises Active Directory", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant Password Rule Settings should enable **password protection on Windows Server Active Directory** (**EnableBannedPasswordCheckOnPremises**) with the mode set to **Enforced**. This extends Entra banned-password checks to on-premises password changes in hybrid environments. This control only applies to tenants with on-premises directory synchronization.", + "Risk": "Without **on-premises** enforcement, users in hybrid environments can set weak or banned passwords directly in Active Directory, bypassing Entra password protection and weakening the organization's overall password posture.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-password-ban-bad-on-premises" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Authentication methods** > **Password protection**\n3. Set **Enable password protection on Windows Server Active Directory** to **Yes**\n4. Set **Mode** to **Enforced**\n5. Click **Save** (requires the Entra Password Protection agents deployed on-premises)", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable on-premises Entra password protection in Enforced mode and deploy the password protection proxy and DC agents so banned-password rules apply to on-premises password changes.", + "Url": "https://hub.prowler.com/check/entra_password_protection_on_premises_enforced" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_password_protection_on_premises_enforced/entra_password_protection_on_premises_enforced.py b/prowler/providers/m365/services/entra/entra_password_protection_on_premises_enforced/entra_password_protection_on_premises_enforced.py new file mode 100644 index 0000000000..870ff24cc9 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_password_protection_on_premises_enforced/entra_password_protection_on_premises_enforced.py @@ -0,0 +1,53 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + PASSWORD_RULE_SETTINGS_TEMPLATE_ID, +) + + +class entra_password_protection_on_premises_enforced(Check): + """Check if Entra password protection is enforced on on-premises Active Directory. + + The Password Rule Settings directory setting should enable password protection on + Windows Server Active Directory (EnableBannedPasswordCheckOnPremises) with the + mode set to Enforced, so banned-password rules apply to hybrid on-premises + password changes. + + - PASS: On-premises password protection is enabled and set to Enforced. + - FAIL: On-premises password protection is disabled or set to Audit only. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + settings = entra_client.directory_settings.get( + PASSWORD_RULE_SETTINGS_TEMPLATE_ID + ) + + report = CheckReportM365( + metadata=self.metadata(), + resource=settings or {}, + resource_name="Password Rule Settings", + resource_id=PASSWORD_RULE_SETTINGS_TEMPLATE_ID, + ) + report.status = "FAIL" + report.status_extended = ( + "On-premises password protection is not enforced in the tenant." + ) + + if settings: + enabled = ( + str(settings.get("EnableBannedPasswordCheckOnPremises", "")).lower() + == "true" + ) + mode = str(settings.get("BannedPasswordCheckOnPremisesMode", "")).lower() + if enabled and mode == "enforced": + report.status = "PASS" + report.status_extended = ( + "On-premises password protection is enabled and enforced in the " + "tenant." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/__init__.py b/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.metadata.json b/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.metadata.json new file mode 100644 index 0000000000..a2c8cded69 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_pim_global_administrator_approval_required", + "CheckTitle": "PIM requires approval to activate the Global Administrator role", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Privileged Identity Management (PIM) should require **approval to activate** the **Global Administrator** role, with at least one approver configured. This adds a human authorization step before the most privileged role in the tenant can be used. Requires a Microsoft Entra ID P2 license.", + "Risk": "Without approval to activate **Global Administrator**, an eligible or compromised account can self-activate the highest-privilege role without oversight, enabling immediate tenant-wide compromise.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-change-default-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **ID Governance** > **Privileged Identity Management** > **Microsoft Entra Roles** > **Roles**\n3. Select **Global Administrator** > **Role settings**\n4. Set **Require approval to activate** to **Yes** and add at least one approver\n5. Save the settings", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require approval to activate the Global Administrator role in PIM and configure at least one approver so activations are authorized before use.", + "Url": "https://hub.prowler.com/check/entra_pim_global_administrator_approval_required" + } + }, + "Categories": [ + "identity-access", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Requires Microsoft Entra ID P2. If PIM role settings are unavailable (no P2), the check returns no findings for this role." +} diff --git a/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.py b/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.py new file mode 100644 index 0000000000..a94864a0fb --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.py @@ -0,0 +1,48 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID, +) + + +class entra_pim_global_administrator_approval_required(Check): + """Check if PIM requires approval to activate the Global Administrator role. + + Privileged Identity Management (PIM) should require approval to activate the + Global Administrator role, with at least one approver configured. + + - PASS: Approval is required to activate Global Administrator and approvers exist. + - FAIL: Approval is not required, no approvers are configured, or PIM settings are + unavailable. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + setting = entra_client.pim_role_approval_settings.get( + GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID + ) + if not setting: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=setting, + resource_name="Global Administrator PIM Role Settings", + resource_id=GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID, + ) + report.status = "FAIL" + report.status_extended = ( + "PIM does not require approval to activate the Global Administrator role." + ) + + if setting.is_approval_required and setting.has_approvers: + report.status = "PASS" + report.status_extended = ( + "PIM requires approval to activate the Global Administrator role and " + "has approvers configured." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/__init__.py b/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.metadata.json b/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.metadata.json new file mode 100644 index 0000000000..422f5468e9 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_pim_privileged_role_administrator_approval_required", + "CheckTitle": "PIM requires approval to activate the Privileged Role Administrator role", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Privileged Identity Management (PIM) should require **approval to activate** the **Privileged Role Administrator** role, with at least one approver configured. This role can manage role assignments, so its activation should require human authorization. Requires a Microsoft Entra ID P2 license.", + "Risk": "Without approval to activate **Privileged Role Administrator**, an eligible or compromised account can self-activate a role that grants control over role assignments, enabling privilege escalation across the tenant.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-change-default-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **ID Governance** > **Privileged Identity Management** > **Microsoft Entra Roles** > **Roles**\n3. Select **Privileged Role Administrator** > **Role settings**\n4. Set **Require approval to activate** to **Yes** and add at least one approver\n5. Save the settings", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require approval to activate the Privileged Role Administrator role in PIM and configure at least one approver so activations are authorized before use.", + "Url": "https://hub.prowler.com/check/entra_pim_privileged_role_administrator_approval_required" + } + }, + "Categories": [ + "identity-access", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Requires Microsoft Entra ID P2. If PIM role settings are unavailable (no P2), the check returns no findings for this role." +} diff --git a/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.py b/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.py new file mode 100644 index 0000000000..8fa0885def --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.py @@ -0,0 +1,50 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID, +) + + +class entra_pim_privileged_role_administrator_approval_required(Check): + """Check if PIM requires approval to activate the Privileged Role Administrator role. + + Privileged Identity Management (PIM) should require approval to activate the + Privileged Role Administrator role, with at least one approver configured. + + - PASS: Approval is required to activate Privileged Role Administrator and + approvers exist. + - FAIL: Approval is not required, no approvers are configured, or PIM settings are + unavailable. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + setting = entra_client.pim_role_approval_settings.get( + PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID + ) + if not setting: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=setting, + resource_name="Privileged Role Administrator PIM Role Settings", + resource_id=PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID, + ) + report.status = "FAIL" + report.status_extended = ( + "PIM does not require approval to activate the Privileged Role " + "Administrator role." + ) + + if setting.is_approval_required and setting.has_approvers: + report.status = "PASS" + report.status_extended = ( + "PIM requires approval to activate the Privileged Role Administrator " + "role and has approvers configured." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/__init__.py b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/entra_policy_default_user_cannot_create_m365_groups.metadata.json b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/entra_policy_default_user_cannot_create_m365_groups.metadata.json new file mode 100644 index 0000000000..877f999744 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/entra_policy_default_user_cannot_create_m365_groups.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_policy_default_user_cannot_create_m365_groups", + "CheckTitle": "Non-admin users cannot create Microsoft 365 groups", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant Group.Unified directory setting should have **EnableGroupCreation** set to false so that non-admin users cannot create Microsoft 365 groups through the portal, API, or PowerShell. Microsoft 365 group creation should be delegated to a controlled set of users.", + "Risk": "When any user can create Microsoft 365 groups, they can provision associated resources (SharePoint sites, Teams, mailboxes) without oversight, leading to group sprawl, ungoverned data locations, and a larger attack surface.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/solutions/manage-creation-of-groups" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Groups** > **General**\n3. Set **Users can create Microsoft 365 groups in Azure portals, API or PowerShell** to **No**\n4. Optionally grant creation rights to a specific security group\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable self-service Microsoft 365 group creation for non-admin users and delegate creation to an approved security group as needed.", + "Url": "https://hub.prowler.com/check/entra_policy_default_user_cannot_create_m365_groups" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/entra_policy_default_user_cannot_create_m365_groups.py b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/entra_policy_default_user_cannot_create_m365_groups.py new file mode 100644 index 0000000000..d7d66f2935 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/entra_policy_default_user_cannot_create_m365_groups.py @@ -0,0 +1,45 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + GROUP_UNIFIED_SETTINGS_TEMPLATE_ID, +) + + +class entra_policy_default_user_cannot_create_m365_groups(Check): + """Check if default users are restricted from creating Microsoft 365 groups. + + The Group.Unified directory setting should have EnableGroupCreation set to false + so that non-admin users cannot create Microsoft 365 groups. If the setting does + not exist, the tenant uses the default, which allows all users to create groups. + + - PASS: Non-admin users cannot create Microsoft 365 groups. + - FAIL: Non-admin users are allowed to create Microsoft 365 groups. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + settings = entra_client.directory_settings.get( + GROUP_UNIFIED_SETTINGS_TEMPLATE_ID + ) + + report = CheckReportM365( + metadata=self.metadata(), + resource=settings or {}, + resource_name="Group.Unified Settings", + resource_id=GROUP_UNIFIED_SETTINGS_TEMPLATE_ID, + ) + report.status = "FAIL" + report.status_extended = ( + "Non-admin users are allowed to create Microsoft 365 groups." + ) + + if settings and str(settings.get("EnableGroupCreation", "")).lower() == "false": + report.status = "PASS" + report.status_extended = ( + "Non-admin users are not allowed to create Microsoft 365 groups." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/__init__.py b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/entra_policy_default_user_cannot_create_security_groups.metadata.json b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/entra_policy_default_user_cannot_create_security_groups.metadata.json new file mode 100644 index 0000000000..8dd3879ee3 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/entra_policy_default_user_cannot_create_security_groups.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_policy_default_user_cannot_create_security_groups", + "CheckTitle": "Non-admin users cannot create security groups", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra tenant's authorization policy should restrict **non-admin users** from creating **security groups**. Security groups can be used to grant access to resources across Microsoft 365, so their creation should be limited to administrators to preserve least privilege and prevent uncontrolled access grants.", + "Risk": "When any user can create security groups, they may grant themselves or others access to resources, circumventing governance controls. Uncontrolled group sprawl also complicates access reviews and increases the attack surface for privilege escalation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Groups** > **All groups** > **General**\n3. Under **Security groups**, set **Users can create security groups in Azure portals, API or PowerShell** to **No**\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict security group creation to administrators by disabling self-service security group creation for non-admin users. Grant group-creation rights only to specific roles or delegated owners as required.", + "Url": "https://hub.prowler.com/check/entra_policy_default_user_cannot_create_security_groups" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/entra_policy_default_user_cannot_create_security_groups.py b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/entra_policy_default_user_cannot_create_security_groups.py new file mode 100644 index 0000000000..502ebbb2e0 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/entra_policy_default_user_cannot_create_security_groups.py @@ -0,0 +1,54 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_policy_default_user_cannot_create_security_groups(Check): + """Check if default users are restricted from creating security groups. + + This check verifies whether the authorization policy prevents non-admin users + from creating security groups in Microsoft Entra ID. + + - PASS: Non-admin users cannot create security groups. + - FAIL: Non-admin users are allowed to create security groups. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the check for security group creation restrictions. + + This method examines the authorization policy settings to determine if + non-admin users are allowed to create security groups. If security group + creation is restricted, the check passes. + + Returns: + List[CheckReportM365]: A list containing the result of the check. + """ + findings = [] + auth_policy = entra_client.authorization_policy + + report = CheckReportM365( + metadata=self.metadata(), + resource=auth_policy if auth_policy else {}, + resource_name=auth_policy.name if auth_policy else "Authorization Policy", + resource_id=auth_policy.id if auth_policy else "authorizationPolicy", + ) + report.status = "FAIL" + report.status_extended = ( + "Non-admin users are allowed to create security groups." + ) + + if getattr( + entra_client.authorization_policy, "default_user_role_permissions", None + ) and not getattr( + entra_client.authorization_policy.default_user_role_permissions, + "allowed_to_create_security_groups", + True, + ): + report.status = "PASS" + report.status_extended = ( + "Non-admin users are not allowed to create security groups." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/__init__.py b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/entra_policy_default_user_cannot_read_bitlocker_keys.metadata.json b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/entra_policy_default_user_cannot_read_bitlocker_keys.metadata.json new file mode 100644 index 0000000000..24d8d6b954 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/entra_policy_default_user_cannot_read_bitlocker_keys.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "m365", + "CheckID": "entra_policy_default_user_cannot_read_bitlocker_keys", + "CheckTitle": "Non-admin users cannot read BitLocker keys for their owned devices", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra tenant's authorization policy should restrict **non-admin users** from reading (self-recovering) **BitLocker recovery keys** for devices they own. Restricting self-service recovery reduces the risk of an attacker who has compromised a user account from also recovering the disk-encryption key of that user's device.", + "Risk": "If a user can retrieve the **BitLocker recovery key** for their own device, an attacker who compromises the account can decrypt the device's disk, exposing data at rest. Recovery-key access should be limited to administrators and controlled recovery workflows.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/devices/device-management-azure-portal" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Devices** > **All devices** > **Device settings**\n3. Set **Users can recover BitLocker key(s) for their owned devices** to **No**\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable self-service BitLocker recovery-key access for non-admin users so that recovery keys can only be retrieved by administrators through a controlled process.", + "Url": "https://hub.prowler.com/check/entra_policy_default_user_cannot_read_bitlocker_keys" + } + }, + "Categories": [ + "identity-access", + "encryption", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/entra_policy_default_user_cannot_read_bitlocker_keys.py b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/entra_policy_default_user_cannot_read_bitlocker_keys.py new file mode 100644 index 0000000000..41c6211161 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/entra_policy_default_user_cannot_read_bitlocker_keys.py @@ -0,0 +1,46 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_policy_default_user_cannot_read_bitlocker_keys(Check): + """Check if default users are restricted from reading BitLocker keys for their owned devices. + + This check verifies whether the authorization policy prevents non-admin users + from self-recovering BitLocker keys for devices they own in Microsoft Entra ID. + + - PASS: Non-admin users cannot read BitLocker keys for their owned devices. + - FAIL: Non-admin users are allowed to read BitLocker keys for their owned devices. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the check for BitLocker key self-recovery restrictions. + + Returns: + List[CheckReportM365]: A list containing the result of the check. + """ + findings = [] + auth_policy = entra_client.authorization_policy + + report = CheckReportM365( + metadata=self.metadata(), + resource=auth_policy if auth_policy else {}, + resource_name=auth_policy.name if auth_policy else "Authorization Policy", + resource_id=auth_policy.id if auth_policy else "authorizationPolicy", + ) + report.status = "FAIL" + report.status_extended = "Non-admin users are allowed to read BitLocker keys for their owned devices." + + if getattr( + entra_client.authorization_policy, "default_user_role_permissions", None + ) and not getattr( + entra_client.authorization_policy.default_user_role_permissions, + "allowed_to_read_bitlocker_keys_for_owned_device", + True, + ): + report.status = "PASS" + report.status_extended = "Non-admin users are not allowed to read BitLocker keys for their owned devices." + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/__init__.py b/prowler/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/entra_policy_guest_invitations_restricted_to_allowed_domains.metadata.json b/prowler/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/entra_policy_guest_invitations_restricted_to_allowed_domains.metadata.json new file mode 100644 index 0000000000..0f3e8731ec --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/entra_policy_guest_invitations_restricted_to_allowed_domains.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "m365", + "CheckID": "entra_policy_guest_invitations_restricted_to_allowed_domains", + "CheckTitle": "Guest invitations are restricted to an allow-list of domains", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The external collaboration settings should **allow invitations only to specified domains** (most restrictive), with an explicit allow-list of target domains. This limits B2B guest invitations to a set of trusted partner organizations.", + "Risk": "Allowing invitations to any domain lets users invite guests from arbitrary or malicious organizations, increasing the risk of data exposure to untrusted external parties and expanding the tenant's collaboration attack surface.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/external-id/allow-deny-list" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Entra ID** > **External Identities** > **External collaboration settings**\n3. Under **Collaboration restrictions**, select **Allow invitations only to the specified domains (most restrictive)**\n4. Add the trusted partner domains under **Target domains**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict guest invitations to an allow-list of trusted partner domains so users cannot invite guests from arbitrary organizations.", + "Url": "https://hub.prowler.com/check/entra_policy_guest_invitations_restricted_to_allowed_domains" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/entra_policy_guest_invitations_restricted_to_allowed_domains.py b/prowler/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/entra_policy_guest_invitations_restricted_to_allowed_domains.py new file mode 100644 index 0000000000..21c9a62460 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/entra_policy_guest_invitations_restricted_to_allowed_domains.py @@ -0,0 +1,48 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_policy_guest_invitations_restricted_to_allowed_domains(Check): + """Check if guest invitations are restricted to an allow-list of domains. + + The B2B collaboration policy should allow invitations only to a specified list of + domains (most restrictive), with at least one allowed domain configured. + + - PASS: Invitations are restricted to an allow-list with at least one domain. + - FAIL: Invitations are not restricted to an allow-list of domains. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + policy = entra_client.b2b_collaboration_policy + if not policy: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name="B2B Collaboration Policy", + resource_id="b2bManagementPolicy", + ) + report.status = "FAIL" + report.status_extended = ( + "Guest invitations are not restricted to an allow-list of domains." + ) + + if policy.invitations_restricted_to_allowed_domains: + report.status = "PASS" + if policy.allowed_domains: + report.status_extended = ( + "Guest invitations are restricted to an allow-list of " + f"{len(policy.allowed_domains)} domain(s)." + ) + else: + report.status_extended = ( + "Guest invitations are restricted to an allow-list with no " + "domains (all external invitations are blocked)." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index a083eb1c3c..53abdeae6a 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -102,6 +102,14 @@ class Entra(M365Service): self._get_service_principals(), self._get_app_registrations(), self._get_exchange_mailbox_permission_service_principals(), + self._get_device_registration_policy(), + self._get_directory_settings(), + self._get_authentication_methods_policy_settings(), + self._get_activity_based_timeout_policies(), + self._get_named_locations(), + self._get_pim_role_approval_settings(), + self._get_access_review_definitions(), + self._get_b2b_collaboration_policy(), ) ) @@ -122,6 +130,22 @@ class Entra(M365Service): self.exchange_mailbox_permission_service_principals: Dict[ str, "ServicePrincipal" ] = attributes[12] + self.device_registration_policy: Optional[DeviceRegistrationPolicy] = ( + attributes[13] + ) + self.directory_settings: Dict[str, Dict[str, str]] = attributes[14] + self.authentication_methods_policy_settings: Optional[ + AuthenticationMethodsPolicySettings + ] = attributes[15] + self.activity_based_timeout_policies: List[ActivityBasedTimeoutPolicy] = ( + attributes[16] + ) + self.named_locations: List[NamedLocation] = attributes[17] + self.pim_role_approval_settings: Dict[str, PimRoleApprovalSetting] = attributes[ + 18 + ] + self.access_review_definitions: List[AccessReviewDefinition] = attributes[19] + self.b2b_collaboration_policy: Optional[B2BCollaborationPolicy] = attributes[20] self.user_accounts_status = {} # Resolve directory-object identifiers referenced by Conditional Access @@ -386,6 +410,24 @@ class Entra(M365Service): authentication_flows=self._parse_authentication_flows( raw_auth_flows_map.get(policy.id) ), + locations=LocationsCondition( + include_locations=list( + getattr( + getattr(policy.conditions, "locations", None), + "include_locations", + [], + ) + or [] + ), + exclude_locations=list( + getattr( + getattr(policy.conditions, "locations", None), + "exclude_locations", + [], + ) + or [] + ), + ), device_conditions=DeviceConditions( device_filter_mode=( DeviceFilterMode( @@ -516,6 +558,19 @@ class Entra(M365Service): else False ), ), + secure_sign_in_session_enabled=bool( + getattr( + getattr( + policy.session_controls, + "secure_sign_in_session", + None, + ), + "is_enabled", + False, + ) + if policy.session_controls + else False + ), ), state=ConditionalAccessPolicyState( getattr(policy, "state", "disabled") @@ -1192,6 +1247,437 @@ OAuthAppInfo ) return authentication_method_configurations + async def _get_device_registration_policy(self): + """Retrieve the tenant device registration policy from Microsoft Entra. + + Fetches the ``policies/deviceRegistrationPolicy`` singleton from the beta + Graph endpoint because several fields required for auditing (the + ``azureADJoin.*`` local-admin and membership settings) are only exposed on + beta. The response is parsed from raw JSON since those fields are not + present on the v1.0 typed SDK model. + + Returns: + Optional[DeviceRegistrationPolicy]: The parsed policy, or None on error. + """ + logger.info("Entra - Getting device registration policy...") + device_registration_policy = None + try: + builder = self.client.policies.device_registration_policy.with_url( + "https://graph.microsoft.com/beta/policies/deviceRegistrationPolicy" + ) + request_info = builder.to_get_request_information() + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if response: + data = json.loads(response) + azure_ad_join = data.get("azureADJoin", {}) or {} + local_admins = azure_ad_join.get("localAdmins", {}) or {} + allowed_to_join = azure_ad_join.get("allowedToJoin", {}) or {} + registering_users = local_admins.get("registeringUsers", {}) or {} + local_admin_password = data.get("localAdminPassword", {}) or {} + device_registration_policy = DeviceRegistrationPolicy( + user_device_quota=data.get("userDeviceQuota"), + azure_ad_join_allowed_to_join_type=allowed_to_join.get( + "@odata.type" + ), + azure_ad_join_global_admins_enabled=local_admins.get( + "enableGlobalAdmins" + ), + azure_ad_join_registering_users_type=registering_users.get( + "@odata.type" + ), + local_admin_password_enabled=local_admin_password.get("isEnabled"), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return device_registration_policy + + async def _get_pim_role_approval_settings(self): + """Retrieve PIM approval-to-activate settings per directory role. + + Fetches ``policies/roleManagementPolicyAssignments`` for directory roles with + their expanded policy rules, and extracts, per role definition, whether + approval is required to activate and whether approvers are configured. + + Returns: + Dict[str, PimRoleApprovalSetting]: Keyed by role definition (template) id. + """ + logger.info("Entra - Getting PIM role approval settings...") + settings: Dict[str, PimRoleApprovalSetting] = {} + try: + url = ( + "https://graph.microsoft.com/v1.0/policies/" + "roleManagementPolicyAssignments?$filter=scopeId%20eq%20'/'%20and%20" + "scopeType%20eq%20'DirectoryRole'&$expand=policy($expand=rules)" + ) + builder = self.client.policies.with_url(url) + request_info = builder.to_get_request_information() + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if response: + data = json.loads(response) + for assignment in data.get("value", []) or []: + role_id = assignment.get("roleDefinitionId") + if not role_id: + continue + rules = (assignment.get("policy", {}) or {}).get("rules", []) or [] + is_approval_required = False + has_approvers = False + for rule in rules: + if rule.get("id") == "Approval_EndUser_Assignment": + setting = rule.get("setting", {}) or {} + is_approval_required = bool( + setting.get("isApprovalRequired", False) + ) + for stage in setting.get("approvalStages", []) or []: + if stage.get("primaryApprovers"): + has_approvers = True + break + settings[role_id] = PimRoleApprovalSetting( + role_definition_id=role_id, + is_approval_required=is_approval_required, + has_approvers=has_approvers, + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return settings + + async def _get_access_review_definitions(self): + """Retrieve access review definitions from Microsoft Entra ID Governance. + + Fetches ``identityGovernance/accessReviews/definitions`` and captures the + status and scope query of each definition for the access-review checks. + + Returns: + List[AccessReviewDefinition]: The parsed access review definitions. + """ + logger.info("Entra - Getting access review definitions...") + definitions = [] + try: + url = ( + "https://graph.microsoft.com/v1.0/identityGovernance/" + "accessReviews/definitions" + ) + builder = self.client.identity_governance.with_url(url) + request_info = builder.to_get_request_information() + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if response: + data = json.loads(response) + for definition in data.get("value", []) or []: + scope = definition.get("scope", {}) or {} + settings = definition.get("settings", {}) or {} + definitions.append( + AccessReviewDefinition( + id=definition.get("id", ""), + display_name=definition.get("displayName"), + status=definition.get("status"), + scope_query=str(scope.get("query", "")), + resource_scope_queries=[ + str(resource_scope.get("query", "")) + for resource_scope in ( + scope.get("resourceScopes", []) or [] + ) + ], + principal_scope_queries=[ + str(principal_scope.get("query", "")) + for principal_scope in ( + scope.get("principalScopes", []) or [] + ) + ], + default_decision=settings.get("defaultDecision"), + auto_apply_enabled=bool( + settings.get("autoApplyDecisionsEnabled", False) + ), + mail_notifications_enabled=bool( + settings.get("mailNotificationsEnabled", False) + ), + reminders_enabled=bool( + settings.get("reminderNotificationsEnabled", False) + ), + duration_in_days=settings.get("instanceDurationInDays"), + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return definitions + + async def _get_b2b_collaboration_policy(self): + """Retrieve the legacy B2B collaboration (invitation domains) policy. + + Fetches the legacy ``B2BManagementPolicy`` to determine whether invitations + are restricted to an allow-list of domains. + + Returns: + Optional[B2BCollaborationPolicy]: The parsed policy, or None on error. + """ + logger.info("Entra - Getting B2B collaboration policy...") + b2b_policy = None + try: + url = "https://graph.microsoft.com/beta/legacy/policies" + builder = self.client.policies.with_url(url) + request_info = builder.to_get_request_information() + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if response: + data = json.loads(response) + # The legacy policy object has no string ``type`` discriminator, so + # match on the ``B2BManagementPolicy`` block inside the definition JSON. + for policy in data.get("value", []) or []: + matched = False + allowed_domains = [] + invitations_restricted = False + for definition in policy.get("definition", []) or []: + try: + parsed = json.loads(definition) + except (TypeError, ValueError): + continue + b2b_block = parsed.get("B2BManagementPolicy") + if not b2b_block: + continue + matched = True + invitation_policy = ( + b2b_block.get( + "InvitationsAllowedAndBlockedDomainsPolicy", {} + ) + or {} + ) + # Allow-list mode is active whenever the AllowedDomains key is + # present, even when empty (empty = block all external invites, + # the most restrictive and CIS-compliant state). + if "AllowedDomains" in invitation_policy: + invitations_restricted = True + allowed_domains = ( + invitation_policy.get("AllowedDomains") or [] + ) + if matched: + b2b_policy = B2BCollaborationPolicy( + invitations_restricted_to_allowed_domains=invitations_restricted, + allowed_domains=allowed_domains, + ) + break + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return b2b_policy + + async def _get_named_locations(self): + """Retrieve Conditional Access named locations from Microsoft Entra. + + Fetches ``identity/conditionalAccess/namedLocations`` as raw JSON to handle + the polymorphic ipNamedLocation / countryNamedLocation types and extract + their trust and IP-range attributes. + + Returns: + List[NamedLocation]: The parsed named locations. + """ + logger.info("Entra - Getting named locations...") + named_locations = [] + try: + request_info = ( + self.client.identity.conditional_access.named_locations.to_get_request_information() + ) + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if response: + data = json.loads(response) + for location in data.get("value", []) or []: + odata_type = location.get("@odata.type", "") + ip_ranges = location.get("ipRanges", []) or [] + named_locations.append( + NamedLocation( + id=location.get("id", ""), + display_name=location.get("displayName"), + is_trusted=bool(location.get("isTrusted", False)), + is_ip_location="ipNamedLocation" in odata_type, + ip_ranges_count=len(ip_ranges), + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return named_locations + + @staticmethod + def _parse_timespan_to_seconds(value) -> "Optional[int]": + """Parse a .NET TimeSpan string (``[d.]hh:mm:ss[.fffffff]``) to seconds. + + Args: + value: The TimeSpan string (e.g. "03:00:00" or "1.00:00:00"). + + Returns: + The total number of seconds, or None if the value cannot be parsed. + """ + if not value or not isinstance(value, str): + return None + try: + days = 0 + remainder = value.strip() + head = remainder.split(":", 1)[0] + # A dot in the hours component denotes the days separator (d.hh). + if "." in head: + day_part, remainder = remainder.split(".", 1) + days = int(day_part) + hours, minutes, seconds = remainder.split(":") + # Seconds may carry fractional digits (ss.fffffff); truncate them. + seconds = seconds.split(".")[0] + return days * 86400 + int(hours) * 3600 + int(minutes) * 60 + int(seconds) + except (ValueError, AttributeError): + return None + + async def _get_activity_based_timeout_policies(self): + """Retrieve activity-based (idle session) timeout policies from Entra. + + Fetches ``policies/activityBasedTimeoutPolicies`` and parses each policy's + ``definition`` JSON to extract the ``WebSessionIdleTimeout`` for the idle + session timeout check. + + Returns: + List[ActivityBasedTimeoutPolicy]: The parsed timeout policies. + """ + logger.info("Entra - Getting activity based timeout policies...") + policies = [] + try: + response = await self.client.policies.activity_based_timeout_policies.get() + for policy in getattr(response, "value", []) or []: + idle_timeout_seconds = None + for definition in getattr(policy, "definition", []) or []: + try: + parsed = json.loads(definition) + except (TypeError, ValueError): + continue + app_policies = ( + parsed.get("ActivityBasedTimeoutPolicy", {}).get( + "ApplicationPolicies", [] + ) + or [] + ) + for app_policy in app_policies: + seconds = self._parse_timespan_to_seconds( + app_policy.get("WebSessionIdleTimeout") + ) + if seconds is not None and ( + idle_timeout_seconds is None + or seconds < idle_timeout_seconds + ): + idle_timeout_seconds = seconds + policies.append( + ActivityBasedTimeoutPolicy( + id=getattr(policy, "id", ""), + display_name=getattr(policy, "display_name", None), + web_session_idle_timeout_seconds=idle_timeout_seconds, + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return policies + + async def _get_authentication_methods_policy_settings(self): + """Retrieve advanced settings from the beta authentication methods policy. + + Fetches ``policies/authenticationMethodsPolicy`` from the beta Graph endpoint + to extract settings not exposed on the v1.0 typed model: the + ``systemCredentialPreferences`` block and the Microsoft Authenticator + ``featureSettings`` (app-information / location-information / companion-app + states). Parsed from raw JSON because these fields are beta-only. + + Returns: + Optional[AuthenticationMethodsPolicySettings]: Parsed settings, or None. + """ + logger.info("Entra - Getting authentication methods policy settings...") + settings = None + try: + builder = self.client.policies.authentication_methods_policy.with_url( + "https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy" + ) + request_info = builder.to_get_request_information() + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if response: + data = json.loads(response) + system_prefs = data.get("systemCredentialPreferences", {}) or {} + include_targets = [ + target.get("id") + for target in (system_prefs.get("includeTargets", []) or []) + if target.get("id") + ] + authenticator = {} + for config in data.get("authenticationMethodConfigurations", []) or []: + if config.get("id") == "MicrosoftAuthenticator": + authenticator = config + break + feature_settings = authenticator.get("featureSettings", {}) or {} + settings = AuthenticationMethodsPolicySettings( + system_preferred_mfa_state=system_prefs.get("state"), + system_preferred_mfa_include_targets=include_targets, + authenticator_state=authenticator.get("state"), + authenticator_display_app_information_state=( + feature_settings.get("displayAppInformationRequiredState", {}) + or {} + ).get("state"), + authenticator_display_location_information_state=( + feature_settings.get( + "displayLocationInformationRequiredState", {} + ) + or {} + ).get("state"), + authenticator_companion_app_state=( + feature_settings.get("companionAppAllowedState", {}) or {} + ).get("state"), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return settings + + async def _get_directory_settings(self): + """Retrieve tenant directory (group) settings from Microsoft Entra. + + Fetches the ``/groupSettings`` collection and returns a mapping of each + setting's ``templateId`` to a dict of its ``name``/``value`` pairs. This + exposes the Group.Unified and Password Rule Settings templates used by the + group-creation and password-protection checks. + + Returns: + Dict[str, Dict[str, str]]: Mapping of template ID to its name/value pairs. + """ + logger.info("Entra - Getting directory (group) settings...") + directory_settings: Dict[str, Dict[str, str]] = {} + try: + response = await self.client.group_settings.get() + for setting in getattr(response, "value", []) or []: + template_id = getattr(setting, "template_id", None) + if not template_id: + continue + values = {} + for value in getattr(setting, "values", []) or []: + name = getattr(value, "name", None) + if name is not None: + values[name] = getattr(value, "value", None) + directory_settings[template_id] = values + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return directory_settings + async def _get_service_principals(self): """Retrieve service principals owned by the audited tenant. @@ -1820,6 +2306,13 @@ class AuthenticationFlows(BaseModel): transfer_methods: List[TransferMethod] = [] +class LocationsCondition(BaseModel): + """Model representing location conditions for Conditional Access policies.""" + + include_locations: List[str] = [] + exclude_locations: List[str] = [] + + class Conditions(BaseModel): """Model representing conditions for Conditional Access policies.""" @@ -1832,6 +2325,7 @@ class Conditions(BaseModel): platform_conditions: Optional[PlatformConditions] = None authentication_flows: Optional[AuthenticationFlows] = None device_conditions: Optional[DeviceConditions] = None + locations: Optional[LocationsCondition] = None class PersistentBrowser(BaseModel): @@ -1868,6 +2362,7 @@ class SessionControls(BaseModel): persistent_browser: PersistentBrowser sign_in_frequency: SignInFrequency application_enforced_restrictions: Optional[ApplicationEnforcedRestrictions] = None + secure_sign_in_session_enabled: bool = False class ConditionalAccessGrantControl(Enum): @@ -1925,6 +2420,93 @@ class AuthorizationPolicy(BaseModel): guest_user_role_id: Optional[UUID] +# Well-known directory setting template IDs (from /groupSettings). +GROUP_UNIFIED_SETTINGS_TEMPLATE_ID = "62375ab9-6b52-47ed-826b-58e47e0e304b" +PASSWORD_RULE_SETTINGS_TEMPLATE_ID = "5cf42378-d67d-4f36-ba46-e8b86229381d" + +# Well-known directory role template IDs. +GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID = "62e90394-69f5-4237-9190-012177145e10" +PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID = "e8611ab8-c189-46e8-94e1-60213ab1f814" + + +class DeviceRegistrationMembershipType(str, Enum): + """OData types for Entra device registration membership settings.""" + + ALL = "#microsoft.graph.allDeviceRegistrationMembership" + ENUMERATED = "#microsoft.graph.enumeratedDeviceRegistrationMembership" + NONE = "#microsoft.graph.noDeviceRegistrationMembership" + + +class DeviceRegistrationPolicy(BaseModel): + """Tenant device registration policy (policies/deviceRegistrationPolicy).""" + + user_device_quota: Optional[int] = None + azure_ad_join_allowed_to_join_type: Optional[str] = None + azure_ad_join_global_admins_enabled: Optional[bool] = None + azure_ad_join_registering_users_type: Optional[str] = None + local_admin_password_enabled: Optional[bool] = None + + +class ActivityBasedTimeoutPolicy(BaseModel): + """Activity-based (idle session) timeout policy.""" + + id: str + display_name: Optional[str] = None + web_session_idle_timeout_seconds: Optional[int] = None + + +class NamedLocation(BaseModel): + """Conditional Access named location.""" + + id: str + display_name: Optional[str] = None + is_trusted: bool = False + is_ip_location: bool = False + ip_ranges_count: int = 0 + + +class PimRoleApprovalSetting(BaseModel): + """PIM approval-to-activate setting for a directory role.""" + + role_definition_id: str + is_approval_required: bool = False + has_approvers: bool = False + + +class AccessReviewDefinition(BaseModel): + """Access review definition (identityGovernance/accessReviews/definitions).""" + + id: str + display_name: Optional[str] = None + status: Optional[str] = None + scope_query: str = "" + resource_scope_queries: List[str] = [] + principal_scope_queries: List[str] = [] + default_decision: Optional[str] = None + auto_apply_enabled: bool = False + mail_notifications_enabled: bool = False + reminders_enabled: bool = False + duration_in_days: Optional[int] = None + + +class B2BCollaborationPolicy(BaseModel): + """Legacy B2B collaboration (invitation domains) policy.""" + + invitations_restricted_to_allowed_domains: bool = False + allowed_domains: List[str] = [] + + +class AuthenticationMethodsPolicySettings(BaseModel): + """Advanced settings from the beta authentication methods policy.""" + + system_preferred_mfa_state: Optional[str] = None + system_preferred_mfa_include_targets: List[str] = [] + authenticator_state: Optional[str] = None + authenticator_display_app_information_state: Optional[str] = None + authenticator_display_location_information_state: Optional[str] = None + authenticator_companion_app_state: Optional[str] = None + + class Organization(BaseModel): id: str name: str diff --git a/prowler/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/__init__.py b/prowler/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/exchange_organization_reject_direct_send_enabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/exchange_organization_reject_direct_send_enabled.metadata.json new file mode 100644 index 0000000000..2aa655daff --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/exchange_organization_reject_direct_send_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "exchange_organization_reject_direct_send_enabled", + "CheckTitle": "Direct Send is rejected for the Exchange Online organization", + "CheckType": [], + "ServiceName": "exchange", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The Exchange Online organization configuration should have **RejectDirectSend** enabled. Direct Send allows on-premises devices, applications, or third-party cloud services to send email to the tenant's hosted mailboxes using one of the tenant's accepted domains without authentication, which can be abused to spoof internal senders.", + "Risk": "When **Direct Send** is allowed, anyone who knows a valid recipient and accepted domain can send unauthenticated email that appears to originate from inside the organization, enabling **phishing** and **business email compromise** against employees.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a-multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365#option-3-configure-a-connector-to-send-mail-using-microsoft-365-or-office-365-smtp-relay" + ], + "Remediation": { + "Code": { + "CLI": "Set-OrganizationConfig -RejectDirectSend $true", + "NativeIaC": "", + "Other": "1. Connect to Exchange Online PowerShell using Connect-ExchangeOnline\n2. Run: Set-OrganizationConfig -RejectDirectSend $true\n3. Ensure legitimate senders use an authenticated connector or SMTP AUTH before enabling", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable RejectDirectSend so that unauthenticated Direct Send email to the tenant's accepted domains is rejected. Migrate any legitimate on-premises or application senders to authenticated connectors first.", + "Url": "https://hub.prowler.com/check/exchange_organization_reject_direct_send_enabled" + } + }, + "Categories": [ + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/exchange_organization_reject_direct_send_enabled.py b/prowler/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/exchange_organization_reject_direct_send_enabled.py new file mode 100644 index 0000000000..cb14ee726f --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/exchange_organization_reject_direct_send_enabled.py @@ -0,0 +1,47 @@ +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_organization_reject_direct_send_enabled(Check): + """Check if Direct Send is rejected in the Exchange Online organization. + + Direct Send lets on-premises devices, applications, or third-party services + send email to the tenant's hosted mailboxes using an accepted domain without + authentication. Rejecting Direct Send reduces the risk of spoofed internal + email. + + - PASS: RejectDirectSend is enabled for the organization. + - FAIL: RejectDirectSend is disabled for the organization. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the check for the Direct Send organization setting. + + Returns: + List[CheckReportM365]: A list of reports containing the result of the check. + """ + findings = [] + organization_config = exchange_client.organization_config + if organization_config: + report = CheckReportM365( + metadata=self.metadata(), + resource=organization_config, + resource_name=organization_config.name, + resource_id=organization_config.guid, + ) + report.status = "FAIL" + report.status_extended = ( + "Direct Send is not rejected for the Exchange Online organization." + ) + + if organization_config.reject_direct_send: + report.status = "PASS" + report.status_extended = ( + "Direct Send is rejected for the Exchange Online organization." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/__init__.py b/prowler/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/exchange_owa_mailbox_policy_personal_accounts_disabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/exchange_owa_mailbox_policy_personal_accounts_disabled.metadata.json new file mode 100644 index 0000000000..83b7d71104 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/exchange_owa_mailbox_policy_personal_accounts_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "exchange_owa_mailbox_policy_personal_accounts_disabled", + "CheckTitle": "Default OWA mailbox policy disables personal account integration", + "CheckType": [], + "ServiceName": "exchange", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The default Outlook on the web (OWA) mailbox policy should have **PersonalAccountsEnabled** and **PersonalAccountCalendarsEnabled** set to false. These settings control whether users can add personal email accounts (e.g., Outlook.com, Gmail, Yahoo) and personal calendars in the new Outlook for Windows.", + "Risk": "Allowing personal account integration lets corporate and personal data mix within the same client, increasing the risk of data leakage and reducing the organization's ability to govern and audit access to corporate mail and calendars.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/exchange/set-owamailboxpolicy" + ], + "Remediation": { + "Code": { + "CLI": "Set-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default -PersonalAccountsEnabled $false -PersonalAccountCalendarsEnabled $false", + "NativeIaC": "", + "Other": "1. Connect to Exchange Online PowerShell using Connect-ExchangeOnline\n2. Run: Set-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default -PersonalAccountsEnabled $false -PersonalAccountCalendarsEnabled $false", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable PersonalAccountsEnabled and PersonalAccountCalendarsEnabled on the default OWA mailbox policy to prevent users from integrating personal email accounts and calendars into the new Outlook for Windows.", + "Url": "https://hub.prowler.com/check/exchange_owa_mailbox_policy_personal_accounts_disabled" + } + }, + "Categories": [ + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/exchange_owa_mailbox_policy_personal_accounts_disabled.py b/prowler/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/exchange_owa_mailbox_policy_personal_accounts_disabled.py new file mode 100644 index 0000000000..533ac3a27d --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/exchange_owa_mailbox_policy_personal_accounts_disabled.py @@ -0,0 +1,50 @@ +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_owa_mailbox_policy_personal_accounts_disabled(Check): + """Check if the default OWA mailbox policy disables personal account integration. + + Outlook on the web mailbox policies expose PersonalAccountsEnabled and + PersonalAccountCalendarsEnabled, which control whether users can add personal + email accounts and personal calendars in the new Outlook for Windows. Only the + default OWA mailbox policy is required for compliance with this control. + + - PASS: The default OWA mailbox policy disables personal accounts and personal + account calendars. + - FAIL: The default OWA mailbox policy allows personal accounts and/or personal + account calendars. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the check for the default OWA mailbox policy personal account settings. + + Returns: + List[CheckReportM365]: A list of reports containing the result of the check. + """ + findings = [] + for mailbox_policy in exchange_client.mailbox_policies: + if not mailbox_policy or not mailbox_policy.is_default: + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=mailbox_policy, + resource_name=f"Exchange Mailbox Policy - {mailbox_policy.id}", + resource_id=mailbox_policy.id, + ) + report.status = "FAIL" + report.status_extended = f"Default OWA mailbox policy '{mailbox_policy.id}' allows personal account integration." + + if ( + not mailbox_policy.personal_accounts_enabled + and not mailbox_policy.personal_account_calendars_enabled + ): + report.status = "PASS" + report.status_extended = f"Default OWA mailbox policy '{mailbox_policy.id}' disables personal account integration." + + 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 c4e4b309a4..88fabb04aa 100644 --- a/prowler/providers/m365/services/exchange/exchange_service.py +++ b/prowler/providers/m365/services/exchange/exchange_service.py @@ -137,6 +137,9 @@ class Exchange(M365Service): delayed_delicensing_enabled=organization_configuration.get( "DelayedDelicensingEnabled", False ), + reject_direct_send=organization_configuration.get( + "RejectDirectSend", False + ), ) except Exception as error: logger.error( @@ -256,6 +259,13 @@ class Exchange(M365Service): additional_storage_enabled=policy.get( "AdditionalStorageProvidersAvailable", True ), + is_default=policy.get("IsDefault", False), + personal_accounts_enabled=policy.get( + "PersonalAccountsEnabled", True + ), + personal_account_calendars_enabled=policy.get( + "PersonalAccountCalendarsEnabled", True + ), ) ) except Exception as error: @@ -489,6 +499,7 @@ class Organization(BaseModel): mailtips_large_audience_threshold: int delayed_delicensing_enabled: bool = False total_paid_licenses: Optional[int] = None + reject_direct_send: bool = False class MailboxAuditConfig(BaseModel): @@ -516,6 +527,9 @@ class TransportConfig(BaseModel): class MailboxPolicy(BaseModel): id: str additional_storage_enabled: bool + is_default: bool = False + personal_accounts_enabled: bool = True + personal_account_calendars_enabled: bool = True class RoleAssignmentPolicy(BaseModel): diff --git a/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.metadata.json b/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.metadata.json index 0d7fac5ba5..c944799f74 100644 --- a/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.metadata.json +++ b/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "NotDefined", "ResourceGroup": "security", "Description": "Intune has a built-in Device Compliance Policy that governs how devices without an explicit compliance policy are treated. When the default behavior marks those devices as Compliant, unmanaged devices can be treated as compliant and gain access to corporate resources. This check verifies the default is set to Not compliant (secureByDefault = true).", - "Risk": "If the built-in policy marks devices without a compliance policy as Compliant, those devices can bypass Conditional Access policies requiring device compliance, granting unauthorized access to corporate resources from unmanaged or non-compliant endpoints.", + "Risk": "If the built-in policy marks devices without a compliance policy as Compliant, those devices can bypass **Conditional Access** policies requiring device compliance, granting unauthorized access to corporate resources from unmanaged or non-compliant endpoints.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/graph/api/resources/intune-deviceconfig-devicemanagementsettings?view=graph-rest-1.0" diff --git a/prowler/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/__init__.py b/prowler/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/intune_device_enrollment_personal_device_restricted.metadata.json b/prowler/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/intune_device_enrollment_personal_device_restricted.metadata.json new file mode 100644 index 0000000000..12be62af10 --- /dev/null +++ b/prowler/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/intune_device_enrollment_personal_device_restricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "intune_device_enrollment_personal_device_restricted", + "CheckTitle": "Default device enrollment restriction blocks personally owned devices", + "CheckType": [], + "ServiceName": "intune", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The default (priority 0) Intune device platform restriction configuration should block **personally owned** devices for all platforms (**personalDeviceEnrollmentBlocked** set to true). This ensures only corporate-owned or otherwise managed devices can enroll into Intune.", + "Risk": "Allowing personally owned devices to enroll gives unmanaged, potentially non-compliant endpoints access to corporate resources, increasing the risk of data leakage and compromise from devices outside the organization's control.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/mem/intune/enrollment/enrollment-restrictions-set" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Intune admin center at https://intune.microsoft.com/\n2. Go to **Devices** > **Device onboarding** > **Enrollment** > **Device platform restriction**\n3. Open the **Default** priority policy > **All Users** > **Properties**\n4. Set every platform to **Block** in the **Personally owned** column\n5. Save the changes", + "Terraform": "" + }, + "Recommendation": { + "Text": "Block personally owned device enrollment for all platforms in the default enrollment platform restriction so only corporate-owned devices can enroll into Intune.", + "Url": "https://hub.prowler.com/check/intune_device_enrollment_personal_device_restricted" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/intune_device_enrollment_personal_device_restricted.py b/prowler/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/intune_device_enrollment_personal_device_restricted.py new file mode 100644 index 0000000000..f6765a90cd --- /dev/null +++ b/prowler/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/intune_device_enrollment_personal_device_restricted.py @@ -0,0 +1,53 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.intune.intune_client import intune_client + + +class intune_device_enrollment_personal_device_restricted(Check): + """Check if the default device enrollment restriction blocks personal devices. + + The default (priority 0) device platform restriction configuration should block + personally owned devices for all platforms + (``personalDeviceEnrollmentBlocked`` set to true). + + - PASS: The default platform restriction blocks personal devices for all + platforms. + - FAIL: The default platform restriction allows personal devices for one or more + platforms. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + configurations = intune_client.device_enrollment_configurations + + default_config = next( + (config for config in configurations if config.priority == 0), + None, + ) + if not default_config: + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=default_config, + resource_name="Default Device Enrollment Platform Restrictions", + resource_id=default_config.id, + ) + + restrictions = default_config.platform_restrictions + if restrictions and all(restrictions.values()): + report.status = "PASS" + report.status_extended = ( + "The default device enrollment restriction blocks personally owned " + "devices for all platforms." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "The default device enrollment restriction allows personally owned " + "devices for one or more platforms." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/intune/intune_service.py b/prowler/providers/m365/services/intune/intune_service.py index 2f6c0febae..1dbfeaadac 100644 --- a/prowler/providers/m365/services/intune/intune_service.py +++ b/prowler/providers/m365/services/intune/intune_service.py @@ -1,5 +1,6 @@ import asyncio -from typing import Optional +import json +from typing import Dict, Optional from kiota_abstractions.base_request_configuration import RequestConfiguration from pydantic.v1 import BaseModel @@ -29,6 +30,9 @@ class Intune(M365Service): self.settings: Optional[IntuneSettings] = None self.compliance_policies: Optional[list[IntuneCompliancePolicy]] = [] self.managed_devices: Optional[list[IntuneManagedDevice]] = [] + self.device_enrollment_configurations: list["DeviceEnrollmentConfiguration"] = ( + [] + ) self.verification_error: Optional[str] = None loop = self._get_event_loop() @@ -44,6 +48,9 @@ class Intune(M365Service): self.settings = settings self.compliance_policies = policies self.managed_devices = managed_devices + self.device_enrollment_configurations = loop.run_until_complete( + self._get_device_enrollment_configurations() + ) self.verification_error = ( " ".join( error @@ -82,6 +89,66 @@ class Intune(M365Service): except Exception as error: logger.debug(f"Intune - Failed to clean up event loop: {error}") + async def _get_device_enrollment_configurations( + self, + ) -> "list[DeviceEnrollmentConfiguration]": + """Retrieve device enrollment configurations (platform restrictions). + + Fetches ``deviceManagement/deviceEnrollmentConfigurations`` as raw JSON and + parses the platform-restriction configurations, capturing each platform's + ``personalDeviceEnrollmentBlocked`` flag. + + Returns: + list[DeviceEnrollmentConfiguration]: The parsed enrollment configurations. + """ + logger.info("M365 - Getting Intune device enrollment configurations...") + configurations: list[DeviceEnrollmentConfiguration] = [] + try: + # The beta endpoint exposes the full set of per-platform restrictions + # (including macOS/Android for Work) that v1.0 may omit. + builder = ( + self.client.device_management.device_enrollment_configurations.with_url( + "https://graph.microsoft.com/beta/deviceManagement/" + "deviceEnrollmentConfigurations" + ) + ) + request_info = builder.to_get_request_information() + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if response: + data = json.loads(response) + for config in data.get("value", []) or []: + odata_type = config.get("@odata.type", "") + if "PlatformRestrictionsConfiguration" not in odata_type: + continue + platform_restrictions = {} + for key, value in config.items(): + if ( + key.endswith("Restriction") + and isinstance(value, dict) + and "personalDeviceEnrollmentBlocked" in value + ): + # A platform is compliant when personal enrollment is + # blocked OR the platform itself is fully blocked (CIS: + # a blocked platform is also a passing state). + platform_restrictions[key] = bool( + value.get("personalDeviceEnrollmentBlocked", False) + ) or bool(value.get("platformBlocked", False)) + configurations.append( + DeviceEnrollmentConfiguration( + id=config.get("id", ""), + priority=config.get("priority", 0), + odata_type=odata_type, + platform_restrictions=platform_restrictions, + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return configurations + async def _load_intune_configuration( self, ) -> tuple[ @@ -320,3 +387,11 @@ class IntuneManagedDevice(BaseModel): device_name: str = "" compliance_state: str = "" management_agent: str = "" + + +class DeviceEnrollmentConfiguration(BaseModel): + id: str + priority: int = 0 + odata_type: Optional[str] = None + # Mapping of platform restriction name -> personalDeviceEnrollmentBlocked flag. + platform_restrictions: Dict[str, bool] = {} diff --git a/prowler/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/__init__.py b/prowler/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/teams_external_access_trial_tenants_blocked.metadata.json b/prowler/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/teams_external_access_trial_tenants_blocked.metadata.json new file mode 100644 index 0000000000..b574c56b77 --- /dev/null +++ b/prowler/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/teams_external_access_trial_tenants_blocked.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "teams_external_access_trial_tenants_blocked", + "CheckTitle": "External access with Teams trial-only tenants is blocked", + "CheckType": [], + "ServiceName": "teams", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "The Teams external access configuration should **block** communication with **trial-only** tenants (tenants that don't have any purchased seats). When blocked, users from these trial-only tenants cannot search for, contact, call, or meet with the organization's users via Teams external access.", + "Risk": "Trial-only tenants can be created quickly and anonymously, making them a common vector for social engineering and **phishing**. Allowing external access with them lets untrusted actors reach employees through Teams chats, calls, and meetings.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoftteams/manage-external-access" + ], + "Remediation": { + "Code": { + "CLI": "Set-CsTenantFederationConfiguration -ExternalAccessWithTrialTenants Blocked", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Teams admin center at https://admin.teams.microsoft.com/\n2. Go to **External collaboration** > **External access** > **Organization settings**\n3. Set **People in my organization can communicate with accounts in trial Teams tenants** to **Off**\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Block external access with Teams trial-only tenants so that users from tenants without purchased seats cannot contact your organization through Teams.", + "Url": "https://hub.prowler.com/check/teams_external_access_trial_tenants_blocked" + } + }, + "Categories": [ + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/teams_external_access_trial_tenants_blocked.py b/prowler/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/teams_external_access_trial_tenants_blocked.py new file mode 100644 index 0000000000..68eed0ad77 --- /dev/null +++ b/prowler/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/teams_external_access_trial_tenants_blocked.py @@ -0,0 +1,46 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.teams.teams_client import teams_client + + +class teams_external_access_trial_tenants_blocked(Check): + """Check if external access with Teams trial-only tenants is blocked. + + This setting controls external access with Teams "trial-only" tenants (tenants + that don't have any purchased seats). When blocked, users from those tenants + cannot search for, chat, call, or meet with the organization's users. + + - PASS: External access with trial-only tenants is blocked. + - FAIL: External access with trial-only tenants is allowed. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the check for external access with trial-only Teams tenants. + + Returns: + List[CheckReportM365]: A list of reports containing the result of the check. + """ + findings = [] + user_settings = teams_client.user_settings + if user_settings: + report = CheckReportM365( + metadata=self.metadata(), + resource=user_settings, + resource_name="Teams User Settings", + resource_id="userSettings", + ) + report.status = "FAIL" + report.status_extended = ( + "External access with Teams trial-only tenants is allowed." + ) + + if user_settings.external_access_with_trial_tenants == "Blocked": + report.status = "PASS" + report.status_extended = ( + "External access with Teams trial-only tenants is blocked." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/teams/teams_meeting_presenters_restricted/teams_meeting_presenters_restricted.metadata.json b/prowler/providers/m365/services/teams/teams_meeting_presenters_restricted/teams_meeting_presenters_restricted.metadata.json index d45e27c4df..2238eb8488 100644 --- a/prowler/providers/m365/services/teams/teams_meeting_presenters_restricted/teams_meeting_presenters_restricted.metadata.json +++ b/prowler/providers/m365/services/teams/teams_meeting_presenters_restricted/teams_meeting_presenters_restricted.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "NotDefined", "ResourceGroup": "collaboration", "Description": "**Teams meeting policy** sets the default `Who can present` to **only organizers and co-organizers** in the org-wide policy.\n\nThis evaluates whether attendees are limited to the attendee role by default rather than joining as presenters.", - "Risk": "Allowing everyone to present enables unsolicited screen sharing and content uploads, causing data exposure (confidentiality), misleading or altered information during sessions (integrity), and meeting takeovers or disruptions (availability). External participants can exploit this to distribute phishing links or malware.", + "Risk": "Allowing everyone to present enables unsolicited screen sharing and content uploads, causing data exposure (confidentiality), misleading or altered information during sessions (integrity), and meeting takeovers or disruptions (availability). External participants can exploit this to distribute **phishing** links or malware.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/microsoftteams/meeting-who-present-request-control" diff --git a/prowler/providers/m365/services/teams/teams_service.py b/prowler/providers/m365/services/teams/teams_service.py index 4045834c43..ec1806d5ae 100644 --- a/prowler/providers/m365/services/teams/teams_service.py +++ b/prowler/providers/m365/services/teams/teams_service.py @@ -119,6 +119,9 @@ class Teams(M365Service): allow_teams_consumer_inbound=settings.get( "AllowTeamsConsumerInbound", True ), + external_access_with_trial_tenants=settings.get( + "ExternalAccessWithTrialTenants", "Allowed" + ), ) except Exception as error: logger.error( @@ -160,3 +163,4 @@ class UserSettings(BaseModel): allow_external_access: bool = True allow_teams_consumer: bool = True allow_teams_consumer_inbound: bool = True + external_access_with_trial_tenants: str = "Allowed" diff --git a/tests/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/__init__.py b/tests/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/admincenter_forms_phishing_protection_enabled_test.py b/tests/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/admincenter_forms_phishing_protection_enabled_test.py new file mode 100644 index 0000000000..7dd4df4784 --- /dev/null +++ b/tests/providers/m365/services/admincenter/admincenter_forms_phishing_protection_enabled/admincenter_forms_phishing_protection_enabled_test.py @@ -0,0 +1,48 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.admincenter.admincenter_forms_phishing_protection_enabled.admincenter_forms_phishing_protection_enabled" + + +def _make_settings(enabled): + from prowler.providers.m365.services.admincenter.admincenter_service import ( + FormsSettings, + ) + + return FormsSettings(in_org_forms_phishing_scan_enabled=enabled) + + +class Test_admincenter_forms_phishing_protection_enabled: + def _run(self, settings): + admincenter_client = mock.MagicMock() + admincenter_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( + f"{CHECK_MODULE_PATH}.admincenter_client", new=admincenter_client + ), + ): + from prowler.providers.m365.services.admincenter.admincenter_forms_phishing_protection_enabled.admincenter_forms_phishing_protection_enabled import ( + admincenter_forms_phishing_protection_enabled, + ) + + admincenter_client.forms_settings = settings + return admincenter_forms_phishing_protection_enabled().execute() + + def test_no_settings(self): + assert self._run(None) == [] + + def test_enabled(self): + result = self._run(_make_settings(True)) + assert result[0].status == "PASS" + + def test_disabled(self): + result = self._run(_make_settings(False)) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/__init__.py b/tests/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/admincenter_office_store_apps_restricted_test.py b/tests/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/admincenter_office_store_apps_restricted_test.py new file mode 100644 index 0000000000..b42c5025c9 --- /dev/null +++ b/tests/providers/m365/services/admincenter/admincenter_office_store_apps_restricted/admincenter_office_store_apps_restricted_test.py @@ -0,0 +1,51 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.admincenter.admincenter_office_store_apps_restricted.admincenter_office_store_apps_restricted" + + +def _make_settings(office_store_enabled, app_and_services_trial_enabled): + from prowler.providers.m365.services.admincenter.admincenter_service import ( + AppsAndServicesSettings, + ) + + return AppsAndServicesSettings( + office_store_enabled=office_store_enabled, + app_and_services_trial_enabled=app_and_services_trial_enabled, + ) + + +class Test_admincenter_office_store_apps_restricted: + def _run(self, settings): + admincenter_client = mock.MagicMock() + admincenter_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( + f"{CHECK_MODULE_PATH}.admincenter_client", new=admincenter_client + ), + ): + from prowler.providers.m365.services.admincenter.admincenter_office_store_apps_restricted.admincenter_office_store_apps_restricted import ( + admincenter_office_store_apps_restricted, + ) + + admincenter_client.apps_and_services_settings = settings + return admincenter_office_store_apps_restricted().execute() + + def test_no_settings(self): + assert self._run(None) == [] + + def test_both_disabled(self): + result = self._run(_make_settings(False, False)) + assert result[0].status == "PASS" + + def test_store_enabled(self): + result = self._run(_make_settings(True, False)) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/__init__.py b/tests/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/admincenter_shared_bookings_disabled_test.py b/tests/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/admincenter_shared_bookings_disabled_test.py new file mode 100644 index 0000000000..bb63307852 --- /dev/null +++ b/tests/providers/m365/services/admincenter/admincenter_shared_bookings_disabled/admincenter_shared_bookings_disabled_test.py @@ -0,0 +1,84 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.admincenter.admincenter_shared_bookings_disabled.admincenter_shared_bookings_disabled" + + +def _make_org(bookings_enabled): + from prowler.providers.m365.services.admincenter.admincenter_service import ( + Organization, + ) + + return Organization( + name="test-org", + guid="org-guid", + customer_lockbox_enabled=False, + bookings_enabled=bookings_enabled, + ) + + +def _make_policy(is_default=True, bookings_mailbox_creation_enabled=True): + from prowler.providers.m365.services.admincenter.admincenter_service import ( + OwaMailboxPolicy, + ) + + return OwaMailboxPolicy( + id="OwaMailboxPolicy-Default", + is_default=is_default, + bookings_mailbox_creation_enabled=bookings_mailbox_creation_enabled, + ) + + +class Test_admincenter_shared_bookings_disabled: + def _run(self, org, policies): + admincenter_client = mock.MagicMock() + admincenter_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( + f"{CHECK_MODULE_PATH}.admincenter_client", new=admincenter_client + ), + ): + from prowler.providers.m365.services.admincenter.admincenter_shared_bookings_disabled.admincenter_shared_bookings_disabled import ( + admincenter_shared_bookings_disabled, + ) + + admincenter_client.organization_config = org + admincenter_client.mailbox_policies = policies + return admincenter_shared_bookings_disabled().execute() + + def test_no_org_config(self): + assert self._run(None, []) == [] + + def test_bookings_enabled_tenant_and_policy(self): + result = self._run(_make_org(True), [_make_policy()]) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_bookings_disabled_at_tenant(self): + result = self._run(_make_org(False), [_make_policy()]) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Shared Bookings is disabled at the tenant level." + ) + + def test_bookings_disabled_in_default_policy(self): + result = self._run( + _make_org(True), + [_make_policy(bookings_mailbox_creation_enabled=False)], + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Shared Bookings is disabled in the default OWA mailbox policy." + ) diff --git a/tests/providers/m365/services/defender/defender_priority_account_protection_enabled/__init__.py b/tests/providers/m365/services/defender/defender_priority_account_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/defender/defender_priority_account_protection_enabled/defender_priority_account_protection_enabled_test.py b/tests/providers/m365/services/defender/defender_priority_account_protection_enabled/defender_priority_account_protection_enabled_test.py new file mode 100644 index 0000000000..7226efba87 --- /dev/null +++ b/tests/providers/m365/services/defender/defender_priority_account_protection_enabled/defender_priority_account_protection_enabled_test.py @@ -0,0 +1,46 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.defender.defender_priority_account_protection_enabled.defender_priority_account_protection_enabled" + + +def _settings(enabled): + from prowler.providers.m365.services.defender.defender_service import ( + EmailTenantSettings, + ) + + return EmailTenantSettings(priority_account_protection_enabled=enabled) + + +class Test_defender_priority_account_protection_enabled: + def _run(self, settings): + defender_client = mock.MagicMock() + 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(f"{CHECK_MODULE_PATH}.defender_client", new=defender_client), + ): + from prowler.providers.m365.services.defender.defender_priority_account_protection_enabled.defender_priority_account_protection_enabled import ( + defender_priority_account_protection_enabled, + ) + + defender_client.email_tenant_settings = settings + return defender_priority_account_protection_enabled().execute() + + def test_no_settings(self): + assert self._run(None) == [] + + def test_enabled(self): + result = self._run(_settings(True)) + assert result[0].status == "PASS" + + def test_disabled(self): + result = self._run(_settings(False)) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/__init__.py b/tests/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/defender_strict_preset_security_policy_enabled_test.py b/tests/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/defender_strict_preset_security_policy_enabled_test.py new file mode 100644 index 0000000000..1998ea2d32 --- /dev/null +++ b/tests/providers/m365/services/defender/defender_strict_preset_security_policy_enabled/defender_strict_preset_security_policy_enabled_test.py @@ -0,0 +1,74 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.defender.defender_strict_preset_security_policy_enabled.defender_strict_preset_security_policy_enabled" + + +def _rule(name, state, recipient_domain_is=None): + from prowler.providers.m365.services.defender.defender_service import ( + PresetSecurityPolicyRule, + ) + + return PresetSecurityPolicyRule( + name=name, + state=state, + recipient_domain_is=( + recipient_domain_is if recipient_domain_is is not None else ["contoso.com"] + ), + ) + + +class Test_defender_strict_preset_security_policy_enabled: + def _run(self, eop, atp): + defender_client = mock.MagicMock() + 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(f"{CHECK_MODULE_PATH}.defender_client", new=defender_client), + ): + from prowler.providers.m365.services.defender.defender_strict_preset_security_policy_enabled.defender_strict_preset_security_policy_enabled import ( + defender_strict_preset_security_policy_enabled, + ) + + defender_client.eop_protection_policy_rules = eop + defender_client.atp_protection_policy_rules = atp + return defender_strict_preset_security_policy_enabled().execute() + + def test_both_enabled(self): + result = self._run( + [_rule("Strict Preset Security Policy", "Enabled")], + [_rule("Strict Preset Security Policy", "Enabled")], + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_only_eop(self): + result = self._run( + [_rule("Strict Preset Security Policy", "Enabled")], + [], + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_disabled(self): + result = self._run( + [_rule("Strict Preset Security Policy", "Disabled")], + [_rule("Strict Preset Security Policy", "Disabled")], + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_enabled_but_no_recipients(self): + result = self._run( + [_rule("Strict Preset Security Policy", "Enabled", recipient_domain_is=[])], + [_rule("Strict Preset Security Policy", "Enabled", recipient_domain_is=[])], + ) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_access_review_guest_users_configured/__init__.py b/tests/providers/m365/services/entra/entra_access_review_guest_users_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured_test.py b/tests/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured_test.py new file mode 100644 index 0000000000..faba5fa63f --- /dev/null +++ b/tests/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured_test.py @@ -0,0 +1,73 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AccessReviewDefinition, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_access_review_guest_users_configured.entra_access_review_guest_users_configured" + + +def _definition( + status="InProgress", + scope_query="/users?$filter=(userType eq 'Guest')", + default_decision="Deny", + auto_apply_enabled=True, + mail_notifications_enabled=True, + reminders_enabled=True, +): + return AccessReviewDefinition( + id="ar1", + display_name="Guest Review", + status=status, + scope_query=scope_query, + default_decision=default_decision, + auto_apply_enabled=auto_apply_enabled, + mail_notifications_enabled=mail_notifications_enabled, + reminders_enabled=reminders_enabled, + ) + + +class Test_entra_access_review_guest_users_configured: + def _run(self, definitions): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_access_review_guest_users_configured.entra_access_review_guest_users_configured import ( + entra_access_review_guest_users_configured, + ) + + entra_client.access_review_definitions = definitions + return entra_access_review_guest_users_configured().execute() + + def test_no_definitions(self): + result = self._run([]) + assert result[0].status == "FAIL" + + def test_active_failclosed_guest_review(self): + result = self._run([_definition()]) + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Access review 'Guest Review' for guest users is active and fail-closed." + ) + + def test_not_active(self): + result = self._run([_definition(status="Completed")]) + assert result[0].status == "FAIL" + + def test_not_fail_closed(self): + # Active guest review but does nothing on non-response -> FAIL. + result = self._run( + [_definition(default_decision="None", auto_apply_enabled=False)] + ) + assert result[0].status == "FAIL" + + def test_not_guest_scope(self): + result = self._run([_definition(scope_query="/roleManagement/directory")]) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_access_review_privileged_roles_configured/__init__.py b/tests/providers/m365/services/entra/entra_access_review_privileged_roles_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured_test.py b/tests/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured_test.py new file mode 100644 index 0000000000..2883ba6ea1 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured_test.py @@ -0,0 +1,77 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AccessReviewDefinition, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_access_review_privileged_roles_configured.entra_access_review_privileged_roles_configured" + + +def _definition( + status="InProgress", + scope_query="", + resource_scope_queries=None, + default_decision="Deny", + auto_apply_enabled=True, + mail_notifications_enabled=True, + reminders_enabled=True, +): + return AccessReviewDefinition( + id="ar1", + display_name="Privileged Roles Review", + status=status, + scope_query=scope_query, + resource_scope_queries=( + resource_scope_queries + if resource_scope_queries is not None + else ["/roleManagement/directory/roleDefinitions/62e90394-..."] + ), + default_decision=default_decision, + auto_apply_enabled=auto_apply_enabled, + mail_notifications_enabled=mail_notifications_enabled, + reminders_enabled=reminders_enabled, + ) + + +class Test_entra_access_review_privileged_roles_configured: + def _run(self, definitions): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_access_review_privileged_roles_configured.entra_access_review_privileged_roles_configured import ( + entra_access_review_privileged_roles_configured, + ) + + entra_client.access_review_definitions = definitions + return entra_access_review_privileged_roles_configured().execute() + + def test_no_definitions(self): + assert self._run([])[0].status == "FAIL" + + def test_active_failclosed_privileged_review(self): + # Role reference lives in resource scopes (not top-level scope.query). + result = self._run([_definition()]) + assert result[0].status == "PASS" + + def test_not_fail_closed(self): + result = self._run( + [_definition(default_decision="None", auto_apply_enabled=False)] + ) + assert result[0].status == "FAIL" + + def test_guest_review_ignored(self): + result = self._run( + [ + _definition( + scope_query="/users?$filter=(userType eq 'Guest')", + resource_scope_queries=[], + ) + ] + ) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/__init__.py b/tests/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/entra_authentication_method_authenticator_companion_app_disabled_test.py b/tests/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/entra_authentication_method_authenticator_companion_app_disabled_test.py new file mode 100644 index 0000000000..ab6bc576f9 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_authentication_method_authenticator_companion_app_disabled/entra_authentication_method_authenticator_companion_app_disabled_test.py @@ -0,0 +1,47 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthenticationMethodsPolicySettings, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_authentication_method_authenticator_companion_app_disabled.entra_authentication_method_authenticator_companion_app_disabled" + + +class Test_entra_authentication_method_authenticator_companion_app_disabled: + def _run(self, settings): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_authenticator_companion_app_disabled.entra_authentication_method_authenticator_companion_app_disabled import ( + entra_authentication_method_authenticator_companion_app_disabled, + ) + + entra_client.authentication_methods_policy_settings = settings + return ( + entra_authentication_method_authenticator_companion_app_disabled().execute() + ) + + def test_no_settings(self): + assert self._run(None) == [] + + def test_disabled(self): + result = self._run( + AuthenticationMethodsPolicySettings( + authenticator_companion_app_state="disabled" + ) + ) + assert result[0].status == "PASS" + + def test_enabled(self): + result = self._run( + AuthenticationMethodsPolicySettings( + authenticator_companion_app_state="enabled" + ) + ) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/__init__.py b/tests/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context_test.py b/tests/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context_test.py new file mode 100644 index 0000000000..778c99a947 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context_test.py @@ -0,0 +1,47 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthenticationMethodsPolicySettings, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_authentication_method_authenticator_show_context.entra_authentication_method_authenticator_show_context" + + +class Test_entra_authentication_method_authenticator_show_context: + def _run(self, settings): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_authenticator_show_context.entra_authentication_method_authenticator_show_context import ( + entra_authentication_method_authenticator_show_context, + ) + + entra_client.authentication_methods_policy_settings = settings + return entra_authentication_method_authenticator_show_context().execute() + + def test_no_settings(self): + assert self._run(None) == [] + + def test_both_enabled(self): + result = self._run( + AuthenticationMethodsPolicySettings( + authenticator_display_app_information_state="enabled", + authenticator_display_location_information_state="enabled", + ) + ) + assert result[0].status == "PASS" + + def test_location_disabled(self): + result = self._run( + AuthenticationMethodsPolicySettings( + authenticator_display_app_information_state="enabled", + authenticator_display_location_information_state="disabled", + ) + ) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/__init__.py b/tests/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled_test.py b/tests/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled_test.py new file mode 100644 index 0000000000..59c977f319 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled_test.py @@ -0,0 +1,103 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthenticationMethodConfiguration, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_authentication_method_email_otp_disabled: + def test_no_configurations(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled import ( + entra_authentication_method_email_otp_disabled, + ) + + entra_client.authentication_method_configurations = {} + entra_client.tenant_domain = DOMAIN + + check = entra_authentication_method_email_otp_disabled() + result = check.execute() + + assert len(result) == 0 + + def test_email_otp_enabled(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled import ( + entra_authentication_method_email_otp_disabled, + ) + + entra_client.authentication_method_configurations = { + "Email": AuthenticationMethodConfiguration( + id="Email", + state="enabled", + ), + } + entra_client.tenant_domain = DOMAIN + + check = entra_authentication_method_email_otp_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Email OTP authentication method is enabled in the tenant." + ) + + def test_email_otp_disabled(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled import ( + entra_authentication_method_email_otp_disabled, + ) + + entra_client.authentication_method_configurations = { + "Email": AuthenticationMethodConfiguration( + id="Email", + state="disabled", + ), + } + entra_client.tenant_domain = DOMAIN + + check = entra_authentication_method_email_otp_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Email OTP authentication method is disabled in the tenant." + ) diff --git a/tests/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/__init__.py b/tests/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/entra_authentication_method_system_preferred_mfa_enabled_test.py b/tests/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/entra_authentication_method_system_preferred_mfa_enabled_test.py new file mode 100644 index 0000000000..d311e4a11c --- /dev/null +++ b/tests/providers/m365/services/entra/entra_authentication_method_system_preferred_mfa_enabled/entra_authentication_method_system_preferred_mfa_enabled_test.py @@ -0,0 +1,56 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthenticationMethodsPolicySettings, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_authentication_method_system_preferred_mfa_enabled.entra_authentication_method_system_preferred_mfa_enabled" + + +class Test_entra_authentication_method_system_preferred_mfa_enabled: + def _run(self, settings): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_system_preferred_mfa_enabled.entra_authentication_method_system_preferred_mfa_enabled import ( + entra_authentication_method_system_preferred_mfa_enabled, + ) + + entra_client.authentication_methods_policy_settings = settings + return entra_authentication_method_system_preferred_mfa_enabled().execute() + + def test_no_settings(self): + assert self._run(None) == [] + + def test_enabled_all_users(self): + result = self._run( + AuthenticationMethodsPolicySettings( + system_preferred_mfa_state="enabled", + system_preferred_mfa_include_targets=["all_users"], + ) + ) + assert result[0].status == "PASS" + + def test_enabled_not_all_users(self): + result = self._run( + AuthenticationMethodsPolicySettings( + system_preferred_mfa_state="enabled", + system_preferred_mfa_include_targets=["some-group-id"], + ) + ) + assert result[0].status == "FAIL" + + def test_disabled(self): + result = self._run( + AuthenticationMethodsPolicySettings( + system_preferred_mfa_state="disabled", + system_preferred_mfa_include_targets=["all_users"], + ) + ) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/__init__.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked_test.py new file mode 100644 index 0000000000..d386505b47 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_authentication_transfer_blocked/entra_conditional_access_policy_authentication_transfer_blocked_test.py @@ -0,0 +1,123 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + AuthenticationFlows, + ConditionalAccessGrantControl, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + TransferMethod, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_authentication_transfer_blocked.entra_conditional_access_policy_authentication_transfer_blocked" + + +def _make_policy( + policy_id="policy-1", + display_name="Block Authentication Transfer", + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + included_applications=None, + transfer_methods=None, + built_in_controls=None, +): + return ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=included_users or ["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + authentication_flows=AuthenticationFlows( + transfer_methods=( + transfer_methods + if transfer_methods is not None + else [TransferMethod.AUTHENTICATION_TRANSFER] + ) + ), + ), + grant_controls=GrantControls( + built_in_controls=( + built_in_controls + if built_in_controls is not None + else [ConditionalAccessGrantControl.BLOCK] + ), + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, frequency=None, type=None, interval=None + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_authentication_transfer_blocked: + def _run(self, policies): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_authentication_transfer_blocked.entra_conditional_access_policy_authentication_transfer_blocked import ( + entra_conditional_access_policy_authentication_transfer_blocked, + ) + + entra_client.conditional_access_policies = policies + entra_client.tenant_domain = DOMAIN + return ( + entra_conditional_access_policy_authentication_transfer_blocked().execute() + ) + + def test_no_policies(self): + result = self._run({}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource == {} + + def test_policy_blocks_authentication_transfer(self): + policy = _make_policy() + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Conditional Access Policy 'Block Authentication Transfer' blocks authentication transfer." + ) + + def test_policy_report_only(self): + policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_does_not_block(self): + policy = _make_policy(built_in_controls=[ConditionalAccessGrantControl.MFA]) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/__init__.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk_test.py new file mode 100644 index 0000000000..076e35d787 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_block_high_medium_sign_in_risk/entra_conditional_access_policy_block_high_medium_sign_in_risk_test.py @@ -0,0 +1,119 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + RiskLevel, + SessionControls, + SignInFrequency, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_high_medium_sign_in_risk.entra_conditional_access_policy_block_high_medium_sign_in_risk" + + +def _make_policy( + policy_id="policy-1", + display_name="Block Risky Sign-ins", + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + included_applications=None, + sign_in_risk_levels=None, + built_in_controls=None, +): + return ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=included_users or ["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + sign_in_risk_levels=( + sign_in_risk_levels + if sign_in_risk_levels is not None + else [RiskLevel.HIGH, RiskLevel.MEDIUM] + ), + ), + grant_controls=GrantControls( + built_in_controls=( + built_in_controls + if built_in_controls is not None + else [ConditionalAccessGrantControl.BLOCK] + ), + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, frequency=None, type=None, interval=None + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_block_high_medium_sign_in_risk: + def _run(self, policies): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_high_medium_sign_in_risk.entra_conditional_access_policy_block_high_medium_sign_in_risk import ( + entra_conditional_access_policy_block_high_medium_sign_in_risk, + ) + + entra_client.conditional_access_policies = policies + entra_client.tenant_domain = DOMAIN + return ( + entra_conditional_access_policy_block_high_medium_sign_in_risk().execute() + ) + + def test_no_policies(self): + result = self._run({}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_blocks_high_medium(self): + policy = _make_policy() + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Conditional Access Policy 'Block Risky Sign-ins' blocks high and medium sign-in risk." + ) + + def test_policy_only_high(self): + policy = _make_policy(sign_in_risk_levels=[RiskLevel.HIGH]) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_report_only(self): + policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/__init__.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users_test.py new file mode 100644 index 0000000000..c241c2c085 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_all_users/entra_conditional_access_policy_sign_in_frequency_all_users_test.py @@ -0,0 +1,136 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + RiskLevel, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + SignInFrequencyType, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_sign_in_frequency_all_users.entra_conditional_access_policy_sign_in_frequency_all_users" + + +def _make_policy( + policy_id="policy-1", + display_name="Sign-in Frequency", + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + included_applications=None, + is_enabled=True, + frequency=1, + freq_type=SignInFrequencyType.DAYS, + interval=SignInFrequencyInterval.TIME_BASED, +): + return ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=included_users or ["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + ), + grant_controls=GrantControls( + built_in_controls=[], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=is_enabled, + frequency=frequency, + type=freq_type, + interval=interval, + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_sign_in_frequency_all_users: + def _run(self, policies): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_sign_in_frequency_all_users.entra_conditional_access_policy_sign_in_frequency_all_users import ( + entra_conditional_access_policy_sign_in_frequency_all_users, + ) + + entra_client.conditional_access_policies = policies + entra_client.tenant_domain = DOMAIN + return ( + entra_conditional_access_policy_sign_in_frequency_all_users().execute() + ) + + def test_no_policies(self): + result = self._run({}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_7_days(self): + policy = _make_policy(frequency=7, freq_type=SignInFrequencyType.DAYS) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_policy_every_time(self): + policy = _make_policy(interval=SignInFrequencyInterval.EVERY_TIME) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_policy_too_long(self): + policy = _make_policy(frequency=30, freq_type=SignInFrequencyType.DAYS) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_disabled_frequency(self): + policy = _make_policy( + is_enabled=False, frequency=None, freq_type=None, interval=None + ) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_policy_report_only(self): + policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING) + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "report-only mode" in result[0].status_extended + + def test_policy_risk_scoped_ignored(self): + # A sign-in-frequency policy scoped only to risky sign-ins must not satisfy + # the "all users" control. + policy = _make_policy(frequency=1, freq_type=SignInFrequencyType.DAYS) + policy.conditions.sign_in_risk_levels = [RiskLevel.HIGH] + result = self._run({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/__init__.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced_test.py new file mode 100644 index 0000000000..28dbff133a --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_token_protection_enforced/entra_conditional_access_policy_token_protection_enforced_test.py @@ -0,0 +1,143 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ClientAppType, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + PlatformConditions, + SessionControls, + SignInFrequency, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_token_protection_enforced.entra_conditional_access_policy_token_protection_enforced" + + +def _make_policy( + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + included_applications=None, + include_platforms=None, + client_app_types=None, + secure_sign_in_session_enabled=True, +): + return ConditionalAccessPolicy( + id="policy-1", + display_name="Token Protection", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=( + included_applications + if included_applications is not None + else ["All"] + ), + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=( + included_users if included_users is not None else ["All"] + ), + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=( + client_app_types + if client_app_types is not None + else [ClientAppType.MOBILE_APPS_AND_DESKTOP_CLIENTS] + ), + platform_conditions=PlatformConditions( + include_platforms=( + include_platforms if include_platforms is not None else ["windows"] + ), + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, frequency=None, type=None, interval=None + ), + secure_sign_in_session_enabled=secure_sign_in_session_enabled, + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_token_protection_enforced: + def _run(self, policies): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_token_protection_enforced.entra_conditional_access_policy_token_protection_enforced import ( + entra_conditional_access_policy_token_protection_enforced, + ) + + entra_client.conditional_access_policies = policies + entra_client.tenant_domain = DOMAIN + return entra_conditional_access_policy_token_protection_enforced().execute() + + def test_no_policies(self): + assert self._run({})[0].status == "FAIL" + + def test_token_protection_enforced(self): + policy = _make_policy() + result = self._run({policy.id: policy}) + assert result[0].status == "PASS" + + def test_token_protection_disabled(self): + policy = _make_policy(secure_sign_in_session_enabled=False) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_missing_windows_platform(self): + policy = _make_policy(include_platforms=["macOS"]) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_browser_only_client(self): + policy = _make_policy(client_app_types=[ClientAppType.BROWSER]) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_partial_app_coverage(self): + policy = _make_policy( + included_applications=["00000002-0000-0ff1-ce00-000000000000"] + ) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_all_required_apps(self): + policy = _make_policy( + included_applications=[ + "00000002-0000-0ff1-ce00-000000000000", + "00000003-0000-0ff1-ce00-000000000000", + "cc15fd57-2c6c-4117-a88c-83b1d56b4bbe", + ] + ) + result = self._run({policy.id: policy}) + assert result[0].status == "PASS" + + def test_report_only(self): + policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/__init__.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked_test.py new file mode 100644 index 0000000000..94980b4038 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_untrusted_locations_blocked/entra_conditional_access_policy_untrusted_locations_blocked_test.py @@ -0,0 +1,113 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + LocationsCondition, + PersistentBrowser, + SessionControls, + SignInFrequency, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_untrusted_locations_blocked.entra_conditional_access_policy_untrusted_locations_blocked" + + +def _make_policy( + state=ConditionalAccessPolicyState.ENABLED, + include_locations=None, + exclude_locations=None, + built_in_controls=None, +): + return ConditionalAccessPolicy( + id="policy-1", + display_name="Block Untrusted Locations", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + locations=LocationsCondition( + include_locations=( + include_locations if include_locations is not None else ["All"] + ), + exclude_locations=( + exclude_locations + if exclude_locations is not None + else ["AllTrusted"] + ), + ), + ), + grant_controls=GrantControls( + built_in_controls=( + built_in_controls + if built_in_controls is not None + else [ConditionalAccessGrantControl.BLOCK] + ), + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, frequency=None, type=None, interval=None + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_untrusted_locations_blocked: + def _run(self, policies): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_untrusted_locations_blocked.entra_conditional_access_policy_untrusted_locations_blocked import ( + entra_conditional_access_policy_untrusted_locations_blocked, + ) + + entra_client.conditional_access_policies = policies + entra_client.named_locations = [] + entra_client.tenant_domain = DOMAIN + return ( + entra_conditional_access_policy_untrusted_locations_blocked().execute() + ) + + def test_no_policies(self): + assert self._run({})[0].status == "FAIL" + + def test_blocks_untrusted(self): + policy = _make_policy() + result = self._run({policy.id: policy}) + assert result[0].status == "PASS" + + def test_no_trusted_exclusion(self): + policy = _make_policy(exclude_locations=[]) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" + + def test_not_block(self): + policy = _make_policy(built_in_controls=[ConditionalAccessGrantControl.MFA]) + result = self._run({policy.id: policy}) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/__init__.py b/tests/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists_test.py b/tests/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists_test.py new file mode 100644 index 0000000000..a6bb347483 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_trusted_named_location_exists/entra_conditional_access_trusted_named_location_exists_test.py @@ -0,0 +1,72 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import NamedLocation +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_trusted_named_location_exists.entra_conditional_access_trusted_named_location_exists" + + +class Test_entra_conditional_access_trusted_named_location_exists: + def _run(self, named_locations): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_trusted_named_location_exists.entra_conditional_access_trusted_named_location_exists import ( + entra_conditional_access_trusted_named_location_exists, + ) + + entra_client.named_locations = named_locations + return entra_conditional_access_trusted_named_location_exists().execute() + + def test_no_locations(self): + result = self._run([]) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_trusted_ip_location(self): + result = self._run( + [ + NamedLocation( + id="loc1", + display_name="Corp", + is_trusted=True, + is_ip_location=True, + ip_ranges_count=2, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_untrusted_ip_location(self): + result = self._run( + [ + NamedLocation( + id="loc1", + is_trusted=False, + is_ip_location=True, + ip_ranges_count=2, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_trusted_but_no_ranges(self): + result = self._run( + [ + NamedLocation( + id="loc1", + is_trusted=True, + is_ip_location=True, + ip_ranges_count=0, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/__init__.py b/tests/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/entra_device_registration_global_admins_not_local_admins_test.py b/tests/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/entra_device_registration_global_admins_not_local_admins_test.py new file mode 100644 index 0000000000..273e9a53f0 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_device_registration_global_admins_not_local_admins/entra_device_registration_global_admins_not_local_admins_test.py @@ -0,0 +1,43 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + DeviceRegistrationPolicy, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_device_registration_global_admins_not_local_admins.entra_device_registration_global_admins_not_local_admins" + + +class Test_entra_device_registration_global_admins_not_local_admins: + def _run(self, policy): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_device_registration_global_admins_not_local_admins.entra_device_registration_global_admins_not_local_admins import ( + entra_device_registration_global_admins_not_local_admins, + ) + + entra_client.device_registration_policy = policy + return entra_device_registration_global_admins_not_local_admins().execute() + + def test_no_policy(self): + assert self._run(None) == [] + + def test_global_admins_enabled(self): + result = self._run( + DeviceRegistrationPolicy(azure_ad_join_global_admins_enabled=True) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_global_admins_disabled(self): + result = self._run( + DeviceRegistrationPolicy(azure_ad_join_global_admins_enabled=False) + ) + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/m365/services/entra/entra_device_registration_join_restricted/__init__.py b/tests/providers/m365/services/entra/entra_device_registration_join_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_device_registration_join_restricted/entra_device_registration_join_restricted_test.py b/tests/providers/m365/services/entra/entra_device_registration_join_restricted/entra_device_registration_join_restricted_test.py new file mode 100644 index 0000000000..45383024dc --- /dev/null +++ b/tests/providers/m365/services/entra/entra_device_registration_join_restricted/entra_device_registration_join_restricted_test.py @@ -0,0 +1,57 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + DeviceRegistrationMembershipType, + DeviceRegistrationPolicy, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_device_registration_join_restricted.entra_device_registration_join_restricted" + + +class Test_entra_device_registration_join_restricted: + def _run(self, policy): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_device_registration_join_restricted.entra_device_registration_join_restricted import ( + entra_device_registration_join_restricted, + ) + + entra_client.device_registration_policy = policy + return entra_device_registration_join_restricted().execute() + + def test_no_policy(self): + assert self._run(None) == [] + + def test_all_users(self): + result = self._run( + DeviceRegistrationPolicy( + azure_ad_join_allowed_to_join_type=DeviceRegistrationMembershipType.ALL.value + ) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_selected_users(self): + result = self._run( + DeviceRegistrationPolicy( + azure_ad_join_allowed_to_join_type=DeviceRegistrationMembershipType.ENUMERATED.value + ) + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_none(self): + result = self._run( + DeviceRegistrationPolicy( + azure_ad_join_allowed_to_join_type=DeviceRegistrationMembershipType.NONE.value + ) + ) + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/m365/services/entra/entra_device_registration_laps_enabled/__init__.py b/tests/providers/m365/services/entra/entra_device_registration_laps_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_device_registration_laps_enabled/entra_device_registration_laps_enabled_test.py b/tests/providers/m365/services/entra/entra_device_registration_laps_enabled/entra_device_registration_laps_enabled_test.py new file mode 100644 index 0000000000..2a256163cc --- /dev/null +++ b/tests/providers/m365/services/entra/entra_device_registration_laps_enabled/entra_device_registration_laps_enabled_test.py @@ -0,0 +1,39 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + DeviceRegistrationPolicy, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_device_registration_laps_enabled.entra_device_registration_laps_enabled" + + +class Test_entra_device_registration_laps_enabled: + def _run(self, policy): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_device_registration_laps_enabled.entra_device_registration_laps_enabled import ( + entra_device_registration_laps_enabled, + ) + + entra_client.device_registration_policy = policy + return entra_device_registration_laps_enabled().execute() + + def test_no_policy(self): + assert self._run(None) == [] + + def test_laps_enabled(self): + result = self._run(DeviceRegistrationPolicy(local_admin_password_enabled=True)) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_laps_disabled(self): + result = self._run(DeviceRegistrationPolicy(local_admin_password_enabled=False)) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/__init__.py b/tests/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/entra_device_registration_max_devices_per_user_limited_test.py b/tests/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/entra_device_registration_max_devices_per_user_limited_test.py new file mode 100644 index 0000000000..0c15a776ec --- /dev/null +++ b/tests/providers/m365/services/entra/entra_device_registration_max_devices_per_user_limited/entra_device_registration_max_devices_per_user_limited_test.py @@ -0,0 +1,44 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + DeviceRegistrationPolicy, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_device_registration_max_devices_per_user_limited.entra_device_registration_max_devices_per_user_limited" + + +class Test_entra_device_registration_max_devices_per_user_limited: + def _run(self, policy): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_device_registration_max_devices_per_user_limited.entra_device_registration_max_devices_per_user_limited import ( + entra_device_registration_max_devices_per_user_limited, + ) + + entra_client.device_registration_policy = policy + return entra_device_registration_max_devices_per_user_limited().execute() + + def test_no_policy(self): + assert self._run(None) == [] + + def test_within_limit(self): + result = self._run(DeviceRegistrationPolicy(user_device_quota=10)) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_exceeds_limit(self): + result = self._run(DeviceRegistrationPolicy(user_device_quota=50)) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_none_quota(self): + result = self._run(DeviceRegistrationPolicy(user_device_quota=None)) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/__init__.py b/tests/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/entra_device_registration_registering_user_not_local_admin_test.py b/tests/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/entra_device_registration_registering_user_not_local_admin_test.py new file mode 100644 index 0000000000..01f18bcb64 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_device_registration_registering_user_not_local_admin/entra_device_registration_registering_user_not_local_admin_test.py @@ -0,0 +1,50 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + DeviceRegistrationMembershipType, + DeviceRegistrationPolicy, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_device_registration_registering_user_not_local_admin.entra_device_registration_registering_user_not_local_admin" + + +class Test_entra_device_registration_registering_user_not_local_admin: + def _run(self, policy): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_device_registration_registering_user_not_local_admin.entra_device_registration_registering_user_not_local_admin import ( + entra_device_registration_registering_user_not_local_admin, + ) + + entra_client.device_registration_policy = policy + return ( + entra_device_registration_registering_user_not_local_admin().execute() + ) + + def test_no_policy(self): + assert self._run(None) == [] + + def test_all_registering_users(self): + result = self._run( + DeviceRegistrationPolicy( + azure_ad_join_registering_users_type=DeviceRegistrationMembershipType.ALL.value + ) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_selected_registering_users(self): + result = self._run( + DeviceRegistrationPolicy( + azure_ad_join_registering_users_type=DeviceRegistrationMembershipType.NONE.value + ) + ) + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/m365/services/entra/entra_idle_session_timeout_configured/__init__.py b/tests/providers/m365/services/entra/entra_idle_session_timeout_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured_test.py b/tests/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured_test.py new file mode 100644 index 0000000000..6f3f27bb26 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_idle_session_timeout_configured/entra_idle_session_timeout_configured_test.py @@ -0,0 +1,56 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + ActivityBasedTimeoutPolicy, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_idle_session_timeout_configured.entra_idle_session_timeout_configured" + + +class Test_entra_idle_session_timeout_configured: + def _run(self, policies): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_idle_session_timeout_configured.entra_idle_session_timeout_configured import ( + entra_idle_session_timeout_configured, + ) + + entra_client.activity_based_timeout_policies = policies + return entra_idle_session_timeout_configured().execute() + + def test_no_policies(self): + result = self._run([]) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_within_limit(self): + result = self._run( + [ + ActivityBasedTimeoutPolicy( + id="p1", + display_name="Timeout", + web_session_idle_timeout_seconds=3 * 60 * 60, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_exceeds_limit(self): + result = self._run( + [ + ActivityBasedTimeoutPolicy( + id="p1", + web_session_idle_timeout_seconds=5 * 60 * 60, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/__init__.py b/tests/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/entra_password_protection_custom_banned_list_enforced_test.py b/tests/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/entra_password_protection_custom_banned_list_enforced_test.py new file mode 100644 index 0000000000..7621e5e080 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_password_protection_custom_banned_list_enforced/entra_password_protection_custom_banned_list_enforced_test.py @@ -0,0 +1,67 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + PASSWORD_RULE_SETTINGS_TEMPLATE_ID, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_password_protection_custom_banned_list_enforced.entra_password_protection_custom_banned_list_enforced" + + +class Test_entra_password_protection_custom_banned_list_enforced: + def _run(self, directory_settings): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_password_protection_custom_banned_list_enforced.entra_password_protection_custom_banned_list_enforced import ( + entra_password_protection_custom_banned_list_enforced, + ) + + entra_client.directory_settings = directory_settings + return entra_password_protection_custom_banned_list_enforced().execute() + + def test_template_absent(self): + result = self._run({}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_enforced_with_list(self): + result = self._run( + { + PASSWORD_RULE_SETTINGS_TEMPLATE_ID: { + "EnableBannedPasswordCheck": "True", + "BannedPasswordList": "contoso\nproduct", + } + } + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_enforced_but_empty(self): + result = self._run( + { + PASSWORD_RULE_SETTINGS_TEMPLATE_ID: { + "EnableBannedPasswordCheck": "True", + "BannedPasswordList": "", + } + } + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_not_enforced(self): + result = self._run( + { + PASSWORD_RULE_SETTINGS_TEMPLATE_ID: { + "EnableBannedPasswordCheck": "False", + "BannedPasswordList": "contoso", + } + } + ) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/__init__.py b/tests/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/entra_password_protection_lockout_duration_configured_test.py b/tests/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/entra_password_protection_lockout_duration_configured_test.py new file mode 100644 index 0000000000..8ffadfb134 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_password_protection_lockout_duration_configured/entra_password_protection_lockout_duration_configured_test.py @@ -0,0 +1,41 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + PASSWORD_RULE_SETTINGS_TEMPLATE_ID, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_password_protection_lockout_duration_configured.entra_password_protection_lockout_duration_configured" + + +class Test_entra_password_protection_lockout_duration_configured: + def _run(self, directory_settings): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_password_protection_lockout_duration_configured.entra_password_protection_lockout_duration_configured import ( + entra_password_protection_lockout_duration_configured, + ) + + entra_client.directory_settings = directory_settings + return entra_password_protection_lockout_duration_configured().execute() + + def test_template_absent(self): + assert self._run({})[0].status == "FAIL" + + def test_at_minimum(self): + result = self._run( + {PASSWORD_RULE_SETTINGS_TEMPLATE_ID: {"LockoutDurationInSeconds": "60"}} + ) + assert result[0].status == "PASS" + + def test_below_minimum(self): + result = self._run( + {PASSWORD_RULE_SETTINGS_TEMPLATE_ID: {"LockoutDurationInSeconds": "30"}} + ) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/__init__.py b/tests/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/entra_password_protection_lockout_threshold_limited_test.py b/tests/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/entra_password_protection_lockout_threshold_limited_test.py new file mode 100644 index 0000000000..9a1fb5e1b2 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_password_protection_lockout_threshold_limited/entra_password_protection_lockout_threshold_limited_test.py @@ -0,0 +1,41 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + PASSWORD_RULE_SETTINGS_TEMPLATE_ID, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_password_protection_lockout_threshold_limited.entra_password_protection_lockout_threshold_limited" + + +class Test_entra_password_protection_lockout_threshold_limited: + def _run(self, directory_settings): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_password_protection_lockout_threshold_limited.entra_password_protection_lockout_threshold_limited import ( + entra_password_protection_lockout_threshold_limited, + ) + + entra_client.directory_settings = directory_settings + return entra_password_protection_lockout_threshold_limited().execute() + + def test_template_absent(self): + assert self._run({})[0].status == "FAIL" + + def test_within_limit(self): + result = self._run( + {PASSWORD_RULE_SETTINGS_TEMPLATE_ID: {"LockoutThreshold": "10"}} + ) + assert result[0].status == "PASS" + + def test_exceeds_limit(self): + result = self._run( + {PASSWORD_RULE_SETTINGS_TEMPLATE_ID: {"LockoutThreshold": "20"}} + ) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_password_protection_on_premises_enforced/__init__.py b/tests/providers/m365/services/entra/entra_password_protection_on_premises_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_password_protection_on_premises_enforced/entra_password_protection_on_premises_enforced_test.py b/tests/providers/m365/services/entra/entra_password_protection_on_premises_enforced/entra_password_protection_on_premises_enforced_test.py new file mode 100644 index 0000000000..e7c61480fe --- /dev/null +++ b/tests/providers/m365/services/entra/entra_password_protection_on_premises_enforced/entra_password_protection_on_premises_enforced_test.py @@ -0,0 +1,51 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + PASSWORD_RULE_SETTINGS_TEMPLATE_ID, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_password_protection_on_premises_enforced.entra_password_protection_on_premises_enforced" + + +class Test_entra_password_protection_on_premises_enforced: + def _run(self, directory_settings): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_password_protection_on_premises_enforced.entra_password_protection_on_premises_enforced import ( + entra_password_protection_on_premises_enforced, + ) + + entra_client.directory_settings = directory_settings + return entra_password_protection_on_premises_enforced().execute() + + def test_template_absent(self): + assert self._run({})[0].status == "FAIL" + + def test_enabled_and_enforced(self): + result = self._run( + { + PASSWORD_RULE_SETTINGS_TEMPLATE_ID: { + "EnableBannedPasswordCheckOnPremises": "True", + "BannedPasswordCheckOnPremisesMode": "Enforced", + } + } + ) + assert result[0].status == "PASS" + + def test_audit_mode(self): + result = self._run( + { + PASSWORD_RULE_SETTINGS_TEMPLATE_ID: { + "EnableBannedPasswordCheckOnPremises": "True", + "BannedPasswordCheckOnPremisesMode": "Audit", + } + } + ) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_pim_global_administrator_approval_required/__init__.py b/tests/providers/m365/services/entra/entra_pim_global_administrator_approval_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required_test.py b/tests/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required_test.py new file mode 100644 index 0000000000..7c6dbbdb2b --- /dev/null +++ b/tests/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required_test.py @@ -0,0 +1,54 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID, + PimRoleApprovalSetting, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_pim_global_administrator_approval_required.entra_pim_global_administrator_approval_required" + + +class Test_entra_pim_global_administrator_approval_required: + def _run(self, settings): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_pim_global_administrator_approval_required.entra_pim_global_administrator_approval_required import ( + entra_pim_global_administrator_approval_required, + ) + + entra_client.pim_role_approval_settings = settings + return entra_pim_global_administrator_approval_required().execute() + + def test_no_setting(self): + assert self._run({}) == [] + + def test_approval_required(self): + result = self._run( + { + GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting( + role_definition_id=GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID, + is_approval_required=True, + has_approvers=True, + ) + } + ) + assert result[0].status == "PASS" + + def test_no_approvers(self): + result = self._run( + { + GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting( + role_definition_id=GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID, + is_approval_required=True, + has_approvers=False, + ) + } + ) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/__init__.py b/tests/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required_test.py b/tests/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required_test.py new file mode 100644 index 0000000000..5bde69df6b --- /dev/null +++ b/tests/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required_test.py @@ -0,0 +1,54 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID, + PimRoleApprovalSetting, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_pim_privileged_role_administrator_approval_required.entra_pim_privileged_role_administrator_approval_required" + + +class Test_entra_pim_privileged_role_administrator_approval_required: + def _run(self, settings): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_pim_privileged_role_administrator_approval_required.entra_pim_privileged_role_administrator_approval_required import ( + entra_pim_privileged_role_administrator_approval_required, + ) + + entra_client.pim_role_approval_settings = settings + return entra_pim_privileged_role_administrator_approval_required().execute() + + def test_no_setting(self): + assert self._run({}) == [] + + def test_approval_required(self): + result = self._run( + { + PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting( + role_definition_id=PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID, + is_approval_required=True, + has_approvers=True, + ) + } + ) + assert result[0].status == "PASS" + + def test_not_required(self): + result = self._run( + { + PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting( + role_definition_id=PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID, + is_approval_required=False, + has_approvers=True, + ) + } + ) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/__init__.py b/tests/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/entra_policy_default_user_cannot_create_m365_groups_test.py b/tests/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/entra_policy_default_user_cannot_create_m365_groups_test.py new file mode 100644 index 0000000000..0b8ab8f0ad --- /dev/null +++ b/tests/providers/m365/services/entra/entra_policy_default_user_cannot_create_m365_groups/entra_policy_default_user_cannot_create_m365_groups_test.py @@ -0,0 +1,45 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + GROUP_UNIFIED_SETTINGS_TEMPLATE_ID, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_policy_default_user_cannot_create_m365_groups.entra_policy_default_user_cannot_create_m365_groups" + + +class Test_entra_policy_default_user_cannot_create_m365_groups: + def _run(self, directory_settings): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_policy_default_user_cannot_create_m365_groups.entra_policy_default_user_cannot_create_m365_groups import ( + entra_policy_default_user_cannot_create_m365_groups, + ) + + entra_client.directory_settings = directory_settings + return entra_policy_default_user_cannot_create_m365_groups().execute() + + def test_template_absent(self): + result = self._run({}) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_group_creation_enabled(self): + result = self._run( + {GROUP_UNIFIED_SETTINGS_TEMPLATE_ID: {"EnableGroupCreation": "true"}} + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_group_creation_disabled(self): + result = self._run( + {GROUP_UNIFIED_SETTINGS_TEMPLATE_ID: {"EnableGroupCreation": "false"}} + ) + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/__init__.py b/tests/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/entra_policy_default_user_cannot_create_security_groups_test.py b/tests/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/entra_policy_default_user_cannot_create_security_groups_test.py new file mode 100644 index 0000000000..6b435c5b5b --- /dev/null +++ b/tests/providers/m365/services/entra/entra_policy_default_user_cannot_create_security_groups/entra_policy_default_user_cannot_create_security_groups_test.py @@ -0,0 +1,108 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthorizationPolicy, + DefaultUserRolePermissions, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + + +class Test_entra_policy_default_user_cannot_create_security_groups: + def test_users_can_create_security_groups(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_default_user_cannot_create_security_groups.entra_policy_default_user_cannot_create_security_groups.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_default_user_cannot_create_security_groups.entra_policy_default_user_cannot_create_security_groups import ( + entra_policy_default_user_cannot_create_security_groups, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions( + allowed_to_create_security_groups=True, + ), + ) + + check = entra_policy_default_user_cannot_create_security_groups() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Non-admin users are allowed to create security groups." + ) + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + + def test_authorization_policy_none(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_default_user_cannot_create_security_groups.entra_policy_default_user_cannot_create_security_groups.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_default_user_cannot_create_security_groups.entra_policy_default_user_cannot_create_security_groups import ( + entra_policy_default_user_cannot_create_security_groups, + ) + + entra_client.authorization_policy = None + + result = entra_policy_default_user_cannot_create_security_groups().execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "authorizationPolicy" + + def test_users_cannot_create_security_groups(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_default_user_cannot_create_security_groups.entra_policy_default_user_cannot_create_security_groups.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_default_user_cannot_create_security_groups.entra_policy_default_user_cannot_create_security_groups import ( + entra_policy_default_user_cannot_create_security_groups, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions( + allowed_to_create_security_groups=False, + ), + ) + + check = entra_policy_default_user_cannot_create_security_groups() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Non-admin users are not allowed to create security groups." + ) diff --git a/tests/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/__init__.py b/tests/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/entra_policy_default_user_cannot_read_bitlocker_keys_test.py b/tests/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/entra_policy_default_user_cannot_read_bitlocker_keys_test.py new file mode 100644 index 0000000000..ad9c147902 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_policy_default_user_cannot_read_bitlocker_keys/entra_policy_default_user_cannot_read_bitlocker_keys_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthorizationPolicy, + DefaultUserRolePermissions, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + + +class Test_entra_policy_default_user_cannot_read_bitlocker_keys: + def test_users_can_read_bitlocker_keys(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys import ( + entra_policy_default_user_cannot_read_bitlocker_keys, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions( + allowed_to_read_bitlocker_keys_for_owned_device=True, + ), + ) + + check = entra_policy_default_user_cannot_read_bitlocker_keys() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Non-admin users are allowed to read BitLocker keys for their owned devices." + ) + + def test_authorization_policy_none(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys import ( + entra_policy_default_user_cannot_read_bitlocker_keys, + ) + + entra_client.authorization_policy = None + + result = entra_policy_default_user_cannot_read_bitlocker_keys().execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_users_cannot_read_bitlocker_keys(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys import ( + entra_policy_default_user_cannot_read_bitlocker_keys, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions( + allowed_to_read_bitlocker_keys_for_owned_device=False, + ), + ) + + check = entra_policy_default_user_cannot_read_bitlocker_keys() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Non-admin users are not allowed to read BitLocker keys for their owned devices." + ) diff --git a/tests/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/__init__.py b/tests/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/entra_policy_guest_invitations_restricted_to_allowed_domains_test.py b/tests/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/entra_policy_guest_invitations_restricted_to_allowed_domains_test.py new file mode 100644 index 0000000000..e442f90ca9 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_policy_guest_invitations_restricted_to_allowed_domains/entra_policy_guest_invitations_restricted_to_allowed_domains_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + B2BCollaborationPolicy, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_policy_guest_invitations_restricted_to_allowed_domains.entra_policy_guest_invitations_restricted_to_allowed_domains" + + +class Test_entra_policy_guest_invitations_restricted_to_allowed_domains: + def _run(self, policy): + entra_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_policy_guest_invitations_restricted_to_allowed_domains.entra_policy_guest_invitations_restricted_to_allowed_domains import ( + entra_policy_guest_invitations_restricted_to_allowed_domains, + ) + + entra_client.b2b_collaboration_policy = policy + return ( + entra_policy_guest_invitations_restricted_to_allowed_domains().execute() + ) + + def test_no_policy(self): + assert self._run(None) == [] + + def test_restricted(self): + result = self._run( + B2BCollaborationPolicy( + invitations_restricted_to_allowed_domains=True, + allowed_domains=["partner.com"], + ) + ) + assert result[0].status == "PASS" + + def test_not_restricted(self): + result = self._run( + B2BCollaborationPolicy( + invitations_restricted_to_allowed_domains=False, + allowed_domains=[], + ) + ) + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/__init__.py b/tests/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/exchange_organization_reject_direct_send_enabled_test.py b/tests/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/exchange_organization_reject_direct_send_enabled_test.py new file mode 100644 index 0000000000..a486a41610 --- /dev/null +++ b/tests/providers/m365/services/exchange/exchange_organization_reject_direct_send_enabled/exchange_organization_reject_direct_send_enabled_test.py @@ -0,0 +1,90 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.exchange.exchange_organization_reject_direct_send_enabled.exchange_organization_reject_direct_send_enabled" + + +def _make_org(reject_direct_send): + from prowler.providers.m365.services.exchange.exchange_service import Organization + + return Organization( + name="test-org", + guid="org-guid", + audit_disabled=False, + oauth_enabled=True, + mailtips_enabled=True, + mailtips_external_recipient_enabled=True, + mailtips_group_metrics_enabled=True, + mailtips_large_audience_threshold=25, + reject_direct_send=reject_direct_send, + ) + + +class Test_exchange_organization_reject_direct_send_enabled: + def test_no_organization(self): + exchange_client = mock.MagicMock() + exchange_client.organization_config = None + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch(f"{CHECK_MODULE_PATH}.exchange_client", new=exchange_client), + ): + from prowler.providers.m365.services.exchange.exchange_organization_reject_direct_send_enabled.exchange_organization_reject_direct_send_enabled import ( + exchange_organization_reject_direct_send_enabled, + ) + + assert exchange_organization_reject_direct_send_enabled().execute() == [] + + def test_reject_direct_send_enabled(self): + exchange_client = mock.MagicMock() + 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(f"{CHECK_MODULE_PATH}.exchange_client", new=exchange_client), + ): + from prowler.providers.m365.services.exchange.exchange_organization_reject_direct_send_enabled.exchange_organization_reject_direct_send_enabled import ( + exchange_organization_reject_direct_send_enabled, + ) + + exchange_client.organization_config = _make_org(True) + result = exchange_organization_reject_direct_send_enabled().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Direct Send is rejected for the Exchange Online organization." + ) + + def test_reject_direct_send_disabled(self): + exchange_client = mock.MagicMock() + 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(f"{CHECK_MODULE_PATH}.exchange_client", new=exchange_client), + ): + from prowler.providers.m365.services.exchange.exchange_organization_reject_direct_send_enabled.exchange_organization_reject_direct_send_enabled import ( + exchange_organization_reject_direct_send_enabled, + ) + + exchange_client.organization_config = _make_org(False) + result = exchange_organization_reject_direct_send_enabled().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/__init__.py b/tests/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/exchange_owa_mailbox_policy_personal_accounts_disabled_test.py b/tests/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/exchange_owa_mailbox_policy_personal_accounts_disabled_test.py new file mode 100644 index 0000000000..3afcb0a63f --- /dev/null +++ b/tests/providers/m365/services/exchange/exchange_owa_mailbox_policy_personal_accounts_disabled/exchange_owa_mailbox_policy_personal_accounts_disabled_test.py @@ -0,0 +1,70 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.exchange.exchange_owa_mailbox_policy_personal_accounts_disabled.exchange_owa_mailbox_policy_personal_accounts_disabled" + + +def _make_policy( + policy_id, + is_default=True, + personal_accounts_enabled=True, + personal_account_calendars_enabled=True, +): + from prowler.providers.m365.services.exchange.exchange_service import MailboxPolicy + + return MailboxPolicy( + id=policy_id, + additional_storage_enabled=False, + is_default=is_default, + personal_accounts_enabled=personal_accounts_enabled, + personal_account_calendars_enabled=personal_account_calendars_enabled, + ) + + +class Test_exchange_owa_mailbox_policy_personal_accounts_disabled: + def _run(self, policies): + exchange_client = mock.MagicMock() + 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(f"{CHECK_MODULE_PATH}.exchange_client", new=exchange_client), + ): + from prowler.providers.m365.services.exchange.exchange_owa_mailbox_policy_personal_accounts_disabled.exchange_owa_mailbox_policy_personal_accounts_disabled import ( + exchange_owa_mailbox_policy_personal_accounts_disabled, + ) + + exchange_client.mailbox_policies = policies + return exchange_owa_mailbox_policy_personal_accounts_disabled().execute() + + def test_no_policies(self): + assert self._run([]) == [] + + def test_only_non_default_policies_ignored(self): + # A non-default policy that is non-compliant must be ignored. + result = self._run([_make_policy("OwaMailboxPolicy-Custom", is_default=False)]) + assert result == [] + + def test_default_policy_disabled(self): + result = self._run( + [ + _make_policy( + "OwaMailboxPolicy-Default", + personal_accounts_enabled=False, + personal_account_calendars_enabled=False, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_default_policy_enabled(self): + result = self._run([_make_policy("OwaMailboxPolicy-Default")]) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/__init__.py b/tests/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/intune_device_enrollment_personal_device_restricted_test.py b/tests/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/intune_device_enrollment_personal_device_restricted_test.py new file mode 100644 index 0000000000..468ffd58e4 --- /dev/null +++ b/tests/providers/m365/services/intune/intune_device_enrollment_personal_device_restricted/intune_device_enrollment_personal_device_restricted_test.py @@ -0,0 +1,62 @@ +from unittest import mock + +from prowler.providers.m365.services.intune.intune_service import ( + DeviceEnrollmentConfiguration, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.intune.intune_device_enrollment_personal_device_restricted.intune_device_enrollment_personal_device_restricted" + + +class Test_intune_device_enrollment_personal_device_restricted: + def _run(self, configurations): + intune_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.intune_client", new=intune_client), + ): + from prowler.providers.m365.services.intune.intune_device_enrollment_personal_device_restricted.intune_device_enrollment_personal_device_restricted import ( + intune_device_enrollment_personal_device_restricted, + ) + + intune_client.device_enrollment_configurations = configurations + return intune_device_enrollment_personal_device_restricted().execute() + + def test_no_default_config(self): + assert self._run([]) == [] + + def test_all_blocked(self): + result = self._run( + [ + DeviceEnrollmentConfiguration( + id="cfg", + priority=0, + platform_restrictions={ + "iosRestriction": True, + "androidRestriction": True, + "windowsRestriction": True, + }, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_one_allowed(self): + result = self._run( + [ + DeviceEnrollmentConfiguration( + id="cfg", + priority=0, + platform_restrictions={ + "iosRestriction": True, + "androidRestriction": False, + }, + ) + ] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/m365/services/intune/intune_service_test.py b/tests/providers/m365/services/intune/intune_service_test.py index 1e273d04e9..5416e59b8d 100644 --- a/tests/providers/m365/services/intune/intune_service_test.py +++ b/tests/providers/m365/services/intune/intune_service_test.py @@ -96,6 +96,72 @@ def _build_intune_service( return Intune(set_mocked_m365_provider()) +class Test_Intune_DeviceEnrollmentConfigurations: + def _run_getter(self, payload): + import json + + service = Intune.__new__(Intune) + client = mock.MagicMock() + builder = ( + client.device_management.device_enrollment_configurations.with_url.return_value + ) + builder.to_get_request_information.return_value = mock.MagicMock() + client.request_adapter.send_primitive_async = AsyncMock( + return_value=json.dumps(payload).encode() + ) + service.client = client + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete( + service._get_device_enrollment_configurations() + ) + finally: + loop.close() + + def test_parses_platform_restrictions_and_platform_blocked(self): + payload = { + "value": [ + { + "@odata.type": "#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration", + "id": "default", + "priority": 0, + "iosRestriction": { + "personalDeviceEnrollmentBlocked": True, + "platformBlocked": False, + }, + "androidRestriction": { + "personalDeviceEnrollmentBlocked": False, + "platformBlocked": True, + }, + "windowsRestriction": { + "personalDeviceEnrollmentBlocked": False, + "platformBlocked": False, + }, + }, + { + "@odata.type": "#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration", + "id": "whfb", + "priority": 0, + }, + ] + } + result = self._run_getter(payload) + # Only the platform-restrictions config is captured (WHfB ignored). + assert len(result) == 1 + config = result[0] + assert config.priority == 0 + # ios: personal blocked -> compliant; android: platformBlocked -> compliant; + # windows: neither -> not compliant. + assert config.platform_restrictions == { + "iosRestriction": True, + "androidRestriction": True, + "windowsRestriction": False, + } + + def test_empty_response(self): + assert self._run_getter({"value": []}) == [] + + class Test_Intune_Service: def test_get_settings_secure_by_default_true(self): intune = _build_intune_service() diff --git a/tests/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/__init__.py b/tests/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/teams_external_access_trial_tenants_blocked_test.py b/tests/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/teams_external_access_trial_tenants_blocked_test.py new file mode 100644 index 0000000000..dbd509d07d --- /dev/null +++ b/tests/providers/m365/services/teams/teams_external_access_trial_tenants_blocked/teams_external_access_trial_tenants_blocked_test.py @@ -0,0 +1,45 @@ +from unittest import mock + +from prowler.providers.m365.services.teams.teams_service import UserSettings +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.teams.teams_external_access_trial_tenants_blocked.teams_external_access_trial_tenants_blocked" + + +class Test_teams_external_access_trial_tenants_blocked: + def _run(self, user_settings): + teams_client = mock.MagicMock() + teams_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_microsoft_teams" + ), + mock.patch(f"{CHECK_MODULE_PATH}.teams_client", new=teams_client), + ): + from prowler.providers.m365.services.teams.teams_external_access_trial_tenants_blocked.teams_external_access_trial_tenants_blocked import ( + teams_external_access_trial_tenants_blocked, + ) + + teams_client.user_settings = user_settings + return teams_external_access_trial_tenants_blocked().execute() + + def test_no_user_settings(self): + assert self._run(None) == [] + + def test_trial_tenants_blocked(self): + result = self._run(UserSettings(external_access_with_trial_tenants="Blocked")) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "External access with Teams trial-only tenants is blocked." + ) + + def test_trial_tenants_allowed(self): + result = self._run(UserSettings(external_access_with_trial_tenants="Allowed")) + assert len(result) == 1 + assert result[0].status == "FAIL"