feat(m365): add entra_service_prinicipal_privileged_role_no_owners_check (#11189)

Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
Ashishraymajhi
2026-06-09 11:29:03 +02:00
committed by GitHub
co-authored by Daniel Barranquero
parent 62955dd16b
commit 7e60e8f8da
6 changed files with 471 additions and 1 deletions
+1
View File
@@ -15,6 +15,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475)
- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070)
### 🐞 Fixed
@@ -1197,6 +1197,10 @@ OAuthAppInfo
service_principals_by_app_id = {
sp.app_id: sp for sp in service_principals.values() if sp.app_id
}
# Remember each SP's parent application object ID so the owner
# lookup below can address it directly without re-walking
# /applications.
application_object_id_by_sp_id: Dict[str, str] = {}
app_response = await self.client.applications.get()
while app_response:
for app in getattr(app_response, "value", []) or []:
@@ -1207,6 +1211,10 @@ OAuthAppInfo
if target_sp is None:
continue
app_object_id = getattr(app, "id", None)
if app_object_id:
application_object_id_by_sp_id[target_sp.id] = app_object_id
for cred in getattr(app, "password_credentials", []) or []:
target_sp.password_credentials.append(
PasswordCredential(
@@ -1257,6 +1265,49 @@ OAuthAppInfo
next_link
).get()
# Resolve owners only for service principals that hold a permanent
# Tier 0 directory role. Owner ownership of the SP object or its
# parent app registration is a credential-rotation escalation path
# outside PIM and Conditional Access; fetching owners for every
# consented SP would multiply Graph traffic for no benefit.
for sp in service_principals.values():
if not sp.directory_role_template_ids:
continue
try:
sp_owners_response = (
await self.client.service_principals.by_service_principal_id(
sp.id
).owners.get()
)
sp.sp_owner_ids = [
getattr(owner, "id", None)
for owner in (getattr(sp_owners_response, "value", []) or [])
if getattr(owner, "id", None)
]
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
app_object_id = application_object_id_by_sp_id.get(sp.id)
if not app_object_id:
continue
try:
app_owners_response = (
await self.client.applications.by_application_id(
app_object_id
).owners.get()
)
sp.app_owner_ids = [
getattr(owner, "id", None)
for owner in (getattr(app_owners_response, "value", []) or [])
if getattr(owner, "id", None)
]
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -1436,7 +1487,7 @@ class PlatformConditions(BaseModel):
@validator("include_platforms", "exclude_platforms", pre=True)
@classmethod
def normalize_platforms(cls, values):
def normalize_platforms(cls, values): # noqa: vulture
if not values:
return []
@@ -1832,6 +1883,12 @@ class ServicePrincipal(BaseModel):
key_credentials: List of key credentials (certificates).
directory_role_template_ids: List of directory role template IDs permanently
assigned to this service principal.
sp_owner_ids: Principal IDs that own the service principal object.
Populated only for service principals that hold a permanent Tier 0
directory role assignment, to keep Graph traffic bounded.
app_owner_ids: Principal IDs that own the parent app registration.
Populated only for service principals that hold a permanent Tier 0
directory role assignment.
"""
id: str
@@ -1841,6 +1898,8 @@ class ServicePrincipal(BaseModel):
password_credentials: List[PasswordCredential] = []
key_credentials: List[KeyCredential] = []
directory_role_template_ids: List[str] = []
sp_owner_ids: List[str] = []
app_owner_ids: List[str] = []
class AppRegistration(BaseModel):
@@ -0,0 +1,40 @@
{
"Provider": "m365",
"CheckID": "entra_service_principal_privileged_role_no_owners",
"CheckTitle": "Service principals with privileged Entra directory roles must have no owners",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Microsoft Entra **service principals** holding permanent **Control Plane (Tier 0)** directory roles (such as **Global Administrator** or **Privileged Role Administrator**) are evaluated for the presence of **owners** on either the service principal itself or its parent **app registration**.",
"Risk": "An **owner** of a service principal or its parent app registration can **rotate credentials** and sign in as the service principal, inheriting its **Tier 0** role outside **PIM** and **Conditional Access** controls. This is a documented privilege escalation path impacting **confidentiality**, **integrity**, and **availability** of the tenant's control plane.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list-owners?view=graph-rest-1.0",
"https://learn.microsoft.com/en-us/graph/api/application-list-owners?view=graph-rest-1.0",
"https://learn.microsoft.com/en-us/graph/api/rbacapplication-list-roleassignments?view=graph-rest-1.0"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Identity > Applications > Enterprise applications > select the service principal\n3. Under Owners, remove all owners\n4. Repeat for the parent App Registration under Identity > Applications > App registrations\n5. Use PIM eligible assignments instead of permanent role assignments where possible",
"Terraform": ""
},
"Recommendation": {
"Text": "Remove all owners from service principals that hold privileged Entra directory roles. Manage privileged service principals exclusively via PIM-eligible role assignments and break-glass controls. Ensure no human account can silently inherit control-plane privileges through ownership.",
"Url": "https://hub.prowler.com/check/entra_service_principal_privileged_role_no_owners"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [
"entra_service_principal_no_secrets_for_permanent_tier0_roles"
],
"Notes": "Only service principals with permanent Tier 0 directory role assignments are evaluated. Microsoft first-party service principals and multi-tenant ISV apps consented from other publishers are excluded by the service layer."
}
@@ -0,0 +1,71 @@
"""Check for service principals with privileged roles that have owners."""
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_service_principal_privileged_role_no_owners(Check):
"""Service principal with a permanent Tier 0 directory role has no owners.
Owners of a service principal or its parent app registration can rotate
credentials and sign in as the service principal, inheriting its privileged
directory role outside PIM approval flows and Conditional Access policies
targeting user accounts.
- PASS: The service principal does not hold a permanent Tier 0 directory
role, or it does but has zero owners on both the service principal and
its parent app registration.
- FAIL: The service principal holds a permanent Tier 0 directory role and
has at least one owner on either the service principal or its parent
app registration.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the privileged service principal owner check.
Returns:
A list of reports, one per service principal owned by the audited
tenant.
"""
findings = []
for sp in entra_client.service_principals.values():
report = CheckReportM365(
metadata=self.metadata(),
resource=sp,
resource_name=sp.name,
resource_id=sp.id,
)
if not sp.directory_role_template_ids:
report.status = "PASS"
report.status_extended = (
f"Service principal '{sp.name}' has no permanent Tier 0 "
f"directory role assignments."
)
findings.append(report)
continue
unique_owners = set(sp.sp_owner_ids) | set(sp.app_owner_ids)
tier0_role_count = len(sp.directory_role_template_ids)
if unique_owners:
report.status = "FAIL"
report.status_extended = (
f"Service principal '{sp.name}' holds {tier0_role_count} "
f"permanent Tier 0 directory role(s) and has "
f"{len(unique_owners)} owner(s) "
f"({len(sp.sp_owner_ids)} on the service principal, "
f"{len(sp.app_owner_ids)} on the parent app registration)."
)
else:
report.status = "PASS"
report.status_extended = (
f"Service principal '{sp.name}' holds {tier0_role_count} "
f"permanent Tier 0 directory role(s) and has no owners on "
f"either the service principal or its parent app registration."
)
findings.append(report)
return findings
@@ -0,0 +1,299 @@
from unittest import mock
from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import ServicePrincipal
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
GLOBAL_ADMIN_ROLE = "62e90394-69f5-4237-9190-012177145e10"
PRIV_ROLE_ADMIN = "e8611ab8-c189-46e8-94e1-60213ab1f814"
class Test_entra_service_principal_privileged_role_no_owners:
"""Tests for the entra_service_principal_privileged_role_no_owners check."""
def test_no_service_principals(self):
"""No service principals configured: expected no findings."""
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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
entra_service_principal_privileged_role_no_owners,
)
entra_client.service_principals = {}
check = entra_service_principal_privileged_role_no_owners()
result = check.execute()
assert len(result) == 0
def test_service_principal_no_tier0_roles(self):
"""Service principal without Tier 0 roles: expected PASS."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id = str(uuid4())
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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
entra_service_principal_privileged_role_no_owners,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="NonPrivilegedApp",
app_id=str(uuid4()),
directory_role_template_ids=[],
sp_owner_ids=[],
app_owner_ids=[],
)
}
check = entra_service_principal_privileged_role_no_owners()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == sp_id
assert result[0].resource_name == "NonPrivilegedApp"
assert (
"no permanent Tier 0 directory role assignments"
in result[0].status_extended
)
def test_service_principal_tier0_no_owners(self):
"""Privileged SP with no owners on SP or app: expected PASS."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id = str(uuid4())
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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
entra_service_principal_privileged_role_no_owners,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="SecureApp",
app_id=str(uuid4()),
directory_role_template_ids=[GLOBAL_ADMIN_ROLE],
sp_owner_ids=[],
app_owner_ids=[],
)
}
check = entra_service_principal_privileged_role_no_owners()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == sp_id
assert result[0].resource_name == "SecureApp"
assert "no owners" in result[0].status_extended
def test_service_principal_tier0_with_sp_owners(self):
"""Privileged SP with owners on SP only: expected FAIL."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id = str(uuid4())
owner_id = str(uuid4())
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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
entra_service_principal_privileged_role_no_owners,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="RiskyApp",
app_id=str(uuid4()),
directory_role_template_ids=[GLOBAL_ADMIN_ROLE],
sp_owner_ids=[owner_id],
app_owner_ids=[],
)
}
check = entra_service_principal_privileged_role_no_owners()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_id == sp_id
assert "1 owner(s)" in result[0].status_extended
assert "1 on the service principal" in result[0].status_extended
assert "0 on the parent app registration" in result[0].status_extended
def test_service_principal_tier0_with_app_owners(self):
"""Privileged SP with owners on parent app only: expected FAIL."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id = str(uuid4())
app_owner_id = str(uuid4())
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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
entra_service_principal_privileged_role_no_owners,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="AppRegOwnerRisk",
app_id=str(uuid4()),
directory_role_template_ids=[GLOBAL_ADMIN_ROLE],
sp_owner_ids=[],
app_owner_ids=[app_owner_id],
)
}
check = entra_service_principal_privileged_role_no_owners()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "1 owner(s)" in result[0].status_extended
assert "0 on the service principal" in result[0].status_extended
assert "1 on the parent app registration" in result[0].status_extended
def test_service_principal_tier0_with_both_owners(self):
"""Privileged SP with distinct owners on both SP and app: expected FAIL."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id = str(uuid4())
sp_owner_id = str(uuid4())
app_owner_id = str(uuid4())
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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
entra_service_principal_privileged_role_no_owners,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="HighRiskApp",
app_id=str(uuid4()),
directory_role_template_ids=[GLOBAL_ADMIN_ROLE, PRIV_ROLE_ADMIN],
sp_owner_ids=[sp_owner_id],
app_owner_ids=[app_owner_id],
)
}
check = entra_service_principal_privileged_role_no_owners()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "2 permanent Tier 0 directory role(s)" in result[0].status_extended
assert "2 owner(s)" in result[0].status_extended
def test_service_principal_tier0_same_owner_on_sp_and_app(self):
"""Same principal owns both SP and parent app: owner count deduplicated."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id = str(uuid4())
shared_owner_id = str(uuid4())
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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
entra_service_principal_privileged_role_no_owners,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="DualOwnedApp",
app_id=str(uuid4()),
directory_role_template_ids=[GLOBAL_ADMIN_ROLE],
sp_owner_ids=[shared_owner_id],
app_owner_ids=[shared_owner_id],
)
}
check = entra_service_principal_privileged_role_no_owners()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "1 owner(s)" in result[0].status_extended
assert "1 on the service principal" in result[0].status_extended
assert "1 on the parent app registration" in result[0].status_extended