Compare commits

...

1 Commits

Author SHA1 Message Date
HugoPBrito
56d7c24dd4 feat(m365): add entra_sign_in_frequency_for_non_corporate_devices security check
Add new security check entra_sign_in_frequency_for_non_corporate_devices for m365 provider.
Includes check implementation, metadata, and unit tests.
2026-02-23 17:05:17 +01:00
10 changed files with 669 additions and 3 deletions

View File

@@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
- `entra_sign_in_frequency_for_non_corporate_devices` check for m365 provider [(#10139)](https://github.com/prowler-cloud/prowler/pull/10139)
- `entra_app_registration_no_unused_privileged_permissions` check for m365 provider [(#10080)](https://github.com/prowler-cloud/prowler/pull/10080)
- `defenderidentity_health_issues_no_open` check for M365 provider [(#10087)](https://github.com/prowler-cloud/prowler/pull/10087)
- `organization_verified_badge` check for GitHub provider [(#10033)](https://github.com/prowler-cloud/prowler/pull/10033)

View File

@@ -1189,7 +1189,8 @@
"Id": "5.2.2.4",
"Description": "In complex deployments, organizations might have a need to restrict authentication sessions. Conditional Access policies allow for the targeting of specific user accounts. Some scenarios might include:- Resource access from an unmanaged or shared device- Access to sensitive information from an external network- High-privileged users- Business-critical applicationsEnsure Sign-in frequency periodic reauthentication does not exceed `4 hours` for E3 tenants, or `24 hours` for E5 tenants using Privileged Identity Management.Ensure `Persistent browser session` is set to `Never persistent`**Note:** This CA policy can be added to the previous CA policy in this benchmark \"Ensure multifactor authentication is enabled for all users in administrative roles\"",
"Checks": [
"entra_admin_users_sign_in_frequency_enabled"
"entra_admin_users_sign_in_frequency_enabled",
"entra_sign_in_frequency_for_non_corporate_devices"
],
"Attributes": [
{

View File

@@ -1580,7 +1580,8 @@
"Id": "5.2.2.11",
"Description": "Sign-in frequency defines the time period before a user is asked to sign in again when attempting to access a resource. Ensure sign-in frequency for Intune Enrollment is set to 'Every time'.",
"Checks": [
"entra_admin_users_sign_in_frequency_enabled"
"entra_admin_users_sign_in_frequency_enabled",
"entra_sign_in_frequency_for_non_corporate_devices"
],
"Attributes": [
{

View File

@@ -243,6 +243,7 @@
"entra_legacy_authentication_blocked",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
"entra_sign_in_frequency_for_non_corporate_devices",
"entra_users_mfa_enabled",
"exchange_organization_modern_authentication_enabled",
"exchange_transport_config_smtp_auth_disabled",
@@ -616,6 +617,7 @@
"entra_users_mfa_enabled",
"entra_managed_device_required_for_mfa_registration",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_sign_in_frequency_for_non_corporate_devices",
"entra_users_mfa_capable"
]
},
@@ -687,6 +689,7 @@
"entra_admin_users_mfa_enabled",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
"entra_sign_in_frequency_for_non_corporate_devices",
"entra_users_mfa_enabled",
"entra_identity_protection_sign_in_risk_enabled"
]

View File

@@ -805,7 +805,8 @@
"Id": "1.3.8",
"Description": "Ensure Sign-in frequency is enabled and browser sessions are not persistent for Administrative users",
"Checks": [
"entra_admin_users_sign_in_frequency_enabled"
"entra_admin_users_sign_in_frequency_enabled",
"entra_sign_in_frequency_for_non_corporate_devices"
],
"Attributes": [
{

View File

@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_sign_in_frequency_for_non_corporate_devices",
"CheckTitle": "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": "At least one Conditional Access Policy targets **all users** and **all cloud apps** with **sign-in frequency** controls enabled, reducing the session lifetime on unmanaged or non-corporate devices.",
"Risk": "Without sign-in frequency controls, sessions on non-corporate or unmanaged devices can remain active indefinitely. Attackers who steal a session token from such a device can maintain access for an extended period, increasing the risk of **data exfiltration** and **lateral movement**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-session-lifetime"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center (https://entra.microsoft.com/).\n2. Go to **Protection** > **Conditional Access** > **Policies**.\n3. Click **New policy**.\n4. Under **Users**, select **All users**.\n5. Under **Target resources**, select **All cloud apps**.\n6. Under **Session**, enable **Sign-in frequency** and set it to a value appropriate for your organization (e.g., 1 hour for unmanaged devices).\n7. Set the policy state to **On** and click **Create**.",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a Conditional Access Policy that enforces a short sign-in frequency for non-corporate devices. This limits the window of exposure from stolen tokens on unmanaged endpoints and aligns with **zero trust** principles.",
"Url": "https://hub.prowler.com/check/entra_sign_in_frequency_for_non_corporate_devices"
}
},
"Categories": [
"e3",
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}

View File

@@ -0,0 +1,60 @@
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,
SignInFrequencyInterval,
)
class entra_sign_in_frequency_for_non_corporate_devices(Check):
"""Check if at least one Conditional Access Policy enforces sign-in frequency for non-corporate devices."""
def execute(self) -> list[CheckReportM365]:
"""Execute the check for sign-in frequency enforcement on non-corporate devices.
Returns:
list[CheckReportM365]: A list containing the check result.
"""
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
or "All"
not in policy.conditions.user_conditions.included_users
or "All"
not in policy.conditions.application_conditions.included_applications
or not policy.session_controls.sign_in_frequency.is_enabled
or policy.session_controls.sign_in_frequency.interval
!= SignInFrequencyInterval.TIME_BASED
):
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}' reports sign-in frequency for non-corporate devices but does not enforce it."
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

View File

@@ -0,0 +1,562 @@
from unittest import mock
from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import (
ApplicationsConditions,
ConditionalAccessPolicyState,
Conditions,
GrantControlOperator,
GrantControls,
PersistentBrowser,
SessionControls,
SignInFrequency,
SignInFrequencyInterval,
SignInFrequencyType,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_entra_sign_in_frequency_for_non_corporate_devices:
def test_entra_no_conditional_access_policies(self):
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices import (
entra_sign_in_frequency_for_non_corporate_devices,
)
entra_client.conditional_access_policies = {}
check = entra_sign_in_frequency_for_non_corporate_devices()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy enforces sign-in frequency for non-corporate devices."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
assert result[0].location == "global"
def test_entra_policy_disabled(self):
id = str(uuid4())
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices import (
entra_sign_in_frequency_for_non_corporate_devices,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
id: ConditionalAccessPolicy(
id=id,
display_name="Test",
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=[],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[], operator=GrantControlOperator.AND
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=True,
frequency=1,
type=SignInFrequencyType.HOURS,
interval=SignInFrequencyInterval.TIME_BASED,
),
),
state=ConditionalAccessPolicyState.DISABLED,
)
}
check = entra_sign_in_frequency_for_non_corporate_devices()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy enforces sign-in frequency for non-corporate devices."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
assert result[0].location == "global"
def test_entra_policy_sign_in_frequency_disabled(self):
id = str(uuid4())
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices import (
entra_sign_in_frequency_for_non_corporate_devices,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
id: ConditionalAccessPolicy(
id=id,
display_name="Test",
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=[],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[], operator=GrantControlOperator.AND
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=None,
),
),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_sign_in_frequency_for_non_corporate_devices()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy enforces sign-in frequency for non-corporate devices."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
assert result[0].location == "global"
def test_entra_policy_not_targeting_all_users(self):
id = str(uuid4())
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices import (
entra_sign_in_frequency_for_non_corporate_devices,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
id: ConditionalAccessPolicy(
id=id,
display_name="Test",
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=[],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["some-user-id"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[], operator=GrantControlOperator.AND
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=True,
frequency=1,
type=SignInFrequencyType.HOURS,
interval=SignInFrequencyInterval.TIME_BASED,
),
),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_sign_in_frequency_for_non_corporate_devices()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy enforces sign-in frequency for non-corporate devices."
)
assert result[0].resource == {}
def test_entra_policy_not_targeting_all_applications(self):
id = str(uuid4())
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices import (
entra_sign_in_frequency_for_non_corporate_devices,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
id: ConditionalAccessPolicy(
id=id,
display_name="Test",
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["some-app-id"],
excluded_applications=[],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[], operator=GrantControlOperator.AND
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=True,
frequency=1,
type=SignInFrequencyType.HOURS,
interval=SignInFrequencyInterval.TIME_BASED,
),
),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_sign_in_frequency_for_non_corporate_devices()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy enforces sign-in frequency for non-corporate devices."
)
assert result[0].resource == {}
def test_entra_policy_every_time_interval_skipped(self):
id = str(uuid4())
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices import (
entra_sign_in_frequency_for_non_corporate_devices,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
id: ConditionalAccessPolicy(
id=id,
display_name="Test",
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=[],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[], operator=GrantControlOperator.AND
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=True,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_sign_in_frequency_for_non_corporate_devices()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy enforces sign-in frequency for non-corporate devices."
)
assert result[0].resource == {}
def test_entra_policy_enabled_for_reporting(self):
id = str(uuid4())
display_name = "Sign-in Frequency Policy"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices import (
entra_sign_in_frequency_for_non_corporate_devices,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
id: ConditionalAccessPolicy(
id=id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=[],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[], operator=GrantControlOperator.AND
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=True,
frequency=1,
type=SignInFrequencyType.HOURS,
interval=SignInFrequencyInterval.TIME_BASED,
),
),
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
)
}
check = entra_sign_in_frequency_for_non_corporate_devices()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Conditional Access Policy '{display_name}' reports sign-in frequency for non-corporate devices but does not enforce it."
)
assert (
result[0].resource
== entra_client.conditional_access_policies[id].dict()
)
assert result[0].resource_name == display_name
assert result[0].resource_id == id
assert result[0].location == "global"
def test_entra_policy_enabled_and_enforcing(self):
id = str(uuid4())
display_name = "Sign-in Frequency Policy"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_sign_in_frequency_for_non_corporate_devices.entra_sign_in_frequency_for_non_corporate_devices import (
entra_sign_in_frequency_for_non_corporate_devices,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
id: ConditionalAccessPolicy(
id=id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=[],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[], operator=GrantControlOperator.AND
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(
is_enabled=False, mode="always"
),
sign_in_frequency=SignInFrequency(
is_enabled=True,
frequency=1,
type=SignInFrequencyType.HOURS,
interval=SignInFrequencyInterval.TIME_BASED,
),
),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_sign_in_frequency_for_non_corporate_devices()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Conditional Access Policy '{display_name}' enforces sign-in frequency for non-corporate devices."
)
assert (
result[0].resource
== entra_client.conditional_access_policies[id].dict()
)
assert result[0].resource_name == display_name
assert result[0].resource_id == id
assert result[0].location == "global"