feat(m365): add entra_conditional_access_policy_p1_license_utilization security check

Add new security check entra_conditional_access_policy_p1_license_utilization for m365 provider.
Includes check implementation, metadata, and unit tests.
This commit is contained in:
Hugo P.Brito
2026-04-20 09:28:12 +01:00
parent f7194b32de
commit ec52c7b3fd
7 changed files with 400 additions and 13 deletions
+4
View File
@@ -4,6 +4,10 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.24.1] (Prowler UNRELEASED)
### 🚀 Added
- `entra_conditional_access_policy_p1_license_utilization` check for m365 provider [(#10783)](https://github.com/prowler-cloud/prowler/pull/10783)
### 🔄 Changed
- bumped `msgraph-sdk` from 1.23.0 to 1.55.0 and `azure-mgmt-resource` from 23.3.0 to 24.0.0, removing `marshmallow` as is a transitively dev dependency [(#10733)](https://github.com/prowler-cloud/prowler/pull/10733)
+16 -13
View File
@@ -206,11 +206,12 @@
"admincenter_users_admins_reduced_license_footprint",
"entra_admin_portals_access_restriction",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_conditional_access_policy_mfa_enforced_for_guest_users",
"entra_conditional_access_policy_block_o365_elevated_insider_risk",
"entra_conditional_access_policy_block_unknown_device_platforms",
"entra_conditional_access_policy_directory_sync_account_excluded",
"entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced",
"entra_conditional_access_policy_directory_sync_account_excluded",
"entra_conditional_access_policy_mfa_enforced_for_guest_users",
"entra_conditional_access_policy_p1_license_utilization",
"entra_policy_guest_users_access_restrictions",
"entra_seamless_sso_disabled"
]
@@ -248,15 +249,16 @@
"defenderxdr_endpoint_privileged_user_exposed_credentials",
"entra_admin_users_mfa_enabled",
"entra_admin_users_sign_in_frequency_enabled",
"entra_break_glass_account_fido2_security_key_registered",
"entra_conditional_access_policy_mfa_enforced_for_guest_users",
"entra_default_app_management_policy_enabled",
"entra_all_apps_conditional_access_coverage",
"entra_conditional_access_policy_device_registration_mfa_required",
"entra_intune_enrollment_sign_in_frequency_every_time",
"entra_break_glass_account_fido2_security_key_registered",
"entra_conditional_access_policy_block_unknown_device_platforms",
"entra_conditional_access_policy_device_code_flow_blocked",
"entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced",
"entra_conditional_access_policy_device_code_flow_blocked",
"entra_conditional_access_policy_device_registration_mfa_required",
"entra_conditional_access_policy_mfa_enforced_for_guest_users",
"entra_conditional_access_policy_p1_license_utilization",
"entra_default_app_management_policy_enabled",
"entra_intune_enrollment_sign_in_frequency_every_time",
"entra_legacy_authentication_blocked",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
@@ -718,16 +720,17 @@
"entra_admin_users_mfa_enabled",
"entra_admin_users_sign_in_frequency_enabled",
"entra_all_apps_conditional_access_coverage",
"entra_conditional_access_policy_device_registration_mfa_required",
"entra_conditional_access_policy_mfa_enforced_for_guest_users",
"entra_intune_enrollment_sign_in_frequency_every_time",
"entra_break_glass_account_fido2_security_key_registered",
"entra_conditional_access_policy_approved_client_app_required_for_mobile",
"entra_conditional_access_policy_block_unknown_device_platforms",
"entra_conditional_access_policy_device_code_flow_blocked",
"entra_conditional_access_policy_directory_sync_account_excluded",
"entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced",
"entra_conditional_access_policy_device_code_flow_blocked",
"entra_conditional_access_policy_device_registration_mfa_required",
"entra_conditional_access_policy_directory_sync_account_excluded",
"entra_conditional_access_policy_mfa_enforced_for_guest_users",
"entra_conditional_access_policy_p1_license_utilization",
"entra_identity_protection_sign_in_risk_enabled",
"entra_intune_enrollment_sign_in_frequency_every_time",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
"entra_users_mfa_enabled"
@@ -0,0 +1,38 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_p1_license_utilization",
"CheckTitle": "P1 license entitlements ensure sufficient coverage for Conditional Access users",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Verifies that the number of entitled Azure AD Premium **P1 licenses** is sufficient to cover all users actively utilizing **Conditional Access**. Unlicensed Conditional Access usage may violate Microsoft licensing terms and indicates gaps in license governance.",
"Risk": "When more users consume Conditional Access than the organization has P1 licenses for, it creates a **licensing compliance gap**. Microsoft may flag the tenant during audits, leading to retroactive charges or service restrictions. Additionally, unlicensed users may lose Conditional Access protection if Microsoft enforces entitlements.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview",
"https://learn.microsoft.com/en-us/entra/fundamentals/concept-license-utilization"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the **Microsoft Entra admin center** (https://entra.microsoft.com).\n2. Go to **Identity** > **Billing** > **Licenses** > **Licensed features**.\n3. Review the **License utilization** report for P1 features.\n4. Purchase additional P1 licenses or remove Conditional Access policy assignments for unlicensed users to bring entitlements in line with utilization.",
"Terraform": ""
},
"Recommendation": {
"Text": "Review the Azure AD Premium license utilization report and ensure that P1 license entitlements match or exceed the number of users utilizing Conditional Access. Purchase additional licenses or adjust policy scope as needed.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_p1_license_utilization"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check uses the Microsoft Graph beta API endpoint reports/azureADPremiumLicenseInsight. The Reports.Read.All permission is required."
}
@@ -0,0 +1,53 @@
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
class entra_conditional_access_policy_p1_license_utilization(Check):
"""Check if P1 license entitlements cover Conditional Access utilization.
Validates that the number of entitled Azure AD Premium P1 licenses
is sufficient to cover all users actively utilizing Conditional Access.
- PASS: P1 license entitlements cover all Conditional Access users.
- FAIL: More users utilize Conditional Access than entitled P1 licenses.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the P1 license utilization check.
Returns:
A list of reports containing the result of the check.
"""
findings = []
insight = entra_client.premium_license_insight
report = CheckReportM365(
metadata=self.metadata(),
resource=insight if insight else {},
resource_name="Premium License Insight",
resource_id="azureADPremiumLicenseInsight",
)
if insight is None:
report.status = "FAIL"
report.status_extended = (
"Could not retrieve Azure AD Premium license insight data. "
"Verify that the tenant has a P1 or P2 license and that "
"the required permissions are granted."
)
elif insight.p1_license_count >= insight.conditional_access_users_count:
report.status = "PASS"
report.status_extended = (
f"P1 license entitlements ({insight.p1_license_count}) cover "
f"all Conditional Access users ({insight.conditional_access_users_count})."
)
else:
report.status = "FAIL"
report.status_extended = (
f"P1 license entitlements ({insight.p1_license_count}) do not cover "
f"all Conditional Access users ({insight.conditional_access_users_count})."
)
findings.append(report)
return findings
@@ -5,6 +5,8 @@ from enum import Enum
from typing import Dict, List, Optional
from uuid import UUID
from kiota_abstractions.method import Method
from kiota_abstractions.request_information import RequestInformation
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import (
RunHuntingQueryPostRequestBody,
@@ -36,6 +38,7 @@ class Entra(M365Service):
user_accounts_status (dict): Dictionary of user account statuses.
oauth_apps (dict): Dictionary of OAuth applications from Defender XDR.
authentication_method_configurations (dict): Dictionary of authentication method configurations.
premium_license_insight (PremiumLicenseInsight): Azure AD Premium license utilization insight.
"""
def __init__(self, provider: M365Provider):
@@ -83,6 +86,7 @@ class Entra(M365Service):
self._get_oauth_apps(),
self._get_directory_sync_settings(),
self._get_authentication_method_configurations(),
self._get_premium_license_insight(),
)
)
@@ -98,6 +102,7 @@ class Entra(M365Service):
self.authentication_method_configurations: Dict[
str, AuthenticationMethodConfiguration
] = attributes[9]
self.premium_license_insight: Optional[PremiumLicenseInsight] = attributes[10]
self.user_accounts_status = {}
if created_loop:
@@ -1019,6 +1024,44 @@ OAuthAppInfo
return oauth_apps
async def _get_premium_license_insight(self) -> Optional["PremiumLicenseInsight"]:
"""Retrieve Azure AD Premium license insight from the Microsoft Graph beta API.
Fetches the premium license utilization report which includes the total
number of entitled P1 licenses and the number of users actively utilizing
Conditional Access (a P1 feature).
Returns:
Optional[PremiumLicenseInsight]: The premium license insight data,
or None if the API call failed or data is unavailable.
"""
logger.info("Entra - Getting premium license insight...")
try:
request_info = RequestInformation()
request_info.http_method = Method.GET
request_info.url_template = (
"{+baseurl}/reports/azureADPremiumLicenseInsight"
)
request_info.path_parameters = {
"baseurl": "https://graph.microsoft.com/beta"
}
response = await self.client.request_adapter.send_primitive_async(
request_info, "bytes", {}
)
if response:
data = json.loads(response)
return PremiumLicenseInsight(
p1_license_count=data.get("p1LicenseCount", 0),
conditional_access_users_count=data.get(
"conditionalAccessUsersCount", 0
),
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
async def _get_authentication_method_configurations(self):
"""Retrieve authentication method configurations from Microsoft Entra.
@@ -1481,3 +1524,18 @@ class OAuthApp(BaseModel):
is_admin_consented: bool = False
last_used_time: Optional[str] = None
app_origin: str = ""
class PremiumLicenseInsight(BaseModel):
"""Model representing Azure AD Premium license utilization insight.
Contains the total number of entitled P1 licenses and the number
of users actively utilizing Conditional Access, a P1-gated feature.
Attributes:
p1_license_count: Total number of entitled P1 (or P2) licenses.
conditional_access_users_count: Number of users utilizing Conditional Access.
"""
p1_license_count: int = 0
conditional_access_users_count: int = 0
@@ -0,0 +1,231 @@
from unittest import mock
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_p1_license_utilization.entra_conditional_access_policy_p1_license_utilization"
class Test_entra_conditional_access_policy_p1_license_utilization:
def test_no_premium_license_insight(self):
"""FAIL when premium license insight data is unavailable (None)."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_p1_license_utilization.entra_conditional_access_policy_p1_license_utilization import (
entra_conditional_access_policy_p1_license_utilization,
)
entra_client.premium_license_insight = None
check = entra_conditional_access_policy_p1_license_utilization()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "Could not retrieve" in result[0].status_extended
assert result[0].resource == {}
assert result[0].resource_name == "Premium License Insight"
assert result[0].resource_id == "azureADPremiumLicenseInsight"
assert result[0].location == "global"
def test_licenses_cover_utilization(self):
"""PASS when P1 license count >= conditional access users count."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_p1_license_utilization.entra_conditional_access_policy_p1_license_utilization import (
entra_conditional_access_policy_p1_license_utilization,
)
from prowler.providers.m365.services.entra.entra_service import (
PremiumLicenseInsight,
)
entra_client.premium_license_insight = PremiumLicenseInsight(
p1_license_count=100,
conditional_access_users_count=80,
)
check = entra_conditional_access_policy_p1_license_utilization()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "P1 license entitlements (100) cover all Conditional Access users (80)."
)
assert (
result[0].resource
== entra_client.premium_license_insight.dict()
)
assert result[0].resource_name == "Premium License Insight"
assert result[0].resource_id == "azureADPremiumLicenseInsight"
assert result[0].location == "global"
def test_licenses_equal_utilization(self):
"""PASS when P1 license count exactly equals conditional access users count."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_p1_license_utilization.entra_conditional_access_policy_p1_license_utilization import (
entra_conditional_access_policy_p1_license_utilization,
)
from prowler.providers.m365.services.entra.entra_service import (
PremiumLicenseInsight,
)
entra_client.premium_license_insight = PremiumLicenseInsight(
p1_license_count=50,
conditional_access_users_count=50,
)
check = entra_conditional_access_policy_p1_license_utilization()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "P1 license entitlements (50) cover all Conditional Access users (50)."
)
assert (
result[0].resource
== entra_client.premium_license_insight.dict()
)
assert result[0].resource_name == "Premium License Insight"
assert result[0].resource_id == "azureADPremiumLicenseInsight"
assert result[0].location == "global"
def test_licenses_insufficient(self):
"""FAIL when P1 license count < conditional access users count."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_p1_license_utilization.entra_conditional_access_policy_p1_license_utilization import (
entra_conditional_access_policy_p1_license_utilization,
)
from prowler.providers.m365.services.entra.entra_service import (
PremiumLicenseInsight,
)
entra_client.premium_license_insight = PremiumLicenseInsight(
p1_license_count=30,
conditional_access_users_count=80,
)
check = entra_conditional_access_policy_p1_license_utilization()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "P1 license entitlements (30) do not cover all Conditional Access users (80)."
)
assert (
result[0].resource
== entra_client.premium_license_insight.dict()
)
assert result[0].resource_name == "Premium License Insight"
assert result[0].resource_id == "azureADPremiumLicenseInsight"
assert result[0].location == "global"
def test_zero_licenses_zero_users(self):
"""PASS when both license count and utilization are zero."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_p1_license_utilization.entra_conditional_access_policy_p1_license_utilization import (
entra_conditional_access_policy_p1_license_utilization,
)
from prowler.providers.m365.services.entra.entra_service import (
PremiumLicenseInsight,
)
entra_client.premium_license_insight = PremiumLicenseInsight(
p1_license_count=0,
conditional_access_users_count=0,
)
check = entra_conditional_access_policy_p1_license_utilization()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "P1 license entitlements (0) cover all Conditional Access users (0)."
)
assert result[0].resource_name == "Premium License Insight"
assert result[0].resource_id == "azureADPremiumLicenseInsight"
assert result[0].location == "global"