diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 7d711c8fdc..c58e99a7ce 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added - `entra_conditional_access_policy_p1_license_utilization` check for m365 provider [(#10783)](https://github.com/prowler-cloud/prowler/pull/10783) +- `entra_conditional_access_policy_p2_license_utilization` check for m365 provider [(#10784)](https://github.com/prowler-cloud/prowler/pull/10784) ### 🔄 Changed diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index 3ec1a5edd5..2aeff4b7e0 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -21,6 +21,7 @@ "defender_antiphishing_policy_configured", "defender_antispam_policy_inbound_no_allowed_domains", "entra_conditional_access_policy_block_o365_elevated_insider_risk", + "entra_conditional_access_policy_p2_license_utilization", "entra_identity_protection_sign_in_risk_enabled", "entra_identity_protection_user_risk_enabled" ] @@ -212,6 +213,7 @@ "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_conditional_access_policy_p2_license_utilization", "entra_policy_guest_users_access_restrictions", "entra_seamless_sso_disabled" ] diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/entra_conditional_access_policy_p2_license_utilization.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/entra_conditional_access_policy_p2_license_utilization.metadata.json new file mode 100644 index 0000000000..f2bbcfade2 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/entra_conditional_access_policy_p2_license_utilization.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_p2_license_utilization", + "CheckTitle": "P2 license entitlements ensure sufficient coverage for risk-based Conditional Access users", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Verifies that the entitled Microsoft Entra ID P2 license count covers all users actively using **risk-based Conditional Access** (regular plus guest). P1 entitlements do not include P2 features. For non-risk-based (P1) Conditional Access, see `entra_conditional_access_policy_p1_license_utilization`.", + "Risk": "When more users consume risk-based Conditional Access than the organization has P2 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 Identity Protection coverage if Microsoft enforces entitlements.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview", + "https://learn.microsoft.com/en-us/graph/api/resources/azureadpremiumlicenseinsight?view=graph-rest-beta", + "https://learn.microsoft.com/en-us/entra/fundamentals/licensing", + "https://maester.dev/docs/tests/MT.1023" + ], + "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 P2 features.\n4. Purchase additional P2 licenses, or reduce the scope of risk-based Conditional Access policies to only include users that hold a P2 license.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Review the Microsoft Entra ID Premium license utilization report and ensure that P2 license entitlements match or exceed the number of users utilizing risk-based Conditional Access (regular and guest). Purchase additional P2 licenses or adjust policy scope as needed.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_p2_license_utilization" + } + }, + "Categories": [ + "identity-access", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Uses Microsoft Graph beta reports/azureADPremiumLicenseInsight. Requires Reports.Read.All. Returns MANUAL when the tenant has no Microsoft Entra ID P1 or P2 license (Graph returns 403 missingLicense). Compares risk-based Conditional Access utilization (regular + guest) against the entitled P2 license count only; P1 entitlements do not cover P2 features." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/entra_conditional_access_policy_p2_license_utilization.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/entra_conditional_access_policy_p2_license_utilization.py new file mode 100644 index 0000000000..a71fc2dc71 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/entra_conditional_access_policy_p2_license_utilization.py @@ -0,0 +1,64 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_conditional_access_policy_p2_license_utilization(Check): + """Check P2-feature (risk-based Conditional Access) license coverage. + + Compares the number of users consuming **risk-based Conditional Access** + (regular plus guest, exposed via ``p2FeatureUtilizations`` in + ``reports/azureADPremiumLicenseInsight``) against the entitled + Microsoft Entra ID P2 license count. P1 entitlements do **not** cover + P2 features, so this check uses ``entitled_p2_license_count`` alone. + + For non-risk-based Conditional Access (P1), see + ``entra_conditional_access_policy_p1_license_utilization``. + + - PASS: P2 entitlements cover all risk-based Conditional Access users. + - FAIL: risk-based CA utilization exceeds entitled P2 licenses. + - MANUAL: license insight unavailable, typically because the tenant has no + P1/P2 license (Microsoft Graph returns 403 ``missingLicense``) or the + ``Reports.Read.All`` permission was not granted. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the P2 license utilization check. + + Returns: + A list with a single report describing the licensing coverage. + """ + findings = [] + + insight = entra_client.premium_license_insight + + report = CheckReportM365( + metadata=self.metadata(), + resource=insight or {}, + resource_name="Premium License Insight", + resource_id="azureADPremiumLicenseInsight", + ) + + if insight is None: + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve Azure AD Premium license insight. " + "Verify the tenant has at least one Microsoft Entra ID P1 or P2 " + "license and that Reports.Read.All permission is granted." + ) + elif insight.entitled_p2_license_count >= insight.p2_licenses_utilized: + report.status = "PASS" + report.status_extended = ( + f"P2 license entitlements ({insight.entitled_p2_license_count}) " + f"cover all risk-based Conditional Access users " + f"({insight.p2_licenses_utilized})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"P2 license entitlements ({insight.entitled_p2_license_count}) " + f"do not cover all risk-based Conditional Access users " + f"({insight.p2_licenses_utilized})." + ) + + findings.append(report) + return findings diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/__init__.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/entra_conditional_access_policy_p2_license_utilization_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/entra_conditional_access_policy_p2_license_utilization_test.py new file mode 100644 index 0000000000..6221dc5066 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_p2_license_utilization/entra_conditional_access_policy_p2_license_utilization_test.py @@ -0,0 +1,277 @@ +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_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization" + + +class Test_entra_conditional_access_policy_p2_license_utilization: + def test_no_premium_license_insight(self): + """MANUAL 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_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization import ( + entra_conditional_access_policy_p2_license_utilization, + ) + + entra_client.premium_license_insight = None + + check = entra_conditional_access_policy_p2_license_utilization() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Could not retrieve" in result[0].status_extended + assert "P1 or P2" in result[0].status_extended + assert "Reports.Read.All" 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_p2_covers_utilization(self): + """PASS when P2 entitlements cover all risk-based CA users (regular + guest).""" + 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_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization import ( + entra_conditional_access_policy_p2_license_utilization, + ) + from prowler.providers.m365.services.entra.entra_service import ( + PremiumLicenseInsight, + ) + + entra_client.premium_license_insight = PremiumLicenseInsight( + entitled_p1_license_count=0, + entitled_p2_license_count=50, + entitled_total_license_count=50, + p1_licenses_utilized=20, + p2_licenses_utilized=35, + ) + + check = entra_conditional_access_policy_p2_license_utilization() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "P2 license entitlements (50) cover all risk-based Conditional Access users (35)." + ) + 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_p1_only_does_not_cover_p2_utilization(self): + """FAIL: P1 entitlements do not include P2 features.""" + 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_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization import ( + entra_conditional_access_policy_p2_license_utilization, + ) + from prowler.providers.m365.services.entra.entra_service import ( + PremiumLicenseInsight, + ) + + entra_client.premium_license_insight = PremiumLicenseInsight( + entitled_p1_license_count=200, + entitled_p2_license_count=0, + entitled_total_license_count=200, + p1_licenses_utilized=150, + p2_licenses_utilized=10, + ) + + check = entra_conditional_access_policy_p2_license_utilization() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "P2 license entitlements (0) do not cover all risk-based Conditional Access users (10)." + ) + assert result[0].resource_name == "Premium License Insight" + assert result[0].resource_id == "azureADPremiumLicenseInsight" + assert result[0].location == "global" + + def test_licenses_insufficient_with_guests(self): + """FAIL when guest risk-based CA users push utilization above the entitled 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_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization import ( + entra_conditional_access_policy_p2_license_utilization, + ) + from prowler.providers.m365.services.entra.entra_service import ( + PremiumLicenseInsight, + ) + + entra_client.premium_license_insight = PremiumLicenseInsight( + entitled_p1_license_count=0, + entitled_p2_license_count=30, + entitled_total_license_count=30, + p1_licenses_utilized=0, + p2_licenses_utilized=35, + ) + + check = entra_conditional_access_policy_p2_license_utilization() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "P2 license entitlements (30) do not cover all risk-based Conditional Access users (35)." + ) + 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_p1_utilization_does_not_affect_p2_check(self): + """P1 CA usage is ignored by this check; only risk-based (P2) is evaluated.""" + 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_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization import ( + entra_conditional_access_policy_p2_license_utilization, + ) + from prowler.providers.m365.services.entra.entra_service import ( + PremiumLicenseInsight, + ) + + entra_client.premium_license_insight = PremiumLicenseInsight( + entitled_p1_license_count=0, + entitled_p2_license_count=10, + entitled_total_license_count=10, + p1_licenses_utilized=999, + p2_licenses_utilized=5, + ) + + check = entra_conditional_access_policy_p2_license_utilization() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "P2 license entitlements (10) cover all risk-based Conditional Access users (5)." + ) + + def test_zero_licenses_zero_users(self): + """PASS when both P2 license count and risk-based 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_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization import ( + entra_conditional_access_policy_p2_license_utilization, + ) + from prowler.providers.m365.services.entra.entra_service import ( + PremiumLicenseInsight, + ) + + entra_client.premium_license_insight = PremiumLicenseInsight() + + check = entra_conditional_access_policy_p2_license_utilization() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "P2 license entitlements (0) cover all risk-based Conditional Access users (0)." + ) + assert result[0].resource_name == "Premium License Insight" + assert result[0].resource_id == "azureADPremiumLicenseInsight" + assert result[0].location == "global"