feat(azure): add entra_user_with_recent_sign_in check (#11040)

Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
s1ns3nz0
2026-06-23 20:13:02 +09:00
committed by GitHub
parent 48acb3bd2e
commit 3ee24fba51
10 changed files with 460 additions and 2 deletions
+1
View File
@@ -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)
@@ -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",
@@ -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",
@@ -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"
]
},
{
@@ -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):
@@ -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/<user_id> --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."
}
@@ -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
@@ -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()
@@ -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