From ce037318cda1a585ede18a27d408fe9c236bb2e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= <101209179+HugoPBrito@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:37:48 -0700 Subject: [PATCH] feat(m365): add CIS M365 v7.0.0 entra authentication method, PIM and access review checks (#12155) Co-authored-by: Daniel Barranquero Co-authored-by: Hugo P.Brito --- .../providers/microsoft365/authentication.mdx | 4 + .../m365-cis7-entra-authn-pim.added.md | 1 + prowler/compliance/m365/cis_7.0_m365.json | 24 +- .../__init__.py | 0 ...eview_guest_users_configured.metadata.json | 37 ++ ...ra_access_review_guest_users_configured.py | 128 +++++++ .../__init__.py | 0 ..._privileged_roles_configured.metadata.json | 37 ++ ...cess_review_privileged_roles_configured.py | 117 ++++++ .../__init__.py | 0 ...d_authenticator_show_context.metadata.json | 37 ++ ...ation_method_authenticator_show_context.py | 61 +++ .../__init__.py | 0 ...on_method_email_otp_disabled.metadata.json | 37 ++ ...uthentication_method_email_otp_disabled.py | 57 +++ .../__init__.py | 0 ...inistrator_approval_required.metadata.json | 37 ++ ..._global_administrator_approval_required.py | 62 +++ .../__init__.py | 0 ...inistrator_approval_required.metadata.json | 37 ++ ...ed_role_administrator_approval_required.py | 65 ++++ .../m365/services/entra/entra_service.py | 260 +++++++++++++ ...cess_review_guest_users_configured_test.py | 147 +++++++ ...review_privileged_roles_configured_test.py | 97 +++++ ..._method_authenticator_show_context_test.py | 59 +++ ...tication_method_email_otp_disabled_test.py | 139 +++++++ ...al_administrator_approval_required_test.py | 69 ++++ ...le_administrator_approval_required_test.py | 70 ++++ .../entra/microsoft365_entra_service_test.py | 359 +++++++++++++++++- 29 files changed, 1934 insertions(+), 7 deletions(-) create mode 100644 prowler/changelog.d/m365-cis7-entra-authn-pim.added.md create mode 100644 prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/__init__.py create mode 100644 prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.metadata.json create mode 100644 prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.py create mode 100644 prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/__init__.py create mode 100644 prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.metadata.json create mode 100644 prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.py create mode 100644 prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/__init__.py create mode 100644 prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.metadata.json create mode 100644 prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.py create mode 100644 prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/__init__.py create mode 100644 prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.metadata.json create mode 100644 prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.py create mode 100644 prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/__init__.py create mode 100644 prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.metadata.json create mode 100644 prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.py create mode 100644 prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/__init__.py create mode 100644 prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.metadata.json create mode 100644 prowler/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required.py create mode 100644 tests/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured_test.py create mode 100644 tests/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured_test.py create mode 100644 tests/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context_test.py create mode 100644 tests/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled_test.py create mode 100644 tests/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required_test.py create mode 100644 tests/providers/m365/services/entra/entra_pim_privileged_role_administrator_approval_required/entra_pim_privileged_role_administrator_approval_required_test.py diff --git a/docs/user-guide/providers/microsoft365/authentication.mdx b/docs/user-guide/providers/microsoft365/authentication.mdx index 1d87def884..e543427e1b 100644 --- a/docs/user-guide/providers/microsoft365/authentication.mdx +++ b/docs/user-guide/providers/microsoft365/authentication.mdx @@ -39,10 +39,12 @@ When using service principal authentication, add these **Application Permissions **Microsoft Graph API Permissions:** +- `AccessReview.Read.All`: Required for `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks. A Microsoft Entra ID P2 or Microsoft Entra ID Governance license is also required. - `AuditLog.Read.All`: Required for Entra service. - `Directory.Read.All`: Required for all services. - `OnPremDirectorySynchronization.Read.All`: Required for `entra_seamless_sso_disabled` check (hybrid deployments). - `Policy.Read.All`: Required for all services. +- `RoleManagementPolicy.Read.Directory`: Required for `entra_pim_global_administrator_approval_required` and `entra_pim_privileged_role_administrator_approval_required` checks. A Microsoft Entra ID P2 or Microsoft Entra ID Governance license is also required. - `SecurityIdentitiesHealth.Read.All`: Required for `defenderidentity_health_issues_no_open` check. - `SecurityIdentitiesSensors.Read.All`: Required for `defenderidentity_health_issues_no_open` check. - `SharePointTenantSettings.Read.All`: Required for SharePoint service. @@ -110,10 +112,12 @@ Browser and Azure CLI authentication methods limit scanning capabilities to chec 3. Search and select the required permissions: + - `AccessReview.Read.All`: Required for `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks. A Microsoft Entra ID P2 or Microsoft Entra ID Governance license is also required - `AuditLog.Read.All`: Required for Entra service - `Directory.Read.All`: Required for all services - `OnPremDirectorySynchronization.Read.All`: Required for `entra_seamless_sso_disabled` check (hybrid deployments) - `Policy.Read.All`: Required for all services + - `RoleManagementPolicy.Read.Directory`: Required for `entra_pim_global_administrator_approval_required` and `entra_pim_privileged_role_administrator_approval_required` checks. A Microsoft Entra ID P2 or Microsoft Entra ID Governance license is also required - `SecurityIdentitiesHealth.Read.All`: Required for `defenderidentity_health_issues_no_open` check - `SecurityIdentitiesSensors.Read.All`: Required for `defenderidentity_health_issues_no_open` check - `SharePointTenantSettings.Read.All`: Required for SharePoint service diff --git a/prowler/changelog.d/m365-cis7-entra-authn-pim.added.md b/prowler/changelog.d/m365-cis7-entra-authn-pim.added.md new file mode 100644 index 0000000000..ac633fb58d --- /dev/null +++ b/prowler/changelog.d/m365-cis7-entra-authn-pim.added.md @@ -0,0 +1 @@ +`entra_authentication_method_email_otp_disabled`, `entra_authentication_method_authenticator_show_context`, `entra_pim_global_administrator_approval_required`, `entra_pim_privileged_role_administrator_approval_required`, `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks for M365 provider covering CIS Microsoft 365 Foundations Benchmark v7.0.0 authentication method, PIM approval and access review controls diff --git a/prowler/compliance/m365/cis_7.0_m365.json b/prowler/compliance/m365/cis_7.0_m365.json index 595b31e094..7cc1315275 100644 --- a/prowler/compliance/m365/cis_7.0_m365.json +++ b/prowler/compliance/m365/cis_7.0_m365.json @@ -2016,7 +2016,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", @@ -2150,7 +2152,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", @@ -2364,7 +2368,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", @@ -2385,7 +2391,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", @@ -2406,7 +2414,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", @@ -2427,7 +2437,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", 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..6feda3543a --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_access_review_guest_users_configured/entra_access_review_guest_users_configured.py @@ -0,0 +1,128 @@ +import re +from typing import List +from urllib.parse import unquote + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +ACTIVE_STATUSES = {"InProgress"} +ALLOWED_RECURRENCE_PATTERNS = {"weekly", "absoluteMonthly"} +GUEST_USER_PREDICATE = re.compile(r"\busertype\s+eq\s+(['\"])guest\1", re.IGNORECASE) +NEGATED_GUEST_USER_PREDICATE = re.compile( + r"\bnot\s*\(\s*usertype\s+eq\s+(['\"])guest\1\s*\)", re.IGNORECASE +) + + +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, recurring, + and have primary reviewers assigned. It should also be fail-closed: if + reviewers do not respond, access is removed + (``defaultDecisionEnabled`` with ``defaultDecision`` = Deny and + ``autoApplyDecisionsEnabled``), with mail notifications and reminders enabled. + + - PASS: A compliant recurring access review scoped to guest users exists. + - FAIL: No compliant recurring access review scoped to guest users exists. + """ + + def _targets_guest_users(self, definition) -> bool: + """Determine whether an access review targets guest users. + + Portal-created reviews use a principal-resource-memberships scope where the + guest filter (``userType eq 'Guest'``) lives in the principal scopes and the + top-level scope query is empty, so both are inspected. + + This bounded matcher recognizes the equality predicate and its direct + ``not(...)`` negation. It does not interpret other compound or nested OData + boolean semantics. + + Args: + definition: The access review definition to evaluate. + + Returns: + bool: True if any scope contains the guest-user equality predicate. + """ + queries = [definition.scope_query] + definition.principal_scope_queries + for query in queries: + decoded_query = unquote(query) + if NEGATED_GUEST_USER_PREDICATE.search(decoded_query): + continue + if GUEST_USER_PREDICATE.search(decoded_query): + return True + return False + + def _is_recurring_with_reviewers(self, definition) -> bool: + """Determine whether recurrence and primary reviewers are configured.""" + return ( + definition.recurrence_pattern_type in ALLOWED_RECURRENCE_PATTERNS + and definition.recurrence_range_type == "noEnd" + and definition.has_primary_reviewers + ) + + def _is_fail_closed(self, definition) -> bool: + """Determine whether an access review definition is fail-closed. + + Args: + definition: The access review definition to evaluate. + + Returns: + bool: True if the review enables and denies access by default, + auto-applies decisions, and has mail notifications and reminders enabled. + """ + return ( + definition.default_decision == "Deny" + and definition.default_decision_enabled + and definition.auto_apply_enabled + and definition.mail_notifications_enabled + and definition.reminders_enabled + ) + + def execute(self) -> List[CheckReportM365]: + """Evaluate whether a compliant access review for guest users exists. + + Searches the tenant's access review definitions for an active, recurring, + reviewer-assigned, fail-closed review scoped to guest users. + + Returns: + List[CheckReportM365]: A single report indicating whether a compliant + access review scoped to guest users is configured. + """ + findings = [] + definitions = entra_client.access_review_definitions + + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Access Review Definitions", + resource_id="accessReviewDefinitions", + ) + report.status = "FAIL" + report.status_extended = ( + "No compliant recurring access review scoped to guest users is configured " + "with assigned primary reviewers." + ) + + for definition in definitions: + if ( + definition.status in ACTIVE_STATUSES + and self._targets_guest_users(definition) + and self._is_fail_closed(definition) + and self._is_recurring_with_reviewers(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, recurring, reviewer-assigned, 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..56268818bf --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_access_review_privileged_roles_configured/entra_access_review_privileged_roles_configured.py @@ -0,0 +1,117 @@ +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"} +ALLOWED_RECURRENCE_PATTERNS = {"weekly", "absoluteMonthly"} +# 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 a compliant recurring access review for privileged roles exists. + + An access review scoped to privileged (PIM) directory roles should exist, be + active, recurring, and have primary reviewers assigned. It should use + ``defaultDecision`` = None with ``autoApplyDecisionsEnabled``, mail + notifications, and reminders enabled. + + - PASS: A compliant recurring access review scoped to privileged roles exists. + - FAIL: No compliant recurring access review scoped to privileged roles exists. + """ + + def _targets_privileged_roles(self, definition) -> bool: + """Determine whether an access review targets privileged directory roles. + + For PIM role reviews the role reference lives in the resource scopes, so both + the scope query and the resource scope queries are inspected for markers that + indicate directory role assignments. + + Args: + definition: The access review definition to evaluate. + + Returns: + bool: True if any of the review's scope queries reference privileged + (PIM) directory roles. + """ + 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 _has_required_decision_settings(self, definition) -> bool: + """Determine whether an access review has the required decision settings. + + Args: + definition: The access review definition to evaluate. + + Returns: + bool: True if the review makes no default decision, auto-applies + decisions, and has mail notifications and reminders enabled. + """ + return ( + definition.default_decision == "None" + and definition.auto_apply_enabled + and definition.mail_notifications_enabled + and definition.reminders_enabled + ) + + def _is_recurring_with_reviewers(self, definition) -> bool: + """Determine whether recurrence and primary reviewers are configured.""" + return ( + definition.recurrence_pattern_type in ALLOWED_RECURRENCE_PATTERNS + and definition.recurrence_range_type == "noEnd" + and definition.has_primary_reviewers + ) + + def execute(self) -> List[CheckReportM365]: + """Evaluate whether a compliant access review for privileged roles exists. + + Searches the tenant's access review definitions for an active, recurring, + reviewer-assigned review scoped to privileged (PIM) directory roles. + + Returns: + List[CheckReportM365]: A single report indicating whether a compliant + access review scoped to privileged roles is configured. + """ + findings = [] + definitions = entra_client.access_review_definitions + + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Access Review Definitions", + resource_id="accessReviewDefinitions", + ) + report.status = "FAIL" + report.status_extended = ( + "No compliant recurring access review scoped to privileged roles is " + "configured with assigned primary reviewers." + ) + + for definition in definitions: + if ( + definition.status in ACTIVE_STATUSES + and self._targets_privileged_roles(definition) + and self._has_required_decision_settings(definition) + and self._is_recurring_with_reviewers(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, recurring, reviewer-assigned, and " + "configured with no default decision." + ) + break + + 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..98264d8099 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_authenticator_show_context/entra_authentication_method_authenticator_show_context.py @@ -0,0 +1,61 @@ +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]: + """Execute the Microsoft Authenticator show context check. + + Verifies that the Microsoft Authenticator method is enabled and configured to + display both the application name and the geographic location of the sign-in in + push and passwordless notifications. + + Returns: + List[CheckReportM365]: A list with a single report describing whether + Microsoft Authenticator shows application name and geographic location + context, or an empty list when the authentication methods policy settings + are not available. + """ + 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_state == "enabled" + and 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..3e962f4582 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_email_otp_disabled/entra_authentication_method_email_otp_disabled.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 + + +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 == "disabled": + report.status = "PASS" + report.status_extended = ( + "Email OTP authentication method is disabled in the tenant." + ) + elif email_config.state == "enabled": + report.status = "FAIL" + report.status_extended = ( + "Email OTP authentication method is enabled in the tenant." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "Email OTP authentication method state could not be determined; " + "treating as enabled/non-compliant." + ) + + 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..91c8347659 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_pim_global_administrator_approval_required/entra_pim_global_administrator_approval_required.py @@ -0,0 +1,62 @@ +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 or no approvers are configured. + - No findings: PIM settings are unavailable. + """ + + def execute(self) -> List[CheckReportM365]: + """Evaluate PIM approval settings for the Global Administrator role. + + Reports whether Privileged Identity Management requires approval to activate + the Global Administrator role and whether at least one approver is configured. + + Returns: + List[CheckReportM365]: A single report for the Global Administrator PIM + role settings, or an empty list when the settings are absent. + """ + 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 not setting.has_approvers: + report.status_extended = ( + "PIM requires approval to activate the Global Administrator role but " + "no approvers are configured." + ) + elif 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..9ed5b57b18 --- /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,65 @@ +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 or no approvers are configured. + """ + + def execute(self) -> List[CheckReportM365]: + """Evaluate PIM approval settings for the Privileged Role Administrator role. + + Reports whether Privileged Identity Management requires approval to activate + the Privileged Role Administrator role and whether at least one approver is + configured. + + Returns: + List[CheckReportM365]: A single report for the Privileged Role + Administrator PIM role settings, or an empty list when the settings are + absent. + """ + 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 not setting.has_approvers: + report.status_extended = ( + "PIM requires approval to activate the Privileged Role Administrator " + "role but no approvers are configured." + ) + elif 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_service.py b/prowler/providers/m365/services/entra/entra_service.py index 7996da4f4a..c839216a76 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -107,6 +107,9 @@ class Entra(M365Service): self._get_b2b_collaboration_policy(), self._get_activity_based_timeout_policies(), self._get_named_locations(), + self._get_authentication_methods_policy_settings(), + self._get_pim_role_approval_settings(), + self._get_access_review_definitions(), ) ) @@ -136,6 +139,13 @@ class Entra(M365Service): attributes[16] ) self.named_locations: List[NamedLocation] = attributes[17] + self.authentication_methods_policy_settings: Optional[ + AuthenticationMethodsPolicySettings + ] = attributes[18] + self.pim_role_approval_settings: Dict[str, PimRoleApprovalSetting] = attributes[ + 19 + ] + self.access_review_definitions: List[AccessReviewDefinition] = attributes[20] self.user_accounts_status = {} # Resolve directory-object identifiers referenced by Conditional Access @@ -1284,6 +1294,167 @@ OAuthAppInfo ) 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)" + ) + request_info = self.client.policies.with_url( + url + ).to_get_request_information() + assignments = [] + while True: + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if not response: + break + data = json.loads(response) + page = data.get("value", []) or [] + if not page: + break + assignments.extend(page) + next_link = data.get("@odata.nextLink") or data.get("nextLink") + if not next_link: + break + request_info = self.client.policies.with_url( + next_link + ).to_get_request_information() + for assignment in assignments: + 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" + ) + request_info = self.client.identity_governance.with_url( + url + ).to_get_request_information() + raw_definitions = [] + while True: + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if not response: + break + data = json.loads(response) + page = data.get("value", []) or [] + if not page: + break + raw_definitions.extend(page) + next_link = data.get("@odata.nextLink") or data.get("nextLink") + if not next_link: + break + request_info = self.client.identity_governance.with_url( + next_link + ).to_get_request_information() + for definition in raw_definitions: + scope = definition.get("scope", {}) or {} + settings = definition.get("settings", {}) or {} + recurrence = settings.get("recurrence", {}) or {} + recurrence_pattern = recurrence.get("pattern", {}) or {} + recurrence_range = recurrence.get("range", {}) or {} + stage_settings = definition.get("stageSettings") + if stage_settings is not None: + has_primary_reviewers = bool(stage_settings) and all( + bool(stage.get("reviewers", []) or []) + for stage in stage_settings + ) + else: + has_primary_reviewers = bool(definition.get("reviewers", []) 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"), + default_decision_enabled=bool( + settings.get("defaultDecisionEnabled", False) + ), + 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"), + recurrence_pattern_type=recurrence_pattern.get("type"), + recurrence_range_type=recurrence_range.get("type"), + has_primary_reviewers=has_primary_reviewers, + ) + ) + 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. @@ -1490,6 +1661,55 @@ OAuthAppInfo ) return policies + async def _get_authentication_methods_policy_settings(self): + """Retrieve Microsoft Authenticator settings from the authentication methods policy. + + Fetches ``policies/authenticationMethodsPolicy`` from the v1.0 Graph endpoint + and extracts the Microsoft Authenticator state and its ``featureSettings`` + (app-information / location-information states). Parsed from raw JSON because + ``featureSettings`` lives on the derived Microsoft Authenticator configuration + type rather than the base configuration model. + + 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/v1.0/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) + authenticator = {} + for config in data.get("authenticationMethodConfigurations", []) or []: + if config.get("id") == "MicrosoftAuthenticator": + authenticator = config + break + feature_settings = authenticator.get("featureSettings", {}) or {} + settings = AuthenticationMethodsPolicySettings( + 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"), + ) + 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. @@ -2267,6 +2487,10 @@ class AuthorizationPolicy(BaseModel): 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.""" @@ -2304,6 +2528,34 @@ class NamedLocation(BaseModel): 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 + default_decision_enabled: bool = False + auto_apply_enabled: bool = False + mail_notifications_enabled: bool = False + reminders_enabled: bool = False + duration_in_days: Optional[int] = None + recurrence_pattern_type: Optional[str] = None + recurrence_range_type: Optional[str] = None + has_primary_reviewers: bool = False + + class B2BCollaborationPolicy(BaseModel): """Legacy B2B collaboration (invitation domains) policy.""" @@ -2311,6 +2563,14 @@ class B2BCollaborationPolicy(BaseModel): allowed_domains: List[str] = [] +class AuthenticationMethodsPolicySettings(BaseModel): + """Microsoft Authenticator settings from the authentication methods policy.""" + + authenticator_state: Optional[str] = None + authenticator_display_app_information_state: Optional[str] = None + authenticator_display_location_information_state: Optional[str] = None + + class Organization(BaseModel): id: str name: str 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..5ee806e248 --- /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,147 @@ +from unittest import mock + +import pytest + +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')", + principal_scope_queries=None, + default_decision="Deny", + default_decision_enabled=True, + auto_apply_enabled=True, + mail_notifications_enabled=True, + reminders_enabled=True, + recurrence_pattern_type="weekly", + recurrence_range_type="noEnd", + has_primary_reviewers=True, +): + definition = { + "id": "ar1", + "display_name": "Guest Review", + "status": status, + "scope_query": scope_query, + "principal_scope_queries": principal_scope_queries or [], + "default_decision": default_decision, + "auto_apply_enabled": auto_apply_enabled, + "mail_notifications_enabled": mail_notifications_enabled, + "reminders_enabled": reminders_enabled, + "recurrence_pattern_type": recurrence_pattern_type, + "recurrence_range_type": recurrence_range_type, + "has_primary_reviewers": has_primary_reviewers, + } + if default_decision_enabled is not None: + definition["default_decision_enabled"] = default_decision_enabled + return AccessReviewDefinition(**definition) + + +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, recurring, " + "reviewer-assigned, 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" + + @pytest.mark.parametrize("default_decision_enabled", [False, None]) + def test_default_decision_not_enabled(self, default_decision_enabled): + result = self._run( + [_definition(default_decision_enabled=default_decision_enabled)] + ) + assert result[0].status == "FAIL" + + def test_not_guest_scope(self): + result = self._run([_definition(scope_query="/roleManagement/directory")]) + assert result[0].status == "FAIL" + + def test_guest_filter_in_principal_scopes(self): + # Portal-created reviews keep the guest filter in principalScopes and + # leave the top-level scope query empty. + result = self._run( + [ + _definition( + scope_query="", + principal_scope_queries=["/users?$filter=(userType eq 'Guest')"], + ) + ] + ) + assert result[0].status == "PASS" + + def test_url_encoded_guest_filter(self): + result = self._run( + [_definition(scope_query="/users?$filter=userType%20eq%20%27Guest%27")] + ) + assert result[0].status == "PASS" + + def test_mixed_case_guest_filter(self): + result = self._run( + [_definition(scope_query="/users?$filter=(USERTYPE EQ 'guest')")] + ) + assert result[0].status == "PASS" + + @pytest.mark.parametrize( + "scope_query", + [ + "/users?$filter=not(userType eq 'Guest')", + "/users?$filter=(userType ne 'Guest')", + "/users?$filter=displayName eq 'Guest account'", + "/groups/guest-review-members", + ], + ) + def test_incidental_guest_text_does_not_target_guests(self, scope_query): + result = self._run([_definition(scope_query=scope_query)]) + assert result[0].status == "FAIL" + + @pytest.mark.parametrize( + "definition_overrides", + [ + {"recurrence_pattern_type": None}, + {"recurrence_pattern_type": "daily"}, + {"recurrence_range_type": None}, + {"recurrence_range_type": "endDate"}, + {"has_primary_reviewers": False}, + ], + ) + def test_invalid_recurrence_or_missing_reviewers(self, definition_overrides): + result = self._run([_definition(**definition_overrides)]) + assert result[0].status == "FAIL" 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..a982dc8b8a --- /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,97 @@ +from unittest import mock + +import pytest + +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="None", + auto_apply_enabled=True, + mail_notifications_enabled=True, + reminders_enabled=True, + recurrence_pattern_type="absoluteMonthly", + recurrence_range_type="noEnd", + has_primary_reviewers=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, + recurrence_pattern_type=recurrence_pattern_type, + recurrence_range_type=recurrence_range_type, + has_primary_reviewers=has_primary_reviewers, + ) + + +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_fail_closed_privileged(self): + # Role reference lives in resource scopes (not top-level scope.query). + result = self._run([_definition()]) + assert result[0].status == "PASS" + + def test_deny_default_decision(self): + result = self._run([_definition(default_decision="Deny")]) + 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" + + @pytest.mark.parametrize( + "definition_overrides", + [ + {"recurrence_pattern_type": None}, + {"recurrence_pattern_type": "daily"}, + {"recurrence_range_type": None}, + {"recurrence_range_type": "numbered"}, + {"has_primary_reviewers": False}, + ], + ) + def test_invalid_recurrence_or_missing_reviewers(self, definition_overrides): + result = self._run([_definition(**definition_overrides)]) + assert result[0].status == "FAIL" 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..d1c7683838 --- /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,59 @@ +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_state="enabled", + 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_state="enabled", + authenticator_display_app_information_state="enabled", + authenticator_display_location_information_state="disabled", + ) + ) + assert result[0].status == "FAIL" + + def test_context_enabled_but_authenticator_disabled(self): + result = self._run( + AuthenticationMethodsPolicySettings( + authenticator_state="disabled", + authenticator_display_app_information_state="enabled", + authenticator_display_location_information_state="enabled", + ) + ) + assert result[0].status == "FAIL" 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..7b16ced8ef --- /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,139 @@ +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." + ) + + def test_email_otp_unknown_state(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="unknown", + ), + } + 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 state could not be determined; " + "treating as enabled/non-compliant." + ) 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..8b359f5cd9 --- /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,69 @@ +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" + + def test_approval_not_required_with_approvers(self): + result = self._run( + { + GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting( + role_definition_id=GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID, + is_approval_required=False, + has_approvers=True, + ) + } + ) + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "PIM does not require approval to activate the Global Administrator role." + ) 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..8c982cedd4 --- /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,70 @@ +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_no_approvers(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=False, + ) + } + ) + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "PIM requires approval to activate the Privileged Role Administrator " + "role but no approvers are configured." + ) + + 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/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index 2b85f503cd..e38ac5c0e6 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -3,7 +3,7 @@ import importlib import json from datetime import datetime, timezone from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest @@ -42,6 +42,363 @@ UserAction = entra_service.UserAction UsersConditions = entra_service.UsersConditions +def _get_access_review_definitions(definition): + service = Entra.__new__(Entra) + request_information = MagicMock() + service.client = SimpleNamespace( + identity_governance=SimpleNamespace( + with_url=MagicMock( + return_value=SimpleNamespace( + to_get_request_information=MagicMock( + return_value=request_information + ) + ) + ) + ), + request_adapter=SimpleNamespace( + send_primitive_async=AsyncMock( + return_value=json.dumps({"value": [definition]}).encode() + ) + ), + ) + return asyncio.run(service._get_access_review_definitions()) + + +class TestAccessReviewDefinitions: + @pytest.mark.parametrize( + ("settings", "expected"), + [ + ({"defaultDecisionEnabled": True}, True), + ({"defaultDecisionEnabled": False}, False), + ({}, False), + ], + ) + def test_parses_default_decision_enabled(self, settings, expected): + definitions = _get_access_review_definitions( + {"id": "review-1", "settings": settings} + ) + + assert definitions[0].default_decision_enabled is expected + + def test_parses_recurrence_and_top_level_reviewers(self): + definitions = _get_access_review_definitions( + { + "id": "review-1", + "reviewers": [{"query": "/users/reviewer-1"}], + "settings": { + "recurrence": { + "pattern": {"type": "weekly"}, + "range": {"type": "noEnd"}, + } + }, + } + ) + + assert definitions[0].recurrence_pattern_type == "weekly" + assert definitions[0].recurrence_range_type == "noEnd" + assert definitions[0].has_primary_reviewers is True + + def test_uses_reviewers_from_every_configured_stage(self): + definitions = _get_access_review_definitions( + { + "id": "review-1", + "reviewers": [], + "stageSettings": [ + {"reviewers": [{"query": "/users/reviewer-1"}]}, + {"reviewers": [{"query": "/users/reviewer-2"}]}, + ], + } + ) + + assert definitions[0].has_primary_reviewers is True + + @pytest.mark.parametrize( + "reviewer_configuration", + [ + {"fallbackReviewers": [{"query": "/users/fallback-1"}]}, + { + "reviewers": [{"query": "/users/top-level-reviewer"}], + "stageSettings": [ + {"reviewers": [{"query": "/users/stage-reviewer"}]}, + {"reviewers": []}, + ], + }, + ], + ) + def test_fallback_or_incomplete_stage_reviewers_do_not_count( + self, reviewer_configuration + ): + definitions = _get_access_review_definitions( + {"id": "review-1", **reviewer_configuration} + ) + + assert definitions[0].has_primary_reviewers is False + + @pytest.mark.parametrize( + "terminal_response", + [b"", json.dumps({"value": []}).encode()], + ) + def test_preserves_definitions_when_terminal_page_is_empty(self, terminal_response): + service = Entra.__new__(Entra) + first_page = json.dumps( + { + "value": [{"id": "review-1", "displayName": "Guest Review"}], + "@odata.nextLink": "next-link", + } + ).encode() + send_mock = AsyncMock(side_effect=[first_page, terminal_response]) + with_url_mock = MagicMock( + side_effect=[ + SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-1") + ), + SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-2") + ), + ] + ) + service.client = SimpleNamespace( + identity_governance=SimpleNamespace(with_url=with_url_mock), + request_adapter=SimpleNamespace(send_primitive_async=send_mock), + ) + + definitions = asyncio.run(service._get_access_review_definitions()) + + assert [definition.id for definition in definitions] == ["review-1"] + assert send_mock.await_count == 2 + assert with_url_mock.call_args_list[-1] == call("next-link") + + def test_error_returns_no_access_review_definitions(self): + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + identity_governance=SimpleNamespace( + with_url=MagicMock( + return_value=SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-1") + ) + ) + ), + request_adapter=SimpleNamespace( + send_primitive_async=AsyncMock(side_effect=RuntimeError("Graph error")) + ), + ) + + assert asyncio.run(service._get_access_review_definitions()) == [] + + +class TestPimRoleApprovalSettings: + def test_paginates_and_parses_role_approval_settings(self): + service = Entra.__new__(Entra) + first_page = json.dumps( + { + "value": [ + {"policy": {"rules": []}}, + { + "roleDefinitionId": "role-unrelated", + "policy": {"rules": [{"id": "Unrelated_Rule"}]}, + }, + { + "roleDefinitionId": "role-approval-required", + "policy": { + "rules": [ + { + "id": "Approval_EndUser_Assignment", + "setting": { + "isApprovalRequired": True, + "approvalStages": [ + {"primaryApprovers": []}, + { + "primaryApprovers": [ + {"id": "approver-1"} + ] + }, + ], + }, + } + ] + }, + }, + ], + "@odata.nextLink": "next-link", + } + ).encode() + final_page = json.dumps( + { + "value": [ + { + "roleDefinitionId": "role-approval-disabled", + "policy": { + "rules": [ + { + "id": "Approval_EndUser_Assignment", + "setting": { + "isApprovalRequired": False, + "approvalStages": [{"primaryApprovers": []}], + }, + } + ] + }, + } + ] + } + ).encode() + send_mock = AsyncMock(side_effect=[first_page, final_page]) + with_url_mock = MagicMock( + side_effect=[ + SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-1") + ), + SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-2") + ), + ] + ) + service.client = SimpleNamespace( + policies=SimpleNamespace(with_url=with_url_mock), + request_adapter=SimpleNamespace(send_primitive_async=send_mock), + ) + + settings = asyncio.run(service._get_pim_role_approval_settings()) + + assert set(settings) == { + "role-unrelated", + "role-approval-required", + "role-approval-disabled", + } + assert settings["role-unrelated"].is_approval_required is False + assert settings["role-unrelated"].has_approvers is False + assert settings["role-approval-required"].is_approval_required is True + assert settings["role-approval-required"].has_approvers is True + assert settings["role-approval-disabled"].is_approval_required is False + assert settings["role-approval-disabled"].has_approvers is False + assert send_mock.await_count == 2 + assert with_url_mock.call_args_list[-1] == call("next-link") + + @pytest.mark.parametrize( + "response", + [b"", json.dumps({"value": []}).encode()], + ) + def test_stops_on_falsy_or_empty_response(self, response): + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + with_url=MagicMock( + return_value=SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-1") + ) + ) + ), + request_adapter=SimpleNamespace( + send_primitive_async=AsyncMock(return_value=response) + ), + ) + + assert asyncio.run(service._get_pim_role_approval_settings()) == {} + + def test_error_returns_no_role_approval_settings(self): + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + with_url=MagicMock( + return_value=SimpleNamespace( + to_get_request_information=MagicMock(return_value="request-1") + ) + ) + ), + request_adapter=SimpleNamespace( + send_primitive_async=AsyncMock(side_effect=RuntimeError("Graph error")) + ), + ) + + assert asyncio.run(service._get_pim_role_approval_settings()) == {} + + +class TestAuthenticationMethodsPolicySettings: + @staticmethod + def _service(response=None, error=None): + send_mock = AsyncMock(return_value=response, side_effect=error) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + authentication_methods_policy=SimpleNamespace( + with_url=MagicMock( + return_value=SimpleNamespace( + to_get_request_information=MagicMock( + return_value="request-1" + ) + ) + ) + ) + ), + request_adapter=SimpleNamespace(send_primitive_async=send_mock), + ) + return service + + def test_parses_microsoft_authenticator_after_unrelated_configuration(self): + response = json.dumps( + { + "authenticationMethodConfigurations": [ + {"id": "Fido2", "state": "enabled"}, + { + "id": "MicrosoftAuthenticator", + "state": "enabled", + "featureSettings": { + "displayAppInformationRequiredState": {"state": "enabled"}, + "displayLocationInformationRequiredState": { + "state": "disabled" + }, + }, + }, + ] + } + ).encode() + + settings = asyncio.run( + self._service( + response=response + )._get_authentication_methods_policy_settings() + ) + + assert settings.authenticator_state == "enabled" + assert settings.authenticator_display_app_information_state == "enabled" + assert settings.authenticator_display_location_information_state == "disabled" + + def test_missing_microsoft_authenticator_returns_unconfigured_model(self): + response = json.dumps( + {"authenticationMethodConfigurations": [{"id": "Fido2"}]} + ).encode() + + settings = asyncio.run( + self._service( + response=response + )._get_authentication_methods_policy_settings() + ) + + assert settings.authenticator_state is None + assert settings.authenticator_display_app_information_state is None + assert settings.authenticator_display_location_information_state is None + + def test_falsy_response_returns_none(self): + assert ( + asyncio.run( + self._service( + response=b"" + )._get_authentication_methods_policy_settings() + ) + is None + ) + + def test_error_returns_none(self): + assert ( + asyncio.run( + self._service( + error=RuntimeError("Graph error") + )._get_authentication_methods_policy_settings() + ) + is None + ) + + async def mock_entra_get_authorization_policy(_): return AuthorizationPolicy( id="id-1",