feat(m365): add entra_policy_msol_powershell_blocked check (#13004)

Co-authored-by: kinomakino <3662588+kinomakino@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 15:22:01 +02:00
1 parent c599a9cf1d
commit f606a3c195
8 files changed
+291

No files matched your search

@@ -0,0 +1 @@
`entra_policy_msol_powershell_blocked` check for M365 provider, verifying that the tenant authorization policy blocks the legacy MSOnline PowerShell module
@@ -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",
@@ -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."
}
@@ -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
@@ -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).
@@ -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."
)
@@ -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