feat(m365): add entra_service_principal_no_secrets_for_permanent_tier0_roles security check (#10788)

Co-authored-by: Hugo P.Brito <hugopbrito@Mac.home>
This commit is contained in:
Hugo Pereira Brito
2026-05-12 10:45:32 +01:00
committed by GitHub
parent 6dfa135755
commit 1b99550572
8 changed files with 951 additions and 2 deletions
+2 -1
View File
@@ -6,7 +6,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
- `iam_user_access_not_stale_to_sagemaker` check for aws provider with configurable `max_unused_sagemaker_access_days` (default 90) [(#11000)](https://github.com/prowler-cloud/prowler/pull/11000)
- `entra_service_principal_no_secrets_for_permanent_tier0_roles` check for M365 provider [(#10788)](https://github.com/prowler-cloud/prowler/pull/10788)
- `iam_user_access_not_stale_to_sagemaker` check for AWS provider with configurable `max_unused_sagemaker_access_days` (default 90) [(#11000)](https://github.com/prowler-cloud/prowler/pull/11000)
- `cloudtrail_bedrock_logging_enabled` check for AWS provider [(#10858)](https://github.com/prowler-cloud/prowler/pull/10858)
---
@@ -261,6 +261,7 @@
"entra_legacy_authentication_blocked",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
"entra_service_principal_no_secrets_for_permanent_tier0_roles",
"entra_users_mfa_enabled",
"exchange_organization_modern_authentication_enabled",
"exchange_transport_config_smtp_auth_disabled",
@@ -283,6 +284,7 @@
"entra_admin_portals_access_restriction",
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_guest_users_access_restrictions",
"entra_service_principal_no_secrets_for_permanent_tier0_roles",
"sharepoint_external_sharing_managed",
"sharepoint_external_sharing_restricted",
"sharepoint_guest_sharing_restricted"
@@ -676,7 +678,8 @@
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_ensure_default_user_cannot_create_tenants",
"entra_policy_guest_invite_only_for_admin_roles",
"entra_seamless_sso_disabled"
"entra_seamless_sso_disabled",
"entra_service_principal_no_secrets_for_permanent_tier0_roles"
]
},
{
@@ -733,6 +736,7 @@
"entra_identity_protection_sign_in_risk_enabled",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
"entra_service_principal_no_secrets_for_permanent_tier0_roles",
"entra_users_mfa_enabled"
]
},
@@ -1,6 +1,7 @@
import asyncio
import json
from asyncio import gather
from datetime import datetime, timezone
from enum import Enum
from typing import Dict, List, Optional
from uuid import UUID
@@ -38,6 +39,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.
service_principals (dict): Dictionary of service principals with credentials and role assignments.
"""
def __init__(self, provider: M365Provider):
@@ -73,6 +75,7 @@ class Entra(M365Service):
)
self.tenant_domain = provider.identity.tenant_domain
self.tenant_id = getattr(provider.identity, "tenant_id", None)
attributes = loop.run_until_complete(
gather(
self._get_authorization_policy(),
@@ -85,6 +88,7 @@ class Entra(M365Service):
self._get_oauth_apps(),
self._get_directory_sync_settings(),
self._get_authentication_method_configurations(),
self._get_service_principals(),
)
)
@@ -100,6 +104,7 @@ class Entra(M365Service):
self.authentication_method_configurations: Dict[
str, AuthenticationMethodConfiguration
] = attributes[9]
self.service_principals: Dict[str, "ServicePrincipal"] = attributes[10]
self.user_accounts_status = {}
if created_loop:
@@ -1090,6 +1095,154 @@ OAuthAppInfo
)
return authentication_method_configurations
async def _get_service_principals(self):
"""Retrieve service principals owned by the audited tenant.
Fetches all service principals from Microsoft Graph and keeps only the
ones whose ``appOwnerOrganizationId`` matches the audited tenant. Skips
Microsoft first-party service principals and multi-tenant ISV apps
consented from other publishers: their credentials live in the
publisher's tenant, not this one, so they are out of scope for any
check that evaluates secret hygiene or role assignments managed by the
customer.
Returns:
Dict[str, ServicePrincipal]: Customer-owned service principals
keyed by service principal ID.
"""
logger.info("Entra - Getting service principals...")
service_principals = {}
tenant_id_normalized = str(self.tenant_id).lower() if self.tenant_id else None
try:
sp_response = await self.client.service_principals.get()
# Build a map of service principal IDs to their data
while sp_response:
for sp in getattr(sp_response, "value", []) or []:
raw_owner = getattr(sp, "app_owner_organization_id", None)
app_owner_org_id = str(raw_owner).lower() if raw_owner else None
if (
tenant_id_normalized
and app_owner_org_id != tenant_id_normalized
):
# Skip Microsoft first-party SPs and consented
# multi-tenant ISV apps; the customer cannot manage
# their credentials.
continue
password_credentials = []
for cred in getattr(sp, "password_credentials", []) or []:
password_credentials.append(
PasswordCredential(
key_id=str(getattr(cred, "key_id", "")),
display_name=getattr(cred, "display_name", None),
end_date_time=getattr(cred, "end_date_time", None),
)
)
key_credentials = []
for cred in getattr(sp, "key_credentials", []) or []:
key_credentials.append(
KeyCredential(
key_id=str(getattr(cred, "key_id", "")),
display_name=getattr(cred, "display_name", None),
)
)
service_principals[sp.id] = ServicePrincipal(
id=sp.id,
name=getattr(sp, "display_name", "") or "",
app_id=getattr(sp, "app_id", "") or "",
app_owner_organization_id=app_owner_org_id,
password_credentials=password_credentials,
key_credentials=key_credentials,
)
next_link = getattr(sp_response, "odata_next_link", None)
if not next_link:
break
sp_response = await self.client.service_principals.with_url(
next_link
).get()
# Fold in credentials registered on the parent Application objects.
# Microsoft Graph stores secrets and certificates added through
# "Certificates & secrets" on /applications, not on the service
# principal itself, so /servicePrincipals.passwordCredentials is
# almost always empty for normal app registrations. Joining via
# appId is required for the check to see those credentials.
#
# Index service principals by app_id once so the join below is
# O(N+M) instead of scanning all SPs for every Application page.
service_principals_by_app_id = {
sp.app_id: sp for sp in service_principals.values() if sp.app_id
}
app_response = await self.client.applications.get()
while app_response:
for app in getattr(app_response, "value", []) or []:
app_id = getattr(app, "app_id", None)
if not app_id:
continue
target_sp = service_principals_by_app_id.get(app_id)
if target_sp is None:
continue
for cred in getattr(app, "password_credentials", []) or []:
target_sp.password_credentials.append(
PasswordCredential(
key_id=str(getattr(cred, "key_id", "")),
display_name=getattr(cred, "display_name", None),
end_date_time=getattr(cred, "end_date_time", None),
)
)
for cred in getattr(app, "key_credentials", []) or []:
target_sp.key_credentials.append(
KeyCredential(
key_id=str(getattr(cred, "key_id", "")),
display_name=getattr(cred, "display_name", None),
)
)
next_link = getattr(app_response, "odata_next_link", None)
if not next_link:
break
app_response = await self.client.applications.with_url(next_link).get()
# Identify permanent Tier 0 directory role assignments via the unified
# role management endpoint. ``directoryRoles/{id}/members`` mixes
# permanent direct assignments with PIM-activated temporary ones, so
# using it would mark just-in-time elevations as "permanent" and emit
# false positives. ``roleManagement/directory/roleAssignments``
# exposes only the durable, statically-assigned principals, which is
# exactly what the Tier 0 check needs.
role_assignments_response = (
await self.client.role_management.directory.role_assignments.get()
)
while role_assignments_response:
for assignment in getattr(role_assignments_response, "value", []) or []:
principal_id = getattr(assignment, "principal_id", None)
role_definition_id = getattr(assignment, "role_definition_id", None)
if (
principal_id in service_principals
and role_definition_id in TIER_0_ROLE_TEMPLATE_IDS
):
service_principals[
principal_id
].directory_role_template_ids.append(role_definition_id)
next_link = getattr(role_assignments_response, "odata_next_link", None)
if not next_link:
break
role_assignments_response = await self.client.role_management.directory.role_assignments.with_url(
next_link
).get()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return service_principals
class ConditionalAccessPolicyState(Enum):
ENABLED = "enabled"
@@ -1521,3 +1674,94 @@ class OAuthApp(BaseModel):
is_admin_consented: bool = False
last_used_time: Optional[str] = None
app_origin: str = ""
class PasswordCredential(BaseModel):
"""Model representing a password credential (client secret) on a service principal.
Attributes:
key_id: The unique identifier of the credential.
display_name: The optional display name of the credential.
end_date_time: The expiration time of the credential. ``None`` indicates
the secret has no recorded expiry and is treated as active.
"""
key_id: str
display_name: Optional[str] = None
end_date_time: Optional[datetime] = None
def is_active(self, now: Optional[datetime] = None) -> bool:
"""Return ``True`` when the credential has not expired.
A credential with no ``end_date_time`` is assumed to be active, matching
the behavior of the Microsoft Graph API when the field is omitted.
"""
if self.end_date_time is None:
return True
reference = now or datetime.now(timezone.utc)
return self.end_date_time > reference
class KeyCredential(BaseModel):
"""Model representing a key credential (certificate) on a service principal.
Attributes:
key_id: The unique identifier of the credential.
display_name: The optional display name of the credential.
"""
key_id: str
display_name: Optional[str] = None
# Control Plane (Tier 0) role template IDs.
#
# Roles included grant tenant-wide control over identity, authentication, or the
# directory itself, so a credential compromise on any of them is equivalent to a
# tenant takeover. References:
# https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/privileged-roles-permissions
# https://learn.microsoft.com/en-us/security/privileged-access-workstations/privileged-access-access-model
TIER_0_ROLE_TEMPLATE_IDS = {
"62e90394-69f5-4237-9190-012177145e10", # Global Administrator
"e8611ab8-c189-46e8-94e1-60213ab1f814", # Privileged Role Administrator
"7be44c8a-adaf-4e2a-84d6-ab2649e08a13", # Privileged Authentication Administrator
"9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3", # Application Administrator
"158c047a-c907-4556-b7ef-446551a6b5f7", # Cloud Application Administrator
"c4e39bd9-1100-46d3-8c65-fb160da0071f", # Authentication Administrator
"0526716b-113d-4c15-b2c8-68e3c22b9f80", # Authentication Policy Administrator
"b1be1c3e-b65d-4f19-8427-f6fa0d97feb9", # Conditional Access Administrator
"8329153b-31d0-4727-b945-745eb3bc5f31", # Domain Name Administrator
"be2f45a1-457d-42af-a067-6ec1fa63bc45", # External Identity Provider Administrator
"8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2", # Hybrid Identity Administrator
"194ae4cb-b126-40b2-bd5b-6091b380977d", # Security Administrator
"fe930be7-5e62-47db-91af-98c3a49a38b1", # User Administrator
"d29b2b05-8046-44ba-8758-1e26182fcf32", # Directory Synchronization Accounts
"e00e864a-17c5-4a4b-9c06-f5b95a8d5bd8", # Partner Tier2 Support
}
class ServicePrincipal(BaseModel):
"""Model representing a Microsoft Entra ID service principal.
Attributes:
id: The service principal's unique identifier.
name: The service principal's display name.
app_id: The application ID associated with the service principal.
app_owner_organization_id: Tenant ID of the application's publisher.
For customer-owned apps this matches the audited tenant; the
service-layer fetch uses this to filter out Microsoft first-party
and third-party multi-tenant service principals that the customer
cannot manage credentials for.
password_credentials: List of password credentials (client secrets).
key_credentials: List of key credentials (certificates).
directory_role_template_ids: List of directory role template IDs permanently
assigned to this service principal.
"""
id: str
name: str
app_id: str = ""
app_owner_organization_id: Optional[str] = None
password_credentials: List[PasswordCredential] = []
key_credentials: List[KeyCredential] = []
directory_role_template_ids: List[str] = []
@@ -0,0 +1,38 @@
{
"Provider": "m365",
"CheckID": "entra_service_principal_no_secrets_for_permanent_tier0_roles",
"CheckTitle": "Secure credential management prevents client secret usage for service principals with permanent Tier 0 roles",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Microsoft Entra **service principals** with permanent assignments to **Control Plane (Tier 0)** directory roles are evaluated for the use of **client secrets** (password credentials) instead of more secure authentication methods such as certificates or managed identities.",
"Risk": "A service principal authenticating with a **client secret** while holding a **Tier 0** role creates a high-impact credential theft path. Leaked or brute-forced secrets grant immediate control-plane access, enabling tenant-wide privilege escalation, security control bypass, data exfiltration, and persistent backdoor creation—impacting **confidentiality**, **integrity**, and **availability**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-planning#prefer-certificate-credentials",
"https://learn.microsoft.com/en-us/entra/architecture/security-operations-applications#application-credentials"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Identity > Applications > App registrations > select the application\n3. Under Certificates & secrets, remove all client secrets\n4. Under Certificates & secrets > Certificates, upload a certificate for authentication\n5. Alternatively, migrate to a managed identity where possible",
"Terraform": ""
},
"Recommendation": {
"Text": "Replace **client secrets** with **certificates** or **managed identities** for service principals holding Control Plane roles. Apply **least privilege** by removing unnecessary Tier 0 assignments. Use **Privileged Identity Management (PIM)** for just-in-time eligible assignments instead of permanent ones. Rotate credentials regularly and monitor sign-in logs for anomalies.",
"Url": "https://hub.prowler.com/check/entra_service_principal_no_secrets_for_permanent_tier0_roles"
}
},
"Categories": [
"identity-access",
"secrets"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Tier 0 roles evaluated follow the Microsoft Entra Control Plane classification, including Global Administrator, Privileged Role Administrator, and Privileged Authentication Administrator."
}
@@ -0,0 +1,81 @@
"""Check for service principals using client secrets with permanent Tier 0 role assignments."""
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import TIER_0_ROLE_TEMPLATE_IDS
class entra_service_principal_no_secrets_for_permanent_tier0_roles(Check):
"""
Service principal with permanent Control Plane role does not use client secrets.
This check evaluates whether service principals that hold permanent assignments
to Tier 0 (Control Plane) directory roles authenticate using client secrets
instead of more secure alternatives such as certificates or managed identities.
- PASS: The service principal does not use client secrets or does not hold a
permanent Tier 0 directory role assignment.
- FAIL: The service principal uses client secrets and has a permanent assignment
to at least one Tier 0 directory role.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the service principal secret management check.
Iterates over service principals and identifies those that combine password
credentials (client secrets) with permanent Tier 0 directory role assignments.
Returns:
A list of reports containing the result of the check for each service principal.
"""
findings = []
for sp in entra_client.service_principals.values():
report = CheckReportM365(
metadata=self.metadata(),
resource=sp,
resource_name=sp.name,
resource_id=sp.id,
)
active_secrets = [
credential
for credential in sp.password_credentials
if credential.is_active()
]
has_secrets = len(active_secrets) > 0
tier0_roles = [
role_id
for role_id in sp.directory_role_template_ids
if role_id in TIER_0_ROLE_TEMPLATE_IDS
]
if has_secrets and tier0_roles:
report.status = "FAIL"
report.status_extended = (
f"Service principal '{sp.name}' uses client secrets and has "
f"permanent assignment to {len(tier0_roles)} Control Plane "
f"(Tier 0) directory role(s)."
)
else:
report.status = "PASS"
if not has_secrets and not tier0_roles:
report.status_extended = (
f"Service principal '{sp.name}' does not use client secrets "
f"and has no Tier 0 directory role assignments."
)
elif not has_secrets:
report.status_extended = (
f"Service principal '{sp.name}' does not use client secrets."
)
else:
report.status_extended = (
f"Service principal '{sp.name}' has no permanent Tier 0 "
f"directory role assignments."
)
findings.append(report)
return findings
@@ -0,0 +1,424 @@
from datetime import datetime, timedelta, timezone
from unittest import mock
from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import (
KeyCredential,
PasswordCredential,
ServicePrincipal,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_entra_service_principal_no_secrets_for_permanent_tier0_roles:
"""Tests for the entra_service_principal_no_secrets_for_permanent_tier0_roles 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_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
entra_service_principal_no_secrets_for_permanent_tier0_roles,
)
entra_client.service_principals = {}
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
result = check.execute()
assert len(result) == 0
def test_service_principal_no_secrets_no_roles(self):
"""Service principal without secrets and no 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_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
entra_service_principal_no_secrets_for_permanent_tier0_roles,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="TestApp",
app_id=str(uuid4()),
password_credentials=[],
key_credentials=[
KeyCredential(key_id=str(uuid4()), display_name="cert1")
],
directory_role_template_ids=[],
)
}
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "does not use client secrets" in result[0].status_extended
assert result[0].resource_id == sp_id
assert result[0].resource_name == "TestApp"
def test_service_principal_with_secrets_no_tier0_roles(self):
"""Service principal with secrets but no Tier 0 roles: expected PASS."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id = str(uuid4())
non_tier0_role = "4a5d8f65-41da-4de4-8968-e035b65339cf" # Reports Reader
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_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
entra_service_principal_no_secrets_for_permanent_tier0_roles,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="AppWithSecrets",
app_id=str(uuid4()),
password_credentials=[
PasswordCredential(key_id=str(uuid4()), display_name="secret1")
],
key_credentials=[],
directory_role_template_ids=[non_tier0_role],
)
}
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "no permanent Tier 0" in result[0].status_extended
assert result[0].resource_id == sp_id
def test_service_principal_no_secrets_with_tier0_roles(self):
"""Service principal without secrets but with Tier 0 roles: expected PASS."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id = str(uuid4())
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
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_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
entra_service_principal_no_secrets_for_permanent_tier0_roles,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="CertBasedApp",
app_id=str(uuid4()),
password_credentials=[],
key_credentials=[
KeyCredential(key_id=str(uuid4()), display_name="cert1")
],
directory_role_template_ids=[global_admin_role],
)
}
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "does not use client secrets" in result[0].status_extended
assert result[0].resource_id == sp_id
def test_service_principal_with_secrets_and_tier0_role(self):
"""Service principal with secrets and Tier 0 role: expected FAIL."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id = str(uuid4())
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
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_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
entra_service_principal_no_secrets_for_permanent_tier0_roles,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="VulnerableApp",
app_id=str(uuid4()),
password_credentials=[
PasswordCredential(key_id=str(uuid4()), display_name="secret1")
],
key_credentials=[],
directory_role_template_ids=[global_admin_role],
)
}
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "uses client secrets" in result[0].status_extended
assert "Control Plane" in result[0].status_extended
assert result[0].resource_id == sp_id
assert result[0].resource_name == "VulnerableApp"
def test_service_principal_with_secrets_and_multiple_tier0_roles(self):
"""Service principal with secrets and multiple Tier 0 roles: expected FAIL."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id = str(uuid4())
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
priv_role_admin = "e8611ab8-c189-46e8-94e1-60213ab1f814"
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_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
entra_service_principal_no_secrets_for_permanent_tier0_roles,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="HighRiskApp",
app_id=str(uuid4()),
password_credentials=[
PasswordCredential(key_id=str(uuid4()), display_name="secret1"),
PasswordCredential(key_id=str(uuid4()), display_name="secret2"),
],
key_credentials=[],
directory_role_template_ids=[
global_admin_role,
priv_role_admin,
],
)
}
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "2 Control Plane" in result[0].status_extended
def test_multiple_service_principals_mixed(self):
"""Multiple service principals with mixed states: mixed results."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id_pass = str(uuid4())
sp_id_fail = str(uuid4())
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
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_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
entra_service_principal_no_secrets_for_permanent_tier0_roles,
)
entra_client.service_principals = {
sp_id_pass: ServicePrincipal(
id=sp_id_pass,
name="SafeApp",
app_id=str(uuid4()),
password_credentials=[],
key_credentials=[
KeyCredential(key_id=str(uuid4()), display_name="cert1")
],
directory_role_template_ids=[global_admin_role],
),
sp_id_fail: ServicePrincipal(
id=sp_id_fail,
name="UnsafeApp",
app_id=str(uuid4()),
password_credentials=[
PasswordCredential(key_id=str(uuid4()), display_name="secret1")
],
key_credentials=[],
directory_role_template_ids=[global_admin_role],
),
}
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
result = check.execute()
assert len(result) == 2
statuses = {r.resource_id: r.status for r in result}
assert statuses[sp_id_pass] == "PASS"
assert statuses[sp_id_fail] == "FAIL"
def test_service_principal_only_expired_secrets_with_tier0_role(self):
"""Service principal whose only client secrets are expired: expected PASS."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id = str(uuid4())
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
expired = datetime.now(timezone.utc) - timedelta(days=1)
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_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
entra_service_principal_no_secrets_for_permanent_tier0_roles,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="LegacyApp",
app_id=str(uuid4()),
password_credentials=[
PasswordCredential(
key_id=str(uuid4()),
display_name="expired-secret",
end_date_time=expired,
)
],
key_credentials=[],
directory_role_template_ids=[global_admin_role],
)
}
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "does not use client secrets" in result[0].status_extended
def test_service_principal_active_and_expired_secrets_with_tier0_role(self):
"""Service principal with at least one active client secret: expected FAIL."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
sp_id = str(uuid4())
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
expired = datetime.now(timezone.utc) - timedelta(days=1)
future = datetime.now(timezone.utc) + timedelta(days=180)
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_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
entra_service_principal_no_secrets_for_permanent_tier0_roles,
)
entra_client.service_principals = {
sp_id: ServicePrincipal(
id=sp_id,
name="MixedSecretsApp",
app_id=str(uuid4()),
password_credentials=[
PasswordCredential(
key_id=str(uuid4()),
display_name="old",
end_date_time=expired,
),
PasswordCredential(
key_id=str(uuid4()),
display_name="current",
end_date_time=future,
),
],
key_credentials=[],
directory_role_template_ids=[global_admin_role],
)
}
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "uses client secrets" in result[0].status_extended
@@ -1,4 +1,5 @@
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@@ -684,3 +685,159 @@ class Test_Entra_Service:
registration_builder.get.assert_awaited()
registration_builder.with_url.assert_called_once_with("next-link")
registration_builder_next.get.assert_awaited()
def test__get_service_principals_filters_third_party_owners(self):
"""Service principals owned by another tenant must not be returned."""
# Mixed-case input to verify the service normalizes both sides before
# comparison, so a Graph response that returns the owner id in upper
# case still matches the lower-case identity in the provider.
tenant_id_in = "AAAAAAAA-1111-1111-1111-111111111111"
tenant_id_lower = tenant_id_in.lower()
microsoft_tenant_id = "f8cdef31-a31e-4b4a-93e4-5f571e91255a"
owned_sp = SimpleNamespace(
id="sp-owned",
display_name="Customer App",
app_id="app-owned",
app_owner_organization_id=tenant_id_in,
password_credentials=[
SimpleNamespace(
key_id="cred-1",
display_name="secret",
end_date_time=None,
)
],
key_credentials=[],
)
first_party_sp = SimpleNamespace(
id="sp-first-party",
display_name="Microsoft Graph",
app_id="app-graph",
app_owner_organization_id=microsoft_tenant_id,
password_credentials=[
SimpleNamespace(
key_id="cred-2",
display_name="secret",
end_date_time=None,
)
],
key_credentials=[],
)
third_party_sp = SimpleNamespace(
id="sp-third-party",
display_name="Some Vendor App",
app_id="app-vendor",
app_owner_organization_id="22222222-2222-2222-2222-222222222222",
password_credentials=[],
key_credentials=[],
)
sp_response = SimpleNamespace(
value=[owned_sp, first_party_sp, third_party_sp],
odata_next_link=None,
)
empty_assignments_response = SimpleNamespace(value=[], odata_next_link=None)
role_assignments_builder = SimpleNamespace(
get=AsyncMock(return_value=empty_assignments_response)
)
role_management_builder = SimpleNamespace(
directory=SimpleNamespace(
role_assignments=role_assignments_builder,
)
)
service_principals_builder = SimpleNamespace(
get=AsyncMock(return_value=sp_response),
with_url=MagicMock(),
)
# The /applications endpoint returns no entries for this case, so the
# service-level test just exercises the customer-owned filter, not the
# secret join.
applications_response = SimpleNamespace(value=[], odata_next_link=None)
applications_builder = SimpleNamespace(
get=AsyncMock(return_value=applications_response),
with_url=MagicMock(),
)
entra_service = Entra.__new__(Entra)
entra_service.tenant_id = tenant_id_lower
entra_service.client = SimpleNamespace(
service_principals=service_principals_builder,
role_management=role_management_builder,
applications=applications_builder,
)
result = asyncio.run(entra_service._get_service_principals())
assert set(result.keys()) == {"sp-owned"}
assert result["sp-owned"].app_owner_organization_id == tenant_id_lower
def test__get_service_principals_merges_application_credentials(self):
"""Secrets registered on the parent Application must be attributed to the SP."""
tenant_id = "11111111-1111-1111-1111-111111111111"
# SP returned by Graph with NO password_credentials of its own (the
# common case in production when the secret was added through "App
# registrations > Certificates & secrets").
sp_without_sp_level_secret = SimpleNamespace(
id="sp-owned",
display_name="m365-dev",
app_id="app-owned",
app_owner_organization_id=tenant_id,
password_credentials=[],
key_credentials=[],
)
sp_response = SimpleNamespace(
value=[sp_without_sp_level_secret], odata_next_link=None
)
# The corresponding Application carries the actual secret.
future = datetime(2099, 1, 1, tzinfo=timezone.utc)
application = SimpleNamespace(
id="app-object",
app_id="app-owned",
password_credentials=[
SimpleNamespace(
key_id="cred-app",
display_name="app-level-secret",
end_date_time=future,
),
],
key_credentials=[],
)
applications_response = SimpleNamespace(
value=[application], odata_next_link=None
)
empty_assignments_response = SimpleNamespace(value=[], odata_next_link=None)
entra_service = Entra.__new__(Entra)
entra_service.tenant_id = tenant_id
entra_service.client = SimpleNamespace(
service_principals=SimpleNamespace(
get=AsyncMock(return_value=sp_response),
with_url=MagicMock(),
),
applications=SimpleNamespace(
get=AsyncMock(return_value=applications_response),
with_url=MagicMock(),
),
role_management=SimpleNamespace(
directory=SimpleNamespace(
role_assignments=SimpleNamespace(
get=AsyncMock(return_value=empty_assignments_response),
),
)
),
)
result = asyncio.run(entra_service._get_service_principals())
merged = result["sp-owned"]
assert len(merged.password_credentials) == 1
assert merged.password_credentials[0].key_id == "cred-app"
assert merged.password_credentials[0].display_name == "app-level-secret"
assert merged.password_credentials[0].is_active()