diff --git a/prowler/changelog.d/entra-msol-powershell-blocked.added.md b/prowler/changelog.d/entra-msol-powershell-blocked.added.md new file mode 100644 index 0000000000..f8e12e10e4 --- /dev/null +++ b/prowler/changelog.d/entra-msol-powershell-blocked.added.md @@ -0,0 +1 @@ +`entra_policy_msol_powershell_blocked` check for M365 provider, verifying that the tenant authorization policy blocks the legacy MSOnline PowerShell module diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index e7bf650ac7..e3a6e873ca 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -674,6 +674,7 @@ "entra_app_registration_no_unused_privileged_permissions", "entra_policy_ensure_default_user_cannot_create_tenants", "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_msol_powershell_blocked", "entra_seamless_sso_disabled", "entra_service_principal_no_secrets_for_permanent_tier0_roles" ] @@ -830,6 +831,7 @@ ], "Checks": [ "entra_app_registration_no_unused_privileged_permissions", + "entra_policy_msol_powershell_blocked", "entra_policy_restricts_user_consent_for_apps", "entra_thirdparty_integrated_apps_not_allowed", "teams_external_domains_restricted", diff --git a/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.metadata.json new file mode 100644 index 0000000000..0268918f8a --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "m365", + "CheckID": "entra_policy_msol_powershell_blocked", + "CheckTitle": "Tenant blocks the legacy MSOnline (MSOL) PowerShell module", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant authorization policy property `blockMsolPowerShell` controls whether the retired **MSOnline (MSOL) PowerShell** module and its legacy service endpoint are accessible. When set to `true`, user-based access through that endpoint is disabled without affecting Microsoft Entra Connect or Microsoft Graph PowerShell.", + "Risk": "The MSOnline and AzureAD PowerShell modules are **retired** and no longer receive security updates. They use a legacy endpoint that is less monitored than Microsoft Graph, providing an **unsupported administrative channel** to identity data and configuration. Attackers or insiders could exploit this path to read or modify directory objects while bypassing modern controls and audit coverage.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/graph/api/resources/authorizationpolicy?view=graph-rest-1.0", + "https://learn.microsoft.com/powershell/azure/active-directory/overview", + "https://learn.microsoft.com/powershell/microsoftgraph/migration-steps", + "https://maester.dev/docs/tests/MT.1185" + ], + "Remediation": { + "Code": { + "CLI": "Update-MgPolicyAuthorizationPolicy -BodyParameter @{ blockMsolPowerShell = $true }", + "NativeIaC": "", + "Other": "1. Connect to Microsoft Graph PowerShell with the required scope:\n `Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\"`\n2. Run:\n `Update-MgPolicyAuthorizationPolicy -BodyParameter @{ blockMsolPowerShell = $true }`\n3. Verify the change:\n `(Get-MgPolicyAuthorizationPolicy).BlockMsolPowerShell`\n\n*Note: There is no Entra admin center toggle for this setting. Before enabling, migrate any remaining scripts from MSOnline/AzureAD to Microsoft Graph PowerShell.*", + "Terraform": "" + }, + "Recommendation": { + "Text": "Block the legacy MSOnline PowerShell endpoint by setting `blockMsolPowerShell` to **true** in the authorization policy. Before enabling, migrate all automation and scripts from the retired MSOnline/AzureAD modules to **Microsoft Graph PowerShell**. This does not affect Microsoft Entra Connect sync or Microsoft Graph-based tooling.", + "Url": "https://hub.prowler.com/check/entra_policy_msol_powershell_blocked" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The blockMsolPowerShell setting is not enabled by default in every tenant and must be verified explicitly. It does not affect Microsoft Entra Connect or Microsoft Graph PowerShell." +} diff --git a/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.py b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.py new file mode 100644 index 0000000000..935235a011 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.py @@ -0,0 +1,67 @@ +"""Check if legacy MSOnline (MSOL) PowerShell module is blocked in the tenant.""" + +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_policy_msol_powershell_blocked(Check): + """Ensure legacy MSOnline (MSOL) PowerShell is blocked in the tenant authorization policy. + + This check verifies the `block_msol_powershell` property of the authorization policy. + The MSOnline and AzureAD PowerShell modules are retired and use a legacy endpoint + that is less monitored than Microsoft Graph. Blocking them removes an unsupported + administrative access channel. + + - PASS: blockMsolPowerShell is true. + - FAIL: blockMsolPowerShell is false. + - MANUAL: The authorization policy could not be read or the property is missing/null. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the MSOL PowerShell blocked check. + + Retrieves the authorization policy from the Microsoft Entra client and checks + whether the 'block_msol_powershell' property is set to true. + + Returns: + List[CheckReportM365]: A list containing a single check report with + the pass/fail/manual status and description. + """ + findings = [] + auth_policy = entra_client.authorization_policy + + if auth_policy is None: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Authorization Policy", + resource_id="authorizationPolicy", + ) + report.status = "MANUAL" + report.status_extended = "Cannot evaluate whether legacy MSOnline (MSOL) PowerShell is blocked: the authorization policy could not be retrieved. Check the scanning application's permissions and any Microsoft Graph API errors in the scan log." + findings.append(report) + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=auth_policy, + resource_name=auth_policy.name, + resource_id=auth_policy.id, + ) + + block_msol = auth_policy.block_msol_powershell + + if block_msol is None: + report.status = "MANUAL" + report.status_extended = "Legacy MSOnline (MSOL) PowerShell blocked status could not be determined from the tenant authorization policy." + elif block_msol: + report.status = "PASS" + report.status_extended = "Legacy MSOnline (MSOL) PowerShell is blocked in the tenant authorization policy." + else: + report.status = "FAIL" + report.status_extended = "Legacy MSOnline (MSOL) PowerShell is not blocked in the tenant authorization policy." + + 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 c761834195..4ecb2b1126 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -227,6 +227,9 @@ class Entra(M365Service): ), guest_invite_settings=auth_policy.allow_invites_from, guest_user_role_id=auth_policy.guest_user_role_id, + block_msol_powershell=getattr( + auth_policy, "block_msol_power_shell", None + ), ) except Exception as error: logger.error( @@ -2510,12 +2513,15 @@ class DefaultUserRolePermissions(BaseModel): class AuthorizationPolicy(BaseModel): + """Tenant-wide authorization policy from Microsoft Entra ID.""" + id: str name: str description: str default_user_role_permissions: Optional[DefaultUserRolePermissions] guest_invite_settings: Optional[str] guest_user_role_id: Optional[UUID] + block_msol_powershell: Optional[bool] = None # Well-known directory setting template IDs (from /groupSettings). diff --git a/tests/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked_test.py b/tests/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked_test.py new file mode 100644 index 0000000000..d6be165b04 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked_test.py @@ -0,0 +1,147 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthorizationPolicy, + DefaultUserRolePermissions, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + + +class Test_entra_policy_msol_powershell_blocked: + """Tests for the entra_policy_msol_powershell_blocked check.""" + + def test_authorization_policy_none(self): + """MANUAL when the authorization policy could not be retrieved.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked import ( + entra_policy_msol_powershell_blocked, + ) + + entra_client.authorization_policy = None + + result = entra_policy_msol_powershell_blocked().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert "could not be retrieved" in result[0].status_extended + + def test_block_msol_powershell_none(self): + """MANUAL when blockMsolPowerShell is missing/null in the response.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked import ( + entra_policy_msol_powershell_blocked, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions(), + block_msol_powershell=None, + ) + + result = entra_policy_msol_powershell_blocked().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert "could not be determined" in result[0].status_extended + + def test_block_msol_powershell_true(self): + """PASS when blockMsolPowerShell is true.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked import ( + entra_policy_msol_powershell_blocked, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions(), + block_msol_powershell=True, + ) + + result = entra_policy_msol_powershell_blocked().execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert ( + result[0].status_extended + == "Legacy MSOnline (MSOL) PowerShell is blocked in the tenant authorization policy." + ) + + def test_block_msol_powershell_false(self): + """FAIL when blockMsolPowerShell is false.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked import ( + entra_policy_msol_powershell_blocked, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions(), + block_msol_powershell=False, + ) + + result = entra_policy_msol_powershell_blocked().execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert ( + result[0].status_extended + == "Legacy MSOnline (MSOL) PowerShell is not blocked in the tenant authorization policy." + ) 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 1c98973e0a..04c874a67a 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -1990,3 +1990,32 @@ class TestGetUsersError: assert users == {} assert service.users_error is not None assert "Unable to retrieve users from Microsoft Graph" in service.users_error + + +class TestAuthorizationPolicyMsolMapping: + """``blockMsolPowerShell`` from the Graph SDK model reaches ``AuthorizationPolicy``.""" + + @pytest.mark.parametrize("value", [True, False, None]) + def test_block_msol_powershell_is_mapped(self, value): + from msgraph.generated.models.authorization_policy import ( + AuthorizationPolicy as GraphAuthorizationPolicy, + ) + + graph_policy = GraphAuthorizationPolicy( + id="authorizationPolicy", + display_name="Authorization Policy", + description="Used to manage authorization related settings across the company.", + block_msol_power_shell=value, + ) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + authorization_policy=SimpleNamespace( + get=AsyncMock(return_value=graph_policy) + ) + ) + ) + + policy = asyncio.run(service._get_authorization_policy()) + + assert policy.block_msol_powershell is value