Compare commits

...

2 Commits

Author SHA1 Message Date
Andoni A.
f389ee3d1d feat(m365): add entra_conditional_access_policy_enforce_sign_in_frequency security check
Move check from Azure provider to M365 provider and add device filter support
to the M365 entra service. The check validates that at least one Conditional
Access policy enforces sign-in frequency for non-corporate devices.

Changes:
- Add DeviceFilter and DeviceFilterMode models to M365 entra_service
- Add device_filter to Conditions model in M365 entra_service
- Update _get_conditional_access_policies to fetch device filter data
- Create check in M365 provider with proper M365 patterns
- Remove Azure check (wrong provider)
- Update CHANGELOG to reflect M365 provider
2026-01-29 14:00:34 +01:00
Andoni A.
2ceda9bac1 feat(azure): add entra_conditional_access_policy_enforce_sign_in_frequency security check
Add new security check entra_conditional_access_policy_enforce_sign_in_frequency for azure provider.
Includes check implementation, metadata, and unit tests.
2026-01-29 12:29:59 +01:00
12 changed files with 1282 additions and 7 deletions

View File

@@ -13,6 +13,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- CloudTrail Timeline abstraction for querying resource modification history [(#9101)](https://github.com/prowler-cloud/prowler/pull/9101)
- Cloudflare `--account-id` filter argument [(#9894)](https://github.com/prowler-cloud/prowler/pull/9894)
- `rds_instance_extended_support` check for AWS provider [(#9865)](https://github.com/prowler-cloud/prowler/pull/9865)
- `entra_conditional_access_policy_enforce_sign_in_frequency` check for M365 provider [(#9915)](https://github.com/prowler-cloud/prowler/pull/9915)
### Changed

View File

@@ -307,6 +307,7 @@
}
],
"Checks": [
"entra_conditional_access_policy_enforce_sign_in_frequency",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_non_privileged_user_has_mfa",
"entra_privileged_user_has_mfa",
@@ -1102,10 +1103,11 @@
}
],
"Checks": [
"app_minimum_tls_version_12",
"entra_conditional_access_policy_enforce_sign_in_frequency",
"entra_conditional_access_policy_require_mfa_for_management_app",
"entra_non_privileged_user_has_mfa entra_privileged_user_has_mfa",
"entra_user_with_vm_access_has_mfa",
"app_minimum_tls_version_12",
"sqlserver_tde_encryption_enabled",
"storage_ensure_encryption_with_customer_managed_keys",
"storage_infrastructure_encryption_is_enabled"

View File

@@ -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_conditional_access_policy_enforce_sign_in_frequency",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_global_admin_in_less_than_five_users",
"entra_non_privileged_user_has_mfa",
@@ -804,6 +805,7 @@
"Description": "Adversaries may modify authentication mechanisms and processes to access user credentials or enable otherwise unwarranted access to accounts. The authentication process is handled by mechanisms, such as the Local Security Authentication Server (LSASS) process and the Security Accounts Manager (SAM) on Windows, pluggable authentication modules (PAM) on Unix-based systems, and authorization plugins on MacOS systems, responsible for gathering, storing, and validating credentials. By modifying an authentication process, an adversary may be able to authenticate to a service or system without using Valid Accounts.",
"TechniqueURL": "https://attack.mitre.org/techniques/T1556/",
"Checks": [
"entra_conditional_access_policy_enforce_sign_in_frequency",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_global_admin_in_less_than_five_users",
"entra_non_privileged_user_has_mfa",
@@ -995,6 +997,7 @@
"Description": "Adversaries may use alternate authentication material, such as password hashes, Kerberos tickets, and application access tokens, in order to move laterally within an environment and bypass normal system access controls.",
"TechniqueURL": "https://attack.mitre.org/techniques/T1550/",
"Checks": [
"entra_conditional_access_policy_enforce_sign_in_frequency",
"entra_global_admin_in_less_than_five_users",
"entra_non_privileged_user_has_mfa",
"entra_policy_default_users_cannot_create_security_groups",
@@ -1187,6 +1190,7 @@
"Description": "Adversaries may forge credential materials that can be used to gain access to web applications or Internet services. Web applications and services (hosted in cloud SaaS environments or on-premise servers) often use session cookies, tokens, or other materials to authenticate and authorize user access.",
"TechniqueURL": "https://attack.mitre.org/techniques/T1606/",
"Checks": [
"entra_conditional_access_policy_enforce_sign_in_frequency",
"entra_policy_default_users_cannot_create_security_groups",
"entra_policy_ensure_default_user_cannot_create_apps",
"entra_policy_ensure_default_user_cannot_create_tenants",

View File

@@ -1603,6 +1603,7 @@
"Id": "11.3.2.a",
"Description": "establish strong identification, authentication such as multi-factor authentication, and authorisation procedures for privileged accounts and system administration accounts;",
"Checks": [
"entra_conditional_access_policy_enforce_sign_in_frequency",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_non_privileged_user_has_mfa",
"entra_privileged_user_has_mfa",
@@ -1690,10 +1691,11 @@
"Id": "11.4.2.c",
"Description": "protect access to system administration systems through authentication and encryption.",
"Checks": [
"entra_trusted_named_locations_exists",
"entra_user_with_vm_access_has_mfa",
"entra_conditional_access_policy_enforce_sign_in_frequency",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_privileged_user_has_mfa"
"entra_privileged_user_has_mfa",
"entra_trusted_named_locations_exists",
"entra_user_with_vm_access_has_mfa"
],
"Attributes": [
{
@@ -1762,6 +1764,7 @@
"Id": "11.6.2.a",
"Description": "ensure the strength of authentication is appropriate to the classification of the asset to be accessed;",
"Checks": [
"entra_conditional_access_policy_enforce_sign_in_frequency",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_non_privileged_user_has_mfa",
"entra_privileged_user_has_mfa",
@@ -1794,6 +1797,7 @@
"Id": "11.7.2",
"Description": "The relevant entities shall ensure that the strength of authentication is appropriate for the classification of the asset to be accessed.",
"Checks": [
"entra_conditional_access_policy_enforce_sign_in_frequency",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_non_privileged_user_has_mfa",
"entra_privileged_user_has_mfa",

View File

@@ -45,6 +45,7 @@
"Id": "1.1.3",
"Description": "Ensure Multi-factor Authentication is Required for Windows Azure Service Management API",
"Checks": [
"entra_conditional_access_policy_enforce_sign_in_frequency",
"entra_conditional_access_policy_require_mfa_for_management_api"
],
"Attributes": [

View File

@@ -18,6 +18,7 @@
}
],
"Checks": [
"entra_conditional_access_policy_enforce_sign_in_frequency",
"entra_conditional_access_policy_require_mfa_for_management_api",
"entra_global_admin_in_less_than_five_users",
"entra_non_privileged_user_has_mfa",
@@ -240,6 +241,7 @@
"aks_clusters_public_access_disabled",
"app_function_not_publicly_accessible",
"containerregistry_not_publicly_accessible",
"entra_conditional_access_policy_enforce_sign_in_frequency",
"network_public_ip_shodan",
"storage_blob_public_access_level_is_disabled"
]
@@ -280,6 +282,7 @@
}
],
"Checks": [
"entra_conditional_access_policy_enforce_sign_in_frequency",
"entra_non_privileged_user_has_mfa",
"entra_privileged_user_has_mfa",
"entra_user_with_vm_access_has_mfa"
@@ -298,14 +301,15 @@
}
],
"Checks": [
"app_minimum_tls_version_12",
"entra_conditional_access_policy_enforce_sign_in_frequency",
"mysql_flexible_server_minimum_tls_version_12",
"mysql_flexible_server_ssl_connection_enabled",
"network_http_internet_access_restricted",
"network_rdp_internet_access_restricted",
"network_ssh_internet_access_restricted",
"network_udp_internet_access_restricted",
"mysql_flexible_server_ssl_connection_enabled",
"postgresql_flexible_server_enforce_ssl_enabled",
"app_minimum_tls_version_12",
"mysql_flexible_server_minimum_tls_version_12",
"sqlserver_recommended_minimal_tls_version",
"storage_ensure_minimum_tls_version_12"
]

View File

@@ -365,6 +365,43 @@ class Entra(AzureService):
else:
block_access_controls.append(str(access_control))
# Extract session controls (sign-in frequency)
sign_in_frequency = None
session_controls = getattr(policy, "session_controls", None)
if session_controls:
sif = getattr(session_controls, "sign_in_frequency", None)
if sif:
sign_in_frequency = SignInFrequencySessionControl(
is_enabled=getattr(sif, "is_enabled", False),
frequency_interval=(
str(getattr(sif, "frequency_interval", None))
if getattr(sif, "frequency_interval", None)
else None
),
type=(
str(getattr(sif, "type", None))
if getattr(sif, "type", None)
else None
),
value=getattr(sif, "value", None),
)
# Extract device filter
device_filter = None
if conditions:
devices = getattr(conditions, "devices", None)
if devices:
df = getattr(devices, "device_filter", None)
if df:
device_filter = DeviceFilter(
mode=(
str(getattr(df, "mode", None))
if getattr(df, "mode", None)
else None
),
rule=getattr(df, "rule", None),
)
conditional_access_policy[tenant].update(
{
policy.id: ConditionalAccessPolicy(
@@ -391,6 +428,8 @@ class Entra(AzureService):
"grant": grant_access_controls,
"block": block_access_controls,
},
sign_in_frequency=sign_in_frequency,
device_filter=device_filter,
)
}
)
@@ -457,10 +496,30 @@ class DirectoryRole(BaseModel):
members: List[User]
class SignInFrequencySessionControl(BaseModel):
"""Sign-in frequency session control settings."""
is_enabled: bool = False
frequency_interval: Optional[str] = None
type: Optional[str] = None
value: Optional[int] = None
class DeviceFilter(BaseModel):
"""Device filter for conditional access policies."""
mode: Optional[str] = None
rule: Optional[str] = None
class ConditionalAccessPolicy(BaseModel):
"""Conditional Access Policy model."""
id: str
name: str
state: str
users: dict[str, List[str]]
target_resources: dict[str, List[str]]
access_controls: dict[str, List[str]]
sign_in_frequency: Optional[SignInFrequencySessionControl] = None
device_filter: Optional[DeviceFilter] = None

View File

@@ -0,0 +1,36 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_enforce_sign_in_frequency",
"CheckTitle": "Ensure that Conditional Access policy enforces sign-in frequency for non-corporate devices",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Conditional Access Policy",
"ResourceGroup": "IAM",
"Description": "This check verifies that at least one Conditional Access policy enforces sign-in frequency controls for non-corporate devices. Sign-in frequency controls require users to reauthenticate at regular intervals when accessing resources from unmanaged devices, limiting the risk of persistent sessions on untrusted endpoints.",
"Risk": "Without sign-in frequency controls for non-corporate devices:\n\n- **Session hijacking** - Long-lived sessions on unmanaged devices increase exposure to credential theft\n- **Unauthorized access** - Shared or compromised devices may retain access indefinitely\n- **Data leakage** - Sensitive resources remain accessible without periodic verification",
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-session-lifetime",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-session-lifetime"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to **Microsoft Entra admin center** > **Protection** > **Conditional Access**\n2. Click **+ New policy**\n3. Under **Users**, select **All users**\n4. Under **Target resources**, select **All cloud apps**\n5. Under **Conditions** > **Filter for devices**, configure to target non-corporate devices:\n - Mode: **Include filtered devices in policy**\n - Rule: `device.trustType -ne \"ServerAD\" -or device.isCompliant -ne True`\n6. Under **Session** > **Sign-in frequency**, enable and set to desired interval (e.g., 1 hour)\n7. Enable the policy",
"Terraform": ""
},
"Recommendation": {
"Text": "Configure Conditional Access policies to enforce sign-in frequency for sessions originating from non-corporate devices. This limits persistent access from unmanaged endpoints and requires users to periodically reauthenticate, reducing the risk of unauthorized access through shared or compromised devices.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_enforce_sign_in_frequency"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Conditional Access policies require Microsoft Entra ID P1 or P2 licenses. The device filter targets devices that are not Hybrid Azure AD joined or not compliant with Intune policies."
}

View File

@@ -0,0 +1,121 @@
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 (
ConditionalAccessPolicyState,
DeviceFilter,
DeviceFilterMode,
SignInFrequencyInterval,
)
class entra_conditional_access_policy_enforce_sign_in_frequency(Check):
"""Check if at least one Conditional Access policy enforces sign-in frequency for non-corporate devices.
This check evaluates whether the tenant has a Conditional Access policy that:
- Is enabled
- Targets all users and all applications
- Has sign-in frequency enabled with time-based interval
- Targets non-corporate devices via device filter
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Conditional Access Policies",
resource_id="conditionalAccessPolicies",
)
report.status = "FAIL"
report.status_extended = "No Conditional Access Policy enforces sign-in frequency for non-corporate devices."
for policy in entra_client.conditional_access_policies.values():
if policy.state == ConditionalAccessPolicyState.DISABLED:
continue
if not policy.session_controls.sign_in_frequency.is_enabled:
continue
if (
policy.session_controls.sign_in_frequency.interval
!= SignInFrequencyInterval.TIME_BASED
):
continue
if "All" not in policy.conditions.user_conditions.included_users:
continue
if (
"All"
not in policy.conditions.application_conditions.included_applications
):
continue
if not self._targets_non_corporate_devices(policy.conditions.device_filter):
continue
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' is configured to enforce sign-in frequency for non-corporate devices but is in report-only mode."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces sign-in frequency for non-corporate devices."
break
findings.append(report)
return findings
def _targets_non_corporate_devices(self, device_filter: DeviceFilter) -> bool:
"""Check if the device filter targets non-corporate devices.
Non-corporate devices are identified by targeting devices that are:
- Not Hybrid Azure AD joined (device.trustType != "ServerAD")
- Not compliant (device.isCompliant != True)
Args:
device_filter: The device filter from the conditional access policy.
Returns:
True if the filter targets non-corporate devices, False otherwise.
"""
if not device_filter or not device_filter.rule:
return False
rule = device_filter.rule.lower()
mode = device_filter.mode
# Include mode: should target non-corporate devices directly
if mode == DeviceFilterMode.INCLUDE:
targets_non_compliant = (
"device.iscompliant" in rule and "-ne" in rule and "true" in rule
)
targets_non_hybrid = (
"device.trusttype" in rule and "-ne" in rule and "serverad" in rule
)
if targets_non_compliant or targets_non_hybrid:
return True
# Exclude mode: should exclude corporate devices (equivalent to targeting non-corporate)
elif mode == DeviceFilterMode.EXCLUDE:
excludes_compliant = (
"device.iscompliant" in rule and "-eq" in rule and "true" in rule
)
excludes_hybrid = (
"device.trusttype" in rule and "-eq" in rule and "serverad" in rule
)
if excludes_compliant or excludes_hybrid:
return True
return False

View File

@@ -235,6 +235,28 @@ class Entra(M365Service):
[],
)
],
device_filter=(
DeviceFilter(
mode=(
DeviceFilterMode(
policy.conditions.devices.device_filter.mode.value.lower()
)
if policy.conditions.devices
and policy.conditions.devices.device_filter
and policy.conditions.devices.device_filter.mode
else None
),
rule=(
policy.conditions.devices.device_filter.rule
if policy.conditions.devices
and policy.conditions.devices.device_filter
else None
),
)
if policy.conditions.devices
and policy.conditions.devices.device_filter
else None
),
),
grant_controls=GrantControls(
built_in_controls=(
@@ -503,12 +525,23 @@ class ClientAppType(Enum):
OTHER_CLIENTS = "other"
class DeviceFilterMode(Enum):
INCLUDE = "include"
EXCLUDE = "exclude"
class DeviceFilter(BaseModel):
mode: Optional[DeviceFilterMode]
rule: Optional[str]
class Conditions(BaseModel):
application_conditions: Optional[ApplicationsConditions]
user_conditions: Optional[UsersConditions]
client_app_types: Optional[List[ClientAppType]]
user_risk_levels: List[RiskLevel] = []
sign_in_risk_levels: List[RiskLevel] = []
device_filter: Optional[DeviceFilter] = None
class PersistentBrowser(BaseModel):