From 3ee24fba51448632ad5e24bc25a5b0583dd06d7a Mon Sep 17 00:00:00 2001 From: s1ns3nz0 Date: Tue, 23 Jun 2026 20:13:02 +0900 Subject: [PATCH] feat(azure): add entra_user_with_recent_sign_in check (#11040) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + .../azure/fedramp_20x_ksi_low_azure.json | 1 + prowler/compliance/azure/hipaa_azure.json | 1 + .../compliance/azure/iso27001_2022_azure.json | 4 +- .../azure/services/entra/entra_service.py | 19 +- .../__init__.py | 0 ...tra_user_with_recent_sign_in.metadata.json | 37 ++ .../entra_user_with_recent_sign_in.py | 77 +++++ .../services/entra/entra_service_test.py | 1 + .../entra_user_with_recent_sign_in_test.py | 321 ++++++++++++++++++ 10 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/__init__.py create mode 100644 prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json create mode 100644 prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py create mode 100644 tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index baac7b8625..f7d498164f 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `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) - `entra_authentication_methods_policy_strong_auth_enforced` check for Azure provider, verifying the Entra ID authentication methods policy enforces MFA registration and enables at least one strong method (Microsoft Authenticator, FIDO2, or X.509 certificate) [(#11039)](https://github.com/prowler-cloud/prowler/pull/11039) +- `entra_user_with_recent_sign_in` check for Azure provider, detecting stale enabled accounts that have not signed in within the last 90 days (requires Entra ID P1/P2 licensing for sign-in activity) [(#11040)](https://github.com/prowler-cloud/prowler/pull/11040) - `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/fedramp_20x_ksi_low_azure.json b/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json index c845f75f60..655f649abe 100644 --- a/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json +++ b/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json @@ -328,6 +328,7 @@ "Checks": [ "entra_non_privileged_user_has_mfa", "entra_privileged_user_has_mfa", + "entra_user_with_recent_sign_in", "entra_user_with_vm_access_has_mfa", "iam_custom_role_has_permissions_to_administer_resource_locks", "iam_role_user_access_admin_restricted", diff --git a/prowler/compliance/azure/hipaa_azure.json b/prowler/compliance/azure/hipaa_azure.json index b27344582f..26ad8128ac 100644 --- a/prowler/compliance/azure/hipaa_azure.json +++ b/prowler/compliance/azure/hipaa_azure.json @@ -182,6 +182,7 @@ } ], "Checks": [ + "entra_user_with_recent_sign_in", "storage_key_rotation_90_days", "keyvault_key_rotation_enabled", "keyvault_rbac_key_expiration_set", diff --git a/prowler/compliance/azure/iso27001_2022_azure.json b/prowler/compliance/azure/iso27001_2022_azure.json index ab17f7057c..00b27b5c65 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -285,6 +285,7 @@ "defender_ensure_notify_alerts_severity_is_high", "entra_policy_guest_users_access_restrictions", "entra_policy_restricts_user_consent_for_apps", + "entra_user_with_recent_sign_in", "entra_users_cannot_create_microsoft_365_groups", "iam_custom_role_has_permissions_to_administer_resource_locks", "monitor_alert_create_update_security_solution", @@ -333,7 +334,8 @@ "entra_policy_guest_invite_only_for_admin_roles", "entra_policy_guest_users_access_restrictions", "entra_policy_restricts_user_consent_for_apps", - "entra_policy_user_consent_for_verified_apps" + "entra_policy_user_consent_for_verified_apps", + "entra_user_with_recent_sign_in" ] }, { diff --git a/prowler/providers/azure/services/entra/entra_service.py b/prowler/providers/azure/services/entra/entra_service.py index d77880b425..e23f3cd34a 100644 --- a/prowler/providers/azure/services/entra/entra_service.py +++ b/prowler/providers/azure/services/entra/entra_service.py @@ -74,7 +74,12 @@ class Entra(AzureService): try: request_configuration = RequestConfiguration( query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( - select=["id", "displayName", "accountEnabled"] + select=[ + "id", + "displayName", + "accountEnabled", + "signInActivity", + ] ) ) for tenant, client in self.clients.items(): @@ -87,6 +92,16 @@ class Entra(AzureService): try: while users_response: for user in getattr(users_response, "value", []) or []: + sign_in_activity = getattr(user, "sign_in_activity", None) + last_sign_in = ( + getattr( + sign_in_activity, + "last_sign_in_date_time", + None, + ) + if sign_in_activity + else None + ) users[tenant].update( { user.id: User( @@ -98,6 +113,7 @@ class Entra(AzureService): account_enabled=getattr( user, "account_enabled", True ), + last_sign_in=last_sign_in, ) } ) @@ -556,6 +572,7 @@ class User(BaseModel): name: str is_mfa_capable: bool = False account_enabled: bool = True + last_sign_in: Optional[datetime] = None class DefaultUserRolePermissions(BaseModel): diff --git a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/__init__.py b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json new file mode 100644 index 0000000000..64ccb383a1 --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "entra_user_with_recent_sign_in", + "CheckTitle": "Enabled user has signed in within the last 90 days", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Microsoft Entra ID** enabled user accounts are evaluated for **recent sign-in activity**. Accounts that have not signed in for more than 90 days are flagged as stale. Stale accounts indicate orphaned identities that may have been abandoned after personnel changes, project completions, or role transitions without proper deprovisioning.", + "Risk": "Stale accounts retain **role assignments** and **group memberships**. Attackers target dormant accounts via **credential stuffing** and **password spraying** because owners are unlikely to notice anomalous activity. Compromise enables **lateral movement**, **data exfiltration**, and **persistence** while evading detection tuned to active users.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/signinactivity", + "https://learn.microsoft.com/en-us/entra/identity/monitoring-health/concept-sign-ins" + ], + "Remediation": { + "Code": { + "CLI": "az rest --method patch --url https://graph.microsoft.com/v1.0/users/ --body '{\"accountEnabled\":false}'", + "NativeIaC": "", + "Other": "1. Sign in to Microsoft Entra admin center\n2. Go to Identity > Users > All users\n3. Add filter: Sign-in activity > Last interactive sign-in date is before <90 days ago>\n4. Review each stale account with the account owner or manager\n5. Disable accounts confirmed as no longer needed\n6. After a grace period, delete disabled accounts\n7. Establish a recurring access review to automate this process", + "Terraform": "" + }, + "Recommendation": { + "Text": "Implement **automated access reviews** in Entra ID to periodically review and remove stale accounts. Configure reviews to run quarterly, targeting all users or specific groups. Set auto-apply to disable accounts that are not confirmed by reviewers. For immediate remediation, filter users by last sign-in date and disable accounts inactive for more than 90 days after confirming with managers.", + "Url": "https://hub.prowler.com/check/entra_user_with_recent_sign_in" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The signInActivity resource requires Microsoft Entra ID P1 or P2 license. Tenants without this license will not have sign-in activity data available, and all users will be reported as never having signed in." +} diff --git a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py new file mode 100644 index 0000000000..fa6d1af05d --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py @@ -0,0 +1,77 @@ +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 + +STALE_THRESHOLD_DAYS = 90 + + +class entra_user_with_recent_sign_in(Check): + """ + Ensure enabled Entra ID users have signed in within the last 90 days. + + This check evaluates each enabled user's last interactive sign-in to detect stale or dormant accounts that should be reviewed or deprovisioned. Sign-in activity requires Entra ID P1/P2 licensing. + + - PASS: The enabled user signed in within the last 90 days. + - FAIL: The enabled user has not signed in for more than 90 days, or has never signed in. + - FAIL (tenant-level): No sign-in activity data is available for any enabled user, indicating missing P1/P2 licensing or Graph permissions (reported once instead of flagging every user). + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for tenant_domain, users in entra_client.users.items(): + enabled_users = {k: v for k, v in users.items() if v.account_enabled} + + if not enabled_users: + continue + + # If all enabled users are missing sign-in data, avoid claiming + # they never signed in. This usually indicates missing telemetry, + # often due to licensing or Graph permission limitations. + all_null = all(u.last_sign_in is None for u in enabled_users.values()) + if all_null: + first_user = next(iter(enabled_users.values())) + report = Check_Report_Azure( + metadata=self.metadata(), resource=first_user + ) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = "Sign-in Activity Data" + count = len(enabled_users) + noun = "user" if count == 1 else "users" + report.status = "FAIL" + report.status_extended = ( + f"No sign-in activity data available for any of the " + f"{count} enabled {noun}. This likely means the tenant " + f"is missing Entra ID P1/P2 licensing or the required " + f"Graph permissions to read sign-in activity." + ) + findings.append(report) + continue + + for user_domain_name, user in enabled_users.items(): + report = Check_Report_Azure(metadata=self.metadata(), resource=user) + report.subscription = f"Tenant: {tenant_domain}" + + if user.last_sign_in is None: + report.status = "FAIL" + report.status_extended = f"User {user.name} has never signed in." + else: + last = user.last_sign_in + if last.tzinfo is None: + last = last.replace(tzinfo=timezone.utc) + days_since = (datetime.now(timezone.utc) - last).days + if days_since > STALE_THRESHOLD_DAYS: + report.status = "FAIL" + report.status_extended = ( + f"User {user.name} has not signed in for {days_since} days." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"User {user.name} signed in {days_since} days ago." + ) + + findings.append(report) + + return findings diff --git a/tests/providers/azure/services/entra/entra_service_test.py b/tests/providers/azure/services/entra/entra_service_test.py index 75ef4f98c4..ebd2b790ab 100644 --- a/tests/providers/azure/services/entra/entra_service_test.py +++ b/tests/providers/azure/services/entra/entra_service_test.py @@ -294,6 +294,7 @@ def test_azure_entra__get_users_handles_pagination(): "id", "displayName", "accountEnabled", + "signInActivity", ] with_url_mock.assert_called_once_with("next-link") registration_details_builder.get.assert_awaited() diff --git a/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py b/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py new file mode 100644 index 0000000000..f940cf232c --- /dev/null +++ b/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py @@ -0,0 +1,321 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock +from uuid import uuid4 + +from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider + + +class Test_entra_user_with_recent_sign_in: + 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_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + entra_client.users = {} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 0 + + def test_entra_user_disabled(self): + entra_client = mock.MagicMock + user_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_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="disabled-user", + account_enabled=False, + last_sign_in=None, + ) + + entra_client.users = {DOMAIN: {f"disabled-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 0 + + def test_entra_user_never_signed_in(self): + entra_client = mock.MagicMock + user_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_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="never-signed-in", + account_enabled=True, + last_sign_in=None, + ) + + entra_client.users = {DOMAIN: {f"never-signed-in@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "No sign-in activity data available" in result[0].status_extended + + def test_entra_single_user_no_sign_in_data_reports_telemetry_gap(self): + entra_client = mock.MagicMock + user_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_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="single-user", + account_enabled=True, + last_sign_in=None, + ) + + entra_client.users = {DOMAIN: {f"single-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "No sign-in activity data available" in result[0].status_extended + assert "1 enabled user" in result[0].status_extended + + def test_entra_user_stale_sign_in(self): + entra_client = mock.MagicMock + user_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_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="stale-user", + account_enabled=True, + last_sign_in=datetime.now(timezone.utc) - timedelta(days=120), + ) + + entra_client.users = {DOMAIN: {f"stale-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "120 days" in result[0].status_extended + + def test_entra_user_recent_sign_in(self): + entra_client = mock.MagicMock + user_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_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="active-user", + account_enabled=True, + last_sign_in=datetime.now(timezone.utc) - timedelta(days=10), + ) + + entra_client.users = {DOMAIN: {f"active-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "10 days ago" in result[0].status_extended + + def test_entra_all_users_no_sign_in_data_license_issue(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_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + # Multiple enabled users, ALL with no sign-in data = license issue + users = {} + for i in range(5): + uid = str(uuid4()) + users[f"user{i}@{DOMAIN}"] = User( + id=uid, + name=f"user{i}", + account_enabled=True, + last_sign_in=None, + ) + + entra_client.users = {DOMAIN: users} + + check = entra_user_with_recent_sign_in() + result = check.execute() + # Should produce 1 finding (license warning), not 5 individual FAILs + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Entra ID P1/P2 licensing" in result[0].status_extended + assert "5 enabled users" in result[0].status_extended + + def test_entra_user_never_signed_in_when_telemetry_exists_for_tenant(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_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + active_user = User( + id=str(uuid4()), + name="active-user", + account_enabled=True, + last_sign_in=datetime.now(timezone.utc) - timedelta(days=5), + ) + never_user = User( + id=str(uuid4()), + name="never-user", + account_enabled=True, + last_sign_in=None, + ) + + entra_client.users = { + DOMAIN: { + f"active-user@{DOMAIN}": active_user, + f"never-user@{DOMAIN}": never_user, + } + } + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 2 + assert any( + r.status == "PASS" and "5 days ago" in r.status_extended for r in result + ) + assert any( + r.status == "FAIL" and "never signed in" in r.status_extended + for r in result + ) + + def test_entra_user_boundary_90_days(self): + entra_client = mock.MagicMock + user_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_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + user = User( + id=user_id, + name="boundary-user", + account_enabled=True, + last_sign_in=datetime.now(timezone.utc) - timedelta(days=90), + ) + + entra_client.users = {DOMAIN: {f"boundary-user@{DOMAIN}": user}} + + check = entra_user_with_recent_sign_in() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "90 days ago" in result[0].status_extended