diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 003ed6b11f..e4bf63c602 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -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) diff --git a/prowler/compliance/azure/iso27001_2022_azure.json b/prowler/compliance/azure/iso27001_2022_azure.json index c1d92b1dc5..04da31be78 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -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", diff --git a/prowler/compliance/azure/mitre_attack_azure.json b/prowler/compliance/azure/mitre_attack_azure.json index 92ddd72713..8b338da534 100644 --- a/prowler/compliance/azure/mitre_attack_azure.json +++ b/prowler/compliance/azure/mitre_attack_azure.json @@ -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", diff --git a/prowler/compliance/azure/secnumcloud_3.2_azure.json b/prowler/compliance/azure/secnumcloud_3.2_azure.json index ef8c94113e..ef6141d446 100644 --- a/prowler/compliance/azure/secnumcloud_3.2_azure.json +++ b/prowler/compliance/azure/secnumcloud_3.2_azure.json @@ -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" ] }, { diff --git a/prowler/compliance/azure/soc2_azure.json b/prowler/compliance/azure/soc2_azure.json index 20db26baf9..e0839cedaa 100644 --- a/prowler/compliance/azure/soc2_azure.json +++ b/prowler/compliance/azure/soc2_azure.json @@ -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" ] }, { diff --git a/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/__init__.py b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.metadata.json b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.metadata.json new file mode 100644 index 0000000000..c786fd49af --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.metadata.json @@ -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 --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." +} diff --git a/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.py b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.py new file mode 100644 index 0000000000..5cbdf009a0 --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.py @@ -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 diff --git a/prowler/providers/azure/services/entra/entra_service.py b/prowler/providers/azure/services/entra/entra_service.py index eb1d62ac11..cda5070ec3 100644 --- a/prowler/providers/azure/services/entra/entra_service.py +++ b/prowler/providers/azure/services/entra/entra_service.py @@ -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] = [] diff --git a/tests/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired_test.py b/tests/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired_test.py new file mode 100644 index 0000000000..89fa5bac16 --- /dev/null +++ b/tests/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired_test.py @@ -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