feat(m365): add entra_app_registration_no_unused_privileged_permissions security check (#10080)

Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
Hugo Pereira Brito
2026-02-19 17:12:50 +01:00
committed by GitHub
parent 23e51158e0
commit 20b26bc7d0
11 changed files with 1292 additions and 7 deletions

View File

@@ -45,6 +45,7 @@ When using service principal authentication, add these **Application Permissions
- `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.
- `ThreatHunting.Read.All`: Required for Entra checks that use Defender XDR Advanced Hunting (e.g., unused privileged permissions detection). Also requires App Governance to be enabled in Microsoft Defender for Cloud Apps.
**External API Permissions:**

View File

@@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
- `entra_app_registration_no_unused_privileged_permissions` check for m365 provider [(#10080)](https://github.com/prowler-cloud/prowler/pull/10080)
- `defenderidentity_health_issues_no_open` check for M365 provider [(#10087)](https://github.com/prowler-cloud/prowler/pull/10087)
- `organization_verified_badge` check for GitHub provider [(#10033)](https://github.com/prowler-cloud/prowler/pull/10033)
- OpenStack provider `clouds_yaml_content` parameter for API integration [(#10003)](https://github.com/prowler-cloud/prowler/pull/10003)

View File

@@ -983,6 +983,7 @@
"Id": "5.1.5.1",
"Description": "Control when end users and group owners are allowed to grant consent to applications, and when they will be required to request administrator review and approval. Allowing users to grant apps access to data helps them acquire useful applications and be productive but can represent a risk in some situations if it's not monitored and controlled carefully.",
"Checks": [
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_restricts_user_consent_for_apps"
],
"Attributes": [

View File

@@ -1215,6 +1215,7 @@
"Id": "5.1.5.1",
"Description": "User consent to apps accessing company data on their behalf allows users to grant permissions to applications without administrator involvement. The recommended state is Do not allow user consent.",
"Checks": [
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_restricts_user_consent_for_apps"
],
"Attributes": [

View File

@@ -256,11 +256,12 @@
}
],
"Checks": [
"sharepoint_external_sharing_restricted",
"sharepoint_external_sharing_managed",
"sharepoint_guest_sharing_restricted",
"entra_admin_portals_access_restriction",
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_guest_users_access_restrictions",
"entra_admin_portals_access_restriction"
"sharepoint_external_sharing_managed",
"sharepoint_external_sharing_restricted",
"sharepoint_guest_sharing_restricted"
]
},
{
@@ -636,6 +637,7 @@
"entra_admin_users_mfa_enabled",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_admin_users_sign_in_frequency_enabled",
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_ensure_default_user_cannot_create_tenants",
"entra_policy_guest_invite_only_for_admin_roles"
]
@@ -767,8 +769,9 @@
}
],
"Checks": [
"entra_thirdparty_integrated_apps_not_allowed",
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_restricts_user_consent_for_apps",
"entra_thirdparty_integrated_apps_not_allowed",
"teams_external_domains_restricted",
"teams_external_users_cannot_start_conversations"
]
@@ -859,9 +862,10 @@
}
],
"Checks": [
"entra_policy_restricts_user_consent_for_apps",
"admincenter_users_admins_reduced_license_footprint",
"defender_malware_policy_comprehensive_attachments_filter_applied",
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_restricts_user_consent_for_apps",
"entra_thirdparty_integrated_apps_not_allowed",
"sharepoint_modern_authentication_required"
]

View File

@@ -712,6 +712,7 @@
"Id": "1.3.3",
"Description": "Ensure third party integrated applications are not allowed",
"Checks": [
"entra_app_registration_no_unused_privileged_permissions",
"entra_thirdparty_integrated_apps_not_allowed"
],
"Attributes": [
@@ -748,6 +749,7 @@
"Id": "1.3.5",
"Description": "Ensure user consent to apps accessing company data on their behalf is not allowed",
"Checks": [
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_restricts_user_consent_for_apps"
],
"Attributes": [

View File

@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_app_registration_no_unused_privileged_permissions",
"CheckTitle": "App registration has no unused privileged API permissions",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "App Registration",
"ResourceGroup": "IAM",
"Description": "OAuth app registrations with privileged API permissions (High privilege level) that are not being actively used. Usage status is determined by Microsoft Defender for Cloud Apps App Governance.",
"Risk": "Unused privileged permissions expand the attack surface. If a compromised app has dormant privileged permissions, attackers can exploit them for **privilege escalation**, **unauthorized access** to sensitive data, or **lateral movement** within the environment.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/defender-cloud-apps/app-governance-visibility-insights-overview",
"https://learn.microsoft.com/en-us/defender-xdr/advanced-hunting-oauthappinfo-table"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to Microsoft Defender XDR portal (https://security.microsoft.com)\n2. Go to Cloud apps > App governance > Overview\n3. Review the Applications inventory for apps with unused permissions\n4. For each flagged app, view details and navigate to the Permissions tab\n5. Remove unnecessary permissions via Microsoft Entra admin center",
"Terraform": ""
},
"Recommendation": {
"Text": "Apply the **principle of least privilege** by regularly reviewing and revoking unused privileged permissions from app registrations. Use Microsoft Defender for Cloud Apps App Governance to monitor permission usage.",
"Url": "https://hub.prowler.com/check/entra_app_registration_no_unused_privileged_permissions"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check requires Microsoft Defender for Cloud Apps with App Governance enabled and ThreatHunting.Read.All permission. If App Governance data is unavailable, the check fails due to missing visibility."
}

View File

@@ -0,0 +1,145 @@
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
class entra_app_registration_no_unused_privileged_permissions(Check):
"""
Ensure that app registrations do not have unused privileged API permissions.
This check evaluates OAuth applications registered in Microsoft Entra ID to identify
those with privileged API permissions (High privilege level or Control/Management Plane
classifications) that are assigned but not actively being used.
The check uses data from Microsoft Defender for Cloud Apps App Governance via
the OAuthAppInfo table in Defender XDR Advanced Hunting.
- PASS: The app has no unused privileged permissions.
- FAIL: The app has one or more unused privileged permissions that should be revoked.
It also fails when OAuth App Governance data is not available.
"""
# InUse field values from OAuthAppInfo:
# - "true" / "1" / "True" = permission is actively used
# - "false" / "0" / "False" = permission is NOT used (this triggers FAIL)
# - "Not supported" = Microsoft cannot determine usage
# - "" (empty) = No tracking data available
# Note: Microsoft is changing from numeric (1/0) to textual (True/False) on Feb 25, 2026
_UNUSED_STATUSES = {"false", "0", "notinuse", "not in use"}
_PRIVILEGED_PLANE_LABELS = ("control plane", "management plane")
def execute(self) -> list[CheckReportM365]:
"""
Execute the unused privileged permissions check for app registrations.
Iterates over OAuth applications retrieved from the Entra client and generates
reports indicating whether each app has unused privileged permissions.
Returns:
list[CheckReportM365]: A list of reports with the result of the check for each app.
"""
findings = []
# If OAuth app data is None, the API call failed (missing permissions or App Governance not enabled)
if entra_client.oauth_apps is None:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="OAuth Applications",
resource_id="oauthApps",
)
report.status = "FAIL"
report.status_extended = (
"OAuth App Governance data is unavailable. "
"Enable App Governance in Microsoft Defender for Cloud Apps and "
"grant ThreatHunting.Read.All to evaluate unused privileged permissions."
)
findings.append(report)
return findings
# If OAuth apps is empty dict, no apps are registered - this is compliant
if not entra_client.oauth_apps:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="OAuth Applications",
resource_id="oauthApps",
)
report.status = "PASS"
report.status_extended = (
"No OAuth applications are registered in the tenant."
)
findings.append(report)
return findings
# Check each OAuth app for unused privileged permissions
for app_id, app in entra_client.oauth_apps.items():
report = CheckReportM365(
metadata=self.metadata(),
resource=app,
resource_name=app.name,
resource_id=app_id,
)
# Find unused privileged permissions
# A permission is considered privileged if it has:
# - PrivilegeLevel == "High"
# Or if it's part of Control Plane / Management Plane (typically High privilege)
unused_privileged_permissions = []
for permission in app.permissions:
# Check if the permission is privileged
is_privileged = self._is_privileged_permission(permission)
# Check if the permission is unused
normalized_usage = self._normalize(permission.usage_status)
is_unused = normalized_usage in self._UNUSED_STATUSES
if is_privileged and is_unused:
unused_privileged_permissions.append(permission.name)
if unused_privileged_permissions:
# The app has unused privileged permissions
report.status = "FAIL"
# Truncate list to first 5 permissions for readability
total_count = len(unused_privileged_permissions)
if total_count > 5:
displayed = unused_privileged_permissions[:5]
permissions_list = ", ".join(displayed)
remaining = total_count - 5
permissions_list += f" (and {remaining} more)"
else:
permissions_list = ", ".join(unused_privileged_permissions)
report.status_extended = (
f"App registration {app.name} has {total_count} "
f"unused privileged permission(s): {permissions_list}."
)
else:
# The app has no unused privileged permissions
report.status = "PASS"
report.status_extended = (
f"App registration {app.name} has no unused privileged permissions."
)
findings.append(report)
return findings
@classmethod
def _is_privileged_permission(cls, permission) -> bool:
privilege_level = cls._normalize(permission.privilege_level)
permission_type = cls._normalize(permission.permission_type)
classification = cls._normalize(getattr(permission, "classification", ""))
if privilege_level == "high":
return True
return any(
label in permission_type or label in classification
for label in cls._PRIVILEGED_PLANE_LABELS
)
@staticmethod
def _normalize(value: str) -> str:
return (
value.lower().replace("_", " ").replace("-", " ").strip() if value else ""
)

View File

@@ -1,9 +1,13 @@
import asyncio
import json
from asyncio import gather
from enum import Enum
from typing import List, Optional
from typing import Dict, List, Optional
from uuid import UUID
from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import (
RunHuntingQueryPostRequestBody,
)
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
@@ -12,7 +16,33 @@ from prowler.providers.m365.m365_provider import M365Provider
class Entra(M365Service):
"""
Microsoft Entra ID service class.
This class provides methods to retrieve and manage Microsoft Entra ID
security policies and configurations, including authorization policies,
conditional access policies, admin consent policies, groups, organizations,
users, and OAuth application data from Defender XDR.
Attributes:
tenant_domain (str): The tenant domain.
authorization_policy (AuthorizationPolicy): The authorization policy.
conditional_access_policies (dict): Dictionary of conditional access policies.
admin_consent_policy (AdminConsentPolicy): The admin consent policy.
groups (list): List of groups.
organizations (list): List of organizations.
users (dict): Dictionary of users.
user_accounts_status (dict): Dictionary of user account statuses.
oauth_apps (dict): Dictionary of OAuth applications from Defender XDR.
"""
def __init__(self, provider: M365Provider):
"""
Initialize the Entra service client.
Args:
provider: The M365Provider instance for authentication and configuration.
"""
super().__init__(provider)
if self.powershell:
@@ -47,6 +77,7 @@ class Entra(M365Service):
self._get_groups(),
self._get_organization(),
self._get_users(),
self._get_oauth_apps(),
)
)
@@ -56,6 +87,7 @@ class Entra(M365Service):
self.groups = attributes[3]
self.organizations = attributes[4]
self.users = attributes[5]
self.oauth_apps: Optional[Dict[str, OAuthApp]] = attributes[6]
self.user_accounts_status = {}
if created_loop:
@@ -461,6 +493,122 @@ class Entra(M365Service):
return registration_details
async def _get_oauth_apps(self) -> Optional[Dict[str, "OAuthApp"]]:
"""
Retrieve OAuth applications from Defender XDR using Advanced Hunting.
This method queries the OAuthAppInfo table to get information about
OAuth applications registered in the tenant, including their permissions
and usage status.
Returns:
Optional[Dict[str, OAuthApp]]: Dictionary of OAuth applications keyed by app ID,
or None if the API call failed (missing permissions or App Governance not enabled).
"""
logger.info("Entra - Getting OAuth apps from Defender XDR...")
oauth_apps: Optional[Dict[str, OAuthApp]] = {}
try:
# Query the OAuthAppInfo table using Advanced Hunting
# The query gets apps with their permissions including usage status
query = """
OAuthAppInfo
| project OAuthAppId, AppName, AppStatus, PrivilegeLevel, Permissions,
ServicePrincipalId, IsAdminConsented, LastUsedTime, AppOrigin
"""
request_body = RunHuntingQueryPostRequestBody(query=query)
result = await self.client.security.microsoft_graph_security_run_hunting_query.post(
request_body
)
if result and result.results:
for row in result.results:
row_data = row.additional_data
raw_app_id = row_data.get("OAuthAppId", "")
# Convert to string in case API returns non-string type
app_id = str(raw_app_id) if raw_app_id else ""
if not app_id:
continue
# Parse the permissions array
# Permissions can be a list of JSON strings or a list of dicts
permissions = []
raw_permissions = row_data.get("Permissions", [])
if raw_permissions:
for perm in raw_permissions:
# Parse JSON string if needed
if isinstance(perm, str):
try:
perm = json.loads(perm)
except json.JSONDecodeError:
continue
if isinstance(perm, dict):
permissions.append(
OAuthAppPermission(
name=str(perm.get("PermissionValue", "")),
target_app_id=str(perm.get("TargetAppId", "")),
target_app_name=str(
perm.get("TargetAppDisplayName", "")
),
permission_type=str(
perm.get("PermissionType", "")
),
classification=str(
perm.get(
"Classification",
perm.get(
"PermissionClassification", ""
),
)
),
privilege_level=str(
perm.get("PrivilegeLevel", "")
),
usage_status=str(perm.get("InUse", "")),
)
)
# Convert values to strings to handle API returning non-string types
raw_service_principal_id = row_data.get("ServicePrincipalId", "")
service_principal_id = (
str(raw_service_principal_id)
if raw_service_principal_id
else ""
)
raw_last_used_time = row_data.get("LastUsedTime")
last_used_time = (
str(raw_last_used_time)
if raw_last_used_time is not None
else None
)
oauth_apps[app_id] = OAuthApp(
id=app_id,
name=str(row_data.get("AppName", "")),
status=str(row_data.get("AppStatus", "")),
privilege_level=str(row_data.get("PrivilegeLevel", "")),
permissions=permissions,
service_principal_id=service_principal_id,
is_admin_consented=bool(
row_data.get("IsAdminConsented", False)
),
last_used_time=last_used_time,
app_origin=str(row_data.get("AppOrigin", "")),
)
except Exception as error:
# Log the error and return None to indicate API failure
# This API requires ThreatHunting.Read.All permission and App Governance to be enabled
logger.warning(
f"Entra - Could not retrieve OAuth apps from Defender XDR. "
f"This requires ThreatHunting.Read.All permission and App Governance enabled. "
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
return oauth_apps
class ConditionalAccessPolicyState(Enum):
ENABLED = "enabled"
@@ -651,3 +799,53 @@ class AuthPolicyRoles(Enum):
USER = UUID("a0b1b346-4d3e-4e8b-98f8-753987be4970")
GUEST_USER = UUID("10dae51f-b6af-4016-8d66-8c2a99b929b3")
GUEST_USER_ACCESS_RESTRICTED = UUID("2af84b1e-32c8-42b7-82bc-daa82404023b")
class OAuthAppPermission(BaseModel):
"""
Model for OAuth application permission.
Attributes:
name: The permission name.
target_app_id: The target application ID that provides this permission.
target_app_name: The target application display name.
permission_type: The type of permission (Application or Delegated).
classification: Optional plane classification (e.g. Control Plane, Management Plane).
privilege_level: The privilege level (High, Medium, Low).
usage_status: The usage status (InUse or NotInUse).
"""
name: str
target_app_id: str = ""
target_app_name: str = ""
permission_type: str = ""
classification: str = ""
privilege_level: str = ""
usage_status: str = ""
class OAuthApp(BaseModel):
"""
Model for OAuth application from Defender XDR.
Attributes:
id: The application ID.
name: The application display name.
status: The application status (Enabled, Disabled, etc.).
privilege_level: The overall privilege level of the app.
permissions: List of permissions assigned to the app.
service_principal_id: The service principal ID.
is_admin_consented: Whether the app has admin consent.
last_used_time: When the app was last used.
app_origin: Whether the app is internal or external.
"""
id: str
name: str
status: str = ""
privilege_level: str = ""
permissions: List[OAuthAppPermission] = []
service_principal_id: str = ""
is_admin_consented: bool = False
last_used_time: Optional[str] = None
app_origin: str = ""

View File

@@ -0,0 +1,895 @@
from unittest import mock
from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import (
OAuthApp,
OAuthAppPermission,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_entra_app_registration_no_unused_privileged_permissions:
def test_no_oauth_apps(self):
"""No OAuth apps registered in tenant (empty dict): expected PASS."""
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "No OAuth applications are registered in the tenant."
)
assert result[0].resource == {}
assert result[0].resource_name == "OAuth Applications"
assert result[0].resource_id == "oauthApps"
def test_no_oauth_apps_none(self):
"""OAuth apps is None (App Governance not enabled): expected FAIL."""
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = None
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "OAuth App Governance data is unavailable. Enable App Governance in Microsoft Defender for Cloud Apps and grant ThreatHunting.Read.All to evaluate unused privileged permissions."
)
assert result[0].resource == {}
assert result[0].resource_name == "OAuth Applications"
assert result[0].resource_id == "oauthApps"
def test_app_no_permissions(self):
"""App with no permissions: expected PASS."""
app_id = str(uuid4())
app_name = "Test App No Permissions"
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="Low",
permissions=[],
service_principal_id=str(uuid4()),
is_admin_consented=False,
last_used_time=None,
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"App registration {app_name} has no unused privileged permissions."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_all_permissions_in_use(self):
"""App with all privileged permissions in use: expected PASS."""
app_id = str(uuid4())
app_name = "Test App All In Use"
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="InUse",
),
OAuthAppPermission(
name="User.Read.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="InUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"App registration {app_name} has no unused privileged permissions."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_low_privilege_unused(self):
"""App with unused low privilege permissions (not high): expected PASS."""
app_id = str(uuid4())
app_name = "Test App Low Privilege Unused"
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="Low",
permissions=[
OAuthAppPermission(
name="User.Read",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Delegated",
privilege_level="Low",
usage_status="NotInUse",
),
OAuthAppPermission(
name="openid",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Delegated",
privilege_level="Low",
usage_status="NotInUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=False,
last_used_time=None,
app_origin="External",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"App registration {app_name} has no unused privileged permissions."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_medium_privilege_unused(self):
"""App with unused medium privilege permissions (not high): expected PASS."""
app_id = str(uuid4())
app_name = "Test App Medium Privilege Unused"
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="Medium",
permissions=[
OAuthAppPermission(
name="Files.Read",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Delegated",
privilege_level="Medium",
usage_status="NotInUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=False,
last_used_time=None,
app_origin="External",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"App registration {app_name} has no unused privileged permissions."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_one_unused_high_privilege_permission(self):
"""App with one unused high privilege permission: expected FAIL."""
app_id = str(uuid4())
app_name = "Test App One Unused High"
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="User.Read",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Delegated",
privilege_level="Low",
usage_status="InUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"App registration {app_name} has 1 unused privileged permission(s): Mail.ReadWrite.All."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_multiple_unused_high_privilege_permissions(self):
"""App with multiple unused high privilege permissions: expected FAIL."""
app_id = str(uuid4())
app_name = "Test App Multiple Unused High"
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="Directory.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="User.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="External",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"App registration {app_name} has 3 unused privileged permission(s): Mail.ReadWrite.All, Directory.ReadWrite.All, User.ReadWrite.All."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_more_than_five_unused_high_privilege_permissions(self):
"""App with more than 5 unused high privilege permissions: expected FAIL with truncated list."""
app_id = str(uuid4())
app_name = "Test App Many Unused High"
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="Directory.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="User.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="Group.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="Sites.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="RoleManagement.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="Application.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="External",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"App registration {app_name} has 7 unused privileged permission(s): Mail.ReadWrite.All, Directory.ReadWrite.All, User.ReadWrite.All, Group.ReadWrite.All, Sites.ReadWrite.All (and 2 more)."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_unused_with_not_in_use_status(self):
"""App with unused permission using 'not_in_use' status variant: expected FAIL."""
app_id = str(uuid4())
app_name = "Test App NotInUse Variant"
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="not_in_use",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time=None,
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"App registration {app_name} has 1 unused privileged permission(s): Mail.ReadWrite.All."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_multiple_apps_mixed_results(self):
"""Multiple apps with mixed results: one PASS and one FAIL."""
app_id_pass = str(uuid4())
app_name_pass = "Test App Pass"
app_id_fail = str(uuid4())
app_name_fail = "Test App Fail"
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id_pass: OAuthApp(
id=app_id_pass,
name=app_name_pass,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="InUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="Internal",
),
app_id_fail: OAuthApp(
id=app_id_fail,
name=app_name_fail,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Directory.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="External",
),
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 2
# Find results by app ID
result_pass = next(r for r in result if r.resource_id == app_id_pass)
result_fail = next(r for r in result if r.resource_id == app_id_fail)
assert result_pass.status == "PASS"
assert (
result_pass.status_extended
== f"App registration {app_name_pass} has no unused privileged permissions."
)
assert result_pass.resource_name == app_name_pass
assert result_fail.status == "FAIL"
assert (
result_fail.status_extended
== f"App registration {app_name_fail} has 1 unused privileged permission(s): Directory.ReadWrite.All."
)
assert result_fail.resource_name == app_name_fail
def test_app_mixed_privilege_levels_unused(self):
"""App with mixed privilege levels (High and Low) unused: only High triggers FAIL."""
app_id = str(uuid4())
app_name = "Test App Mixed Privileges"
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="User.Read",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Delegated",
privilege_level="Low",
usage_status="NotInUse",
),
OAuthAppPermission(
name="Files.Read",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Delegated",
privilege_level="Medium",
usage_status="NotInUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
# Only the High privilege permission should be reported
assert (
result[0].status_extended
== f"App registration {app_name} has 1 unused privileged permission(s): Mail.ReadWrite.All."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_high_privilege_in_use_and_unused(self):
"""App with some high privilege permissions in use and some unused: expected FAIL."""
app_id = str(uuid4())
app_name = "Test App Partial Usage"
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="InUse",
),
OAuthAppPermission(
name="Directory.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="User.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="InUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"App registration {app_name} has 1 unused privileged permission(s): Directory.ReadWrite.All."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_without_name_uses_id(self):
"""App without a name should use app_id as resource_name."""
app_id = str(uuid4())
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.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name="",
status="Enabled",
privilege_level="Low",
permissions=[],
service_principal_id=str(uuid4()),
is_admin_consented=False,
last_used_time=None,
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_name == ""
assert result[0].resource_id == app_id