feat(azure): add entra_app_registration_credential_not_expired check (#11038)

Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
s1ns3nz0
2026-06-23 00:30:18 +09:00
committed by GitHub
parent ca48fd0719
commit 6826422a6a
10 changed files with 458 additions and 2 deletions
+1
View File
@@ -30,6 +30,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `aks_cluster_local_accounts_disabled` check for Azure provider, verifying AKS clusters have local accounts disabled so authentication is forced through Microsoft Entra ID [(#11030)](https://github.com/prowler-cloud/prowler/pull/11030)
- `network_subnet_nsg_associated` check for Azure provider, verifying virtual network subnets have a network security group associated to enforce traffic filtering [(#11043)](https://github.com/prowler-cloud/prowler/pull/11043)
- `network_vnet_ddos_protection_enabled` check for Azure provider, verifying virtual networks have Azure DDoS Network Protection enabled [(#11044)](https://github.com/prowler-cloud/prowler/pull/11044)
- `entra_app_registration_credential_not_expired` check for Azure provider, verifying Entra ID app registration secrets and certificates are not expired, expiring within 30 days, or without an expiration date [(#11038)](https://github.com/prowler-cloud/prowler/pull/11038)
- `aks_cluster_auto_upgrade_enabled` check for Azure provider [(#11027)](https://github.com/prowler-cloud/prowler/pull/11027)
- Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398)
- Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602)
@@ -1519,6 +1519,7 @@
"Checks": [
"app_minimum_tls_version_12",
"cosmosdb_account_minimum_tls_version",
"entra_app_registration_credential_not_expired",
"monitor_storage_account_with_activity_logs_cmk_encrypted",
"sqlserver_tde_encrypted_with_cmk",
"sqlserver_tde_encryption_enabled",
@@ -212,6 +212,7 @@
"Description": "Adversaries may obtain and abuse credentials of existing accounts as a means of gaining Initial Access, Persistence, Privilege Escalation, or Defense Evasion. Compromised credentials may be used to bypass access controls placed on various resources on systems within the network and may even be used for persistent access to remote systems and externally available services, such as VPNs, Outlook Web Access, network devices, and remote desktop.[1] Compromised credentials may also grant an adversary increased privilege to specific systems or access to restricted areas of the network. Adversaries may choose not to use malware or tools in conjunction with the legitimate access those credentials provide to make it harder to detect their presence.",
"TechniqueURL": "https://attack.mitre.org/techniques/T1078/",
"Checks": [
"entra_app_registration_credential_not_expired",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_global_admin_in_less_than_five_users",
"entra_non_privileged_user_has_mfa",
@@ -493,7 +493,8 @@
"keyvault_non_rbac_secret_expiration_set",
"keyvault_logging_enabled",
"keyvault_private_endpoints",
"keyvault_access_only_through_private_endpoints"
"keyvault_access_only_through_private_endpoints",
"entra_app_registration_credential_not_expired"
]
},
{
+2 -1
View File
@@ -241,7 +241,8 @@
"app_function_not_publicly_accessible",
"containerregistry_not_publicly_accessible",
"network_public_ip_shodan",
"storage_blob_public_access_level_is_disabled"
"storage_blob_public_access_level_is_disabled",
"entra_app_registration_credential_not_expired"
]
},
{
@@ -0,0 +1,37 @@
{
"Provider": "azure",
"CheckID": "entra_app_registration_credential_not_expired",
"CheckTitle": "App registration credentials are not expired or expiring soon",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "**Microsoft Entra ID** app registrations are evaluated for **credential validity**. Each app's password secrets and certificate credentials are checked for expiration. Credentials that are already expired, expiring within 30 days, or have no expiration date are flagged.",
"Risk": "Expired credentials cause **service outages** when apps can no longer authenticate. Credentials without expiration violate **least privilege** by persisting indefinitely. Long-lived or leaked secrets enable **unauthorized API access**, **data exfiltration**, and **lateral movement** via the app's permissions.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity-platform/howto-create-service-principal-portal",
"https://learn.microsoft.com/en-us/graph/api/resources/application"
],
"Remediation": {
"Code": {
"CLI": "az ad app credential reset --id <app_id> --years 1",
"NativeIaC": "",
"Other": "1. Sign in to Microsoft Entra admin center\n2. Go to Identity > Applications > App registrations\n3. Select the application with the expiring credential\n4. Go to Certificates & secrets\n5. Delete the expired credential\n6. Add a new credential with an appropriate expiration (recommended: 6-12 months)\n7. Update the consuming application with the new credential\n8. Consider migrating to managed identities or federated credentials where possible",
"Terraform": ""
},
"Recommendation": {
"Text": "Rotate app registration credentials before expiration. Set expiration to 6-12 months maximum. Prefer **managed identities** or **federated credentials** over secrets. Monitor credential expiry with Azure Monitor alerts or Microsoft Entra workbooks.",
"Url": "https://hub.prowler.com/check/entra_app_registration_credential_not_expired"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check evaluates both password secrets and certificate credentials. Each credential is reported individually. Apps with no credentials are skipped."
}
@@ -0,0 +1,72 @@
from datetime import datetime, timezone
from prowler.lib.check.models import Check, Check_Report_Azure
from prowler.providers.azure.services.entra.entra_client import entra_client
EXPIRY_WARNING_DAYS = 30
class entra_app_registration_credential_not_expired(Check):
"""
Ensure Microsoft Entra ID app registration credentials are not expired or expiring soon.
This check evaluates each app registration's password secrets and certificate credentials. A credential is reported individually and flagged when it is already expired, expiring within 30 days, or has no expiration date. Apps with no credentials are skipped.
- PASS: The credential is valid for more than 30 days.
- FAIL: The credential is expired, expiring within 30 days, or has no expiration date.
"""
def execute(self) -> Check_Report_Azure:
findings = []
for tenant_domain, apps in entra_client.app_registrations.items():
for app_id, app in apps.items():
if not app.credentials:
continue
for credential in app.credentials:
report = Check_Report_Azure(metadata=self.metadata(), resource=app)
report.subscription = f"Tenant: {tenant_domain}"
report.resource_name = (
f"{app.name} ({credential.credential_type}: "
f"{credential.display_name or 'unnamed'})"
)
if credential.end_date_time is None:
report.status = "FAIL"
report.status_extended = (
f"App '{app.name}' has a {credential.credential_type} "
f"credential with no expiration date."
)
else:
now = datetime.now(timezone.utc)
end = credential.end_date_time
if end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
if end <= now:
days_ago = (now - end).days
report.status = "FAIL"
report.status_extended = (
f"App '{app.name}' has a {credential.credential_type} "
f"credential that expired {days_ago} days ago."
)
elif (end - now).days <= EXPIRY_WARNING_DAYS:
days_left = (end - now).days
report.status = "FAIL"
report.status_extended = (
f"App '{app.name}' has a {credential.credential_type} "
f"credential expiring in {days_left} days (within the "
f"{EXPIRY_WARNING_DAYS}-day rotation threshold); rotate it soon."
)
else:
days_left = (end - now).days
report.status = "PASS"
report.status_extended = (
f"App '{app.name}' has a {credential.credential_type} "
f"credential valid for {days_left} more days (beyond the "
f"{EXPIRY_WARNING_DAYS}-day rotation threshold)."
)
findings.append(report)
return findings
@@ -1,5 +1,6 @@
import asyncio
from asyncio import gather
from datetime import datetime
from typing import List, Optional
from uuid import UUID
@@ -49,6 +50,7 @@ class Entra(AzureService):
self._get_named_locations(),
self._get_directory_roles(),
self._get_conditional_access_policy(),
self._get_app_registrations(),
)
)
@@ -58,6 +60,7 @@ class Entra(AzureService):
self.named_locations = attributes[3]
self.directory_roles = attributes[4]
self.conditional_access_policy = attributes[5]
self.app_registrations = attributes[6]
if created_loop:
asyncio.set_event_loop(None)
@@ -416,6 +419,64 @@ class Entra(AzureService):
return conditional_access_policy
async def _get_app_registrations(self):
logger.info("Entra - Getting app registrations...")
app_registrations = {}
try:
for tenant, client in self.clients.items():
app_registrations[tenant] = {}
apps_response = await client.applications.get()
try:
while apps_response:
for app in getattr(apps_response, "value", []) or []:
credentials = []
for cred in getattr(app, "password_credentials", []) or []:
credentials.append(
AppCredential(
display_name=getattr(cred, "display_name", "")
or "",
credential_type="password",
end_date_time=getattr(
cred, "end_date_time", None
),
)
)
for cred in getattr(app, "key_credentials", []) or []:
credentials.append(
AppCredential(
display_name=getattr(cred, "display_name", "")
or "",
credential_type="certificate",
end_date_time=getattr(
cred, "end_date_time", None
),
)
)
app_registrations[tenant][app.id] = AppRegistration(
id=app.id,
name=getattr(app, "display_name", "") or "",
credentials=credentials,
)
next_link = getattr(apps_response, "odata_next_link", None)
if not next_link:
break
apps_response = await client.applications.with_url(
next_link
).get()
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}"
)
return app_registrations
class User(BaseModel):
id: str
@@ -481,3 +542,15 @@ class ConditionalAccessPolicy(BaseModel):
users: dict[str, List[str]]
target_resources: dict[str, List[str]]
access_controls: dict[str, List[str]]
class AppCredential(BaseModel):
display_name: str = ""
credential_type: str # "password" or "certificate"
end_date_time: Optional[datetime] = None
class AppRegistration(BaseModel):
id: str
name: str
credentials: List[AppCredential] = []
@@ -0,0 +1,269 @@
from datetime import datetime, timezone, timedelta
from unittest import mock
from uuid import uuid4
from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider
class Test_entra_app_registration_credential_not_expired:
def test_entra_no_tenants(self):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import (
entra_app_registration_credential_not_expired,
)
entra_client.app_registrations = {}
check = entra_app_registration_credential_not_expired()
result = check.execute()
assert len(result) == 0
def test_entra_app_no_credentials(self):
entra_client = mock.MagicMock
app_id = str(uuid4())
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import (
entra_app_registration_credential_not_expired,
)
from prowler.providers.azure.services.entra.entra_service import (
AppRegistration,
)
app = AppRegistration(id=app_id, name="no-creds-app", credentials=[])
entra_client.app_registrations = {DOMAIN: {app_id: app}}
check = entra_app_registration_credential_not_expired()
result = check.execute()
assert len(result) == 0
def test_entra_app_credential_expired(self):
entra_client = mock.MagicMock
app_id = str(uuid4())
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import (
entra_app_registration_credential_not_expired,
)
from prowler.providers.azure.services.entra.entra_service import (
AppCredential,
AppRegistration,
)
app = AppRegistration(
id=app_id,
name="expired-app",
credentials=[
AppCredential(
display_name="old-secret",
credential_type="password",
end_date_time=datetime.now(timezone.utc) - timedelta(days=30),
)
],
)
entra_client.app_registrations = {DOMAIN: {app_id: app}}
check = entra_app_registration_credential_not_expired()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "expired" in result[0].status_extended
def test_entra_app_credential_expiring_soon(self):
entra_client = mock.MagicMock
app_id = str(uuid4())
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import (
entra_app_registration_credential_not_expired,
)
from prowler.providers.azure.services.entra.entra_service import (
AppCredential,
AppRegistration,
)
app = AppRegistration(
id=app_id,
name="expiring-soon-app",
credentials=[
AppCredential(
display_name="expiring-cert",
credential_type="certificate",
end_date_time=datetime.now(timezone.utc) + timedelta(days=15),
)
],
)
entra_client.app_registrations = {DOMAIN: {app_id: app}}
check = entra_app_registration_credential_not_expired()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "expiring in" in result[0].status_extended
def test_entra_app_credential_no_expiration(self):
entra_client = mock.MagicMock
app_id = str(uuid4())
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import (
entra_app_registration_credential_not_expired,
)
from prowler.providers.azure.services.entra.entra_service import (
AppCredential,
AppRegistration,
)
app = AppRegistration(
id=app_id,
name="no-expiry-app",
credentials=[
AppCredential(
display_name="forever-secret",
credential_type="password",
end_date_time=None,
)
],
)
entra_client.app_registrations = {DOMAIN: {app_id: app}}
check = entra_app_registration_credential_not_expired()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "no expiration" in result[0].status_extended
def test_entra_app_credential_valid(self):
entra_client = mock.MagicMock
app_id = str(uuid4())
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import (
entra_app_registration_credential_not_expired,
)
from prowler.providers.azure.services.entra.entra_service import (
AppCredential,
AppRegistration,
)
app = AppRegistration(
id=app_id,
name="healthy-app",
credentials=[
AppCredential(
display_name="good-secret",
credential_type="password",
end_date_time=datetime.now(timezone.utc) + timedelta(days=180),
)
],
)
entra_client.app_registrations = {DOMAIN: {app_id: app}}
check = entra_app_registration_credential_not_expired()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "more days" in result[0].status_extended
def test_entra_app_multiple_credentials_mixed(self):
entra_client = mock.MagicMock
app_id = str(uuid4())
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import (
entra_app_registration_credential_not_expired,
)
from prowler.providers.azure.services.entra.entra_service import (
AppCredential,
AppRegistration,
)
app = AppRegistration(
id=app_id,
name="mixed-app",
credentials=[
AppCredential(
display_name="expired-one",
credential_type="password",
end_date_time=datetime.now(timezone.utc) - timedelta(days=10),
),
AppCredential(
display_name="valid-one",
credential_type="certificate",
end_date_time=datetime.now(timezone.utc) + timedelta(days=200),
),
],
)
entra_client.app_registrations = {DOMAIN: {app_id: app}}
check = entra_app_registration_credential_not_expired()
result = check.execute()
assert len(result) == 2
statuses = {r.status for r in result}
assert "FAIL" in statuses
assert "PASS" in statuses