feat(m365): add CIS M365 v7.0.0 entra conditional access and session checks (#12154)

Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
This commit is contained in:
Pedro Martín
2026-08-10 11:17:19 +01:00
committed by GitHub
co-authored by Hugo P.Brito
parent 286685a4f3
commit 356036fe1f
32 changed files with 2503 additions and 7 deletions
@@ -0,0 +1 @@
7 M365 entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 Conditional Access (5.2.2.x) and idle session timeout controls
+22 -7
View File
@@ -170,7 +170,10 @@
{
"Id": "1.3.2",
"Description": "Idle session timeout allows the configuration of a setting which will timeout inactive users after a pre-determined amount of time. When a user reaches the set idle timeout session, they'll get a notification that they're about to be signed out. They must choose to stay signed in or they'll be automatically signed out of all Microsoft 365 web apps. Combined with a Conditional Access rule this will only impact unmanaged devices. A managed device is considered a device managed by Intune MDM or joined to a domain (Entra ID or Hybrid joined). The following Microsoft 365 web apps are supported. - Outlook Web App - OneDrive - SharePoint - Microsoft Fabric - Microsoft365.com and other start pages - Microsoft 365 web apps (Word, Excel, PowerPoint) - Microsoft 365 Admin Center - M365 Defender Portal - Microsoft Purview Compliance Portal The recommended setting is 3 hours (or less) for unmanaged devices. Note: Idle session timeout doesn't affect Microsoft 365 desktop and mobile apps.",
"Checks": [],
"Checks": [
"entra_idle_session_timeout_configured",
"entra_conditional_access_policy_app_enforced_restrictions"
],
"Attributes": [
{
"Section": "1 Microsoft 365 admin center",
@@ -1783,7 +1786,9 @@
{
"Id": "5.2.2.8",
"Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account. Note: While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits: - Enhanced diagnostic data - Report-only mode integration - Graph API support - Use more Conditional Access attributes like sign-in frequency in the policy",
"Checks": [],
"Checks": [
"entra_conditional_access_policy_block_high_medium_sign_in_risk"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1896,7 +1901,9 @@
{
"Id": "5.2.2.13",
"Description": "Sign-in frequency defines the time period before a user is asked to sign in again when attempting to access a resource. The Microsoft Entra ID default configuration for user sign-in frequency is a rolling window of 90 days. The recommended state for all users is to enforce periodic reauthentication for 7 days or less.",
"Checks": [],
"Checks": [
"entra_conditional_access_policy_sign_in_frequency_all_users"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1917,7 +1924,9 @@
{
"Id": "5.2.2.14",
"Description": "Microsoft Entra ID Conditional Access allows an organization to configure Named locations and configure whether those locations are trusted or untrusted. These settings provide organizations the means to specify Geographical locations for use in conditional access policies, or define actual IP addresses and IP ranges and whether or not those IP addresses and/or ranges are trusted by the organization. The recommended state is to define at least one trusted, IP range named location.",
"Checks": [],
"Checks": [
"entra_conditional_access_trusted_named_location_exists"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1938,7 +1947,9 @@
{
"Id": "5.2.2.15",
"Description": "Conditional Access Policies can be used to block access from geographic locations that are deemed out-of-scope for your organization or application. The scope and variables for this policy should be carefully examined and defined. The recommended state is to configure at least one policy to block access from untrusted locations.",
"Checks": [],
"Checks": [
"entra_conditional_access_policy_untrusted_locations_blocked"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1959,7 +1970,9 @@
{
"Id": "5.2.2.16",
"Description": "Token Protection is a Conditional Access session control that attempts to reduce token replay attacks by ensuring only device bound sign-in session tokens, like Primary Refresh Tokens (PRTs), are accepted by Microsoft Entra ID when applications request access to protected resources. When a user registers a supported device with Microsoft Entra, a PRT is issued and cryptographically bound to that device. This binding ensures that even if a threat actor steals the token, it can't be used from another device. With Token Protection enforced, Microsoft Entra validates that only these bound sign-in session tokens are used by supported applications. The recommended state is to enforce Token Protection for Office 365 Exchange Online, Office 365 SharePoint Online and Microsoft Teams Services.",
"Checks": [],
"Checks": [
"entra_conditional_access_policy_token_protection_enforced"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1980,7 +1993,9 @@
{
"Id": "5.2.2.17",
"Description": "Authentication transfer is a flow that lets users seamlessly transfer authenticated state from one device to another. For example, users might see a QR code in the desktop version of Outlook that, when scanned on their mobile device, transfers their authenticated state to the mobile device. The recommended state is to block Authentication transfer.",
"Checks": [],
"Checks": [
"entra_conditional_access_policy_authentication_transfer_blocked"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_authentication_transfer_blocked",
"CheckTitle": "Conditional Access policy blocks authentication transfer",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "At least one enabled Conditional Access policy should target the **authentication transfer** flow for **all users** and **all resources** and set the grant control to **Block**. Authentication transfer lets a user move an authenticated session from one device to another (for example, by scanning a QR code), which can be abused to move a session onto an attacker-controlled device.",
"Risk": "If authentication transfer is not blocked, an attacker who can present a transfer prompt (e.g., a malicious QR code) may capture an authenticated session and gain access to the victim's resources without needing their credentials or **MFA**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-conditions"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include **All users** (exclude only documented break-glass accounts)\n4. Under **Target resources**, select **All resources**\n5. Under **Conditions** > **Authentication flows**, enable and select **Authentication transfer**\n6. Under **Grant**, select **Block access**\n7. Set the policy to **On** and click **Create**",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a Conditional Access policy that blocks the authentication transfer flow for all users and all resources, excluding only documented break-glass accounts.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_authentication_transfer_blocked"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,62 @@
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 (
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
TransferMethod,
)
class entra_conditional_access_policy_authentication_transfer_blocked(Check):
"""Check if at least one Conditional Access policy blocks authentication transfer.
This check ensures that at least one enabled Conditional Access policy targets
the authentication transfer flow and blocks access, preventing an authenticated
session from being seamlessly transferred to another (potentially attacker
controlled) device.
- PASS: An enabled Conditional Access policy blocks authentication transfer.
- FAIL: No Conditional Access policy restricts authentication transfer.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the check to verify authentication transfer is blocked by a Conditional Access policy.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for policy in entra_client.conditional_access_policies.values():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' does not block authentication transfer."
authentication_flows = policy.conditions.authentication_flows
blocks_authentication_transfer = (
policy.state != ConditionalAccessPolicyState.DISABLED
and "All" in policy.conditions.user_conditions.included_users
and "All"
in policy.conditions.application_conditions.included_applications
and not policy.conditions.application_conditions.excluded_applications
and authentication_flows
and TransferMethod.AUTHENTICATION_TRANSFER
in authentication_flows.transfer_methods
and ConditionalAccessGrantControl.BLOCK
in policy.grant_controls.built_in_controls
)
if blocks_authentication_transfer:
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' reports authentication transfer but does not block it."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks authentication transfer."
findings.append(report)
return findings
@@ -0,0 +1,38 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_block_high_medium_sign_in_risk",
"CheckTitle": "Conditional Access policy blocks high and medium sign-in risk",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "At least one enabled Conditional Access policy should target **all users** and **all resources** with the **sign-in risk** condition set to **High** and **Medium** and the grant control set to **Block**. Microsoft Entra ID Protection evaluates sign-in risk in real time; blocking risky sign-ins prevents access from sessions that are likely not performed by the legitimate account owner.",
"Risk": "Without a **sign-in risk** based **Conditional Access** policy, sign-ins flagged as high or medium risk (indicative of compromised credentials, anomalous locations, or malicious IPs) are allowed, giving attackers a path to access resources with stolen credentials.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/id-protection/concept-identity-protection-policies",
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-conditions#sign-in-risk"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include **All users** (exclude only documented break-glass accounts)\n4. Under **Target resources**, select **All resources**\n5. Under **Conditions** > **Sign-in risk**, select **High** and **Medium**\n6. Under **Grant**, select **Block access**\n7. Set the policy to **On** and click **Create**",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a risk-based Conditional Access policy that blocks high and medium sign-in risk for all users and all resources, excluding only documented break-glass accounts. Microsoft recommends configuring risk-based policies in Conditional Access rather than in the legacy Identity Protection policies.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_block_high_medium_sign_in_risk"
}
},
"Categories": [
"identity-access",
"e5"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,80 @@
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 (
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
RiskLevel,
)
class entra_conditional_access_policy_block_high_medium_sign_in_risk(Check):
"""Check if a Conditional Access policy blocks high and medium sign-in risk.
This check ensures that at least one enabled Conditional Access policy targets
all users and all resources with the sign-in risk condition set to High and
Medium, and blocks access, mitigating risky sign-in attempts detected by
Microsoft Entra ID Protection.
- PASS: An enabled Conditional Access policy blocks high and medium sign-in risk.
- FAIL: No Conditional Access policy blocks high and medium sign-in risk.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the check to verify high and medium sign-in risk is blocked.
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 blocks high and medium sign-in risk."
)
for policy in entra_client.conditional_access_policies.values():
if policy.state == ConditionalAccessPolicyState.DISABLED:
continue
if "All" not in policy.conditions.user_conditions.included_users:
continue
if (
"All"
not in policy.conditions.application_conditions.included_applications
):
continue
if policy.conditions.application_conditions.excluded_applications:
continue
if not {RiskLevel.HIGH, RiskLevel.MEDIUM}.issubset(
set(policy.conditions.sign_in_risk_levels)
):
continue
if (
ConditionalAccessGrantControl.BLOCK
in policy.grant_controls.built_in_controls
):
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 high and medium sign-in risk but does not block it."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks high and medium sign-in risk."
break
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_sign_in_frequency_all_users",
"CheckTitle": "Conditional Access policy enforces sign-in frequency for all users",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "At least one enabled Conditional Access policy should target **all users** and **all resources** and enforce a **sign-in frequency of 7 days or less**. Sign-in frequency defines how long an authenticated session remains valid before the user must reauthenticate. The Microsoft Entra default is a rolling 90-day window, which is too permissive.",
"Risk": "Long-lived sessions increase the window in which a stolen token or hijacked session can be used. Without an enforced reauthentication interval, a compromised session can remain valid for up to 90 days, extending an attacker's access.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-session-lifetime"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include **All users** (exclude only documented break-glass accounts)\n4. Under **Target resources**, select **All resources**\n5. Under **Session**, enable **Sign-in frequency** and set it to **7 days** or less\n6. Set the policy to **On** and click **Create**",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a Conditional Access policy that enforces a sign-in frequency of 7 days or less for all users and all resources, excluding only documented break-glass accounts.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_sign_in_frequency_all_users"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,146 @@
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 (
ClientAppType,
ConditionalAccessPolicyState,
SignInFrequencyInterval,
SignInFrequencyType,
)
# Maximum allowed reauthentication window (CIS: 7 days or less).
MAX_SIGN_IN_FREQUENCY_DAYS = 7
MAX_SIGN_IN_FREQUENCY_HOURS = MAX_SIGN_IN_FREQUENCY_DAYS * 24
class entra_conditional_access_policy_sign_in_frequency_all_users(Check):
"""Check if a Conditional Access policy enforces sign-in frequency for all users.
This check ensures that at least one enabled Conditional Access policy targets
all users and all resources and enforces a sign-in frequency of 7 days or less,
limiting how long an authenticated session remains valid before reauthentication.
- PASS: An enabled Conditional Access policy enforces sign-in frequency of 7 days
or less for all users.
- FAIL: No Conditional Access policy enforces sign-in frequency of 7 days or less
for all users.
"""
def _is_within_limit(self, sign_in_frequency) -> bool:
"""Return True if the sign-in frequency is 7 days or less."""
if not sign_in_frequency or not sign_in_frequency.is_enabled:
return False
if sign_in_frequency.interval == SignInFrequencyInterval.EVERY_TIME:
return True
if sign_in_frequency.frequency is None:
return False
if sign_in_frequency.type == SignInFrequencyType.DAYS:
return sign_in_frequency.frequency <= MAX_SIGN_IN_FREQUENCY_DAYS
if sign_in_frequency.type == SignInFrequencyType.HOURS:
return sign_in_frequency.frequency <= MAX_SIGN_IN_FREQUENCY_HOURS
return False
def _has_restricted_scope(self, conditions) -> bool:
"""Return True when policy conditions exclude part of the session scope."""
platforms = conditions.platform_conditions
platform_restricted = bool(
platforms
and (
platforms.exclude_platforms
or (
platforms.include_platforms
and "all" not in platforms.include_platforms
)
)
)
client_app_types = conditions.client_app_types or []
client_app_restricted = bool(
client_app_types and ClientAppType.ALL not in client_app_types
)
locations = conditions.locations
location_restricted = bool(
locations
and (
locations.exclude_locations
or (
locations.include_locations
and "All" not in locations.include_locations
)
)
)
device_conditions = conditions.device_conditions
device_restricted = bool(
device_conditions
and (
device_conditions.device_filter_mode
or device_conditions.device_filter_rule
)
)
authentication_flows = conditions.authentication_flows
authentication_flow_restricted = bool(
authentication_flows and authentication_flows.transfer_methods
)
user_conditions = conditions.user_conditions
user_restricted = bool(
user_conditions.excluded_users
or user_conditions.excluded_groups
or user_conditions.excluded_roles
or user_conditions.excluded_guests_or_external_users
)
application_restricted = bool(
conditions.application_conditions.included_user_actions
)
return bool(
platform_restricted
or client_app_restricted
or location_restricted
or device_restricted
or authentication_flow_restricted
or conditions.insider_risk_levels
or user_restricted
or application_restricted
)
def execute(self) -> list[CheckReportM365]:
"""Execute the check to verify sign-in frequency is enforced for all users.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for policy in entra_client.conditional_access_policies.values():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' does not enforce a sign-in frequency of 7 days or less for all users."
enforces_sign_in_frequency = (
policy.state != ConditionalAccessPolicyState.DISABLED
and "All" in policy.conditions.user_conditions.included_users
and "All"
in policy.conditions.application_conditions.included_applications
and not policy.conditions.application_conditions.excluded_applications
and not policy.conditions.sign_in_risk_levels
and not policy.conditions.user_risk_levels
and not self._has_restricted_scope(policy.conditions)
and self._is_within_limit(policy.session_controls.sign_in_frequency)
)
if enforces_sign_in_frequency:
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces sign-in frequency but is in report-only mode."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces a sign-in frequency of 7 days or less for all users."
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_token_protection_enforced",
"CheckTitle": "Conditional Access policy enforces Token Protection",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "At least one enabled Conditional Access policy should enforce **Token Protection** (the **secureSignInSession** session control) for targeted users. Token Protection binds sign-in session tokens (such as Primary Refresh Tokens) to the device, reducing the effectiveness of token theft and replay attacks.",
"Risk": "Without **Token Protection**, stolen sign-in tokens can be replayed from an attacker's device to impersonate the user and bypass authentication, including **MFA**, because the token is not cryptographically bound to the original device.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-token-protection"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include the targeted users (exclude only documented break-glass accounts)\n4. Under **Target resources**, select the supported applications (e.g., Exchange Online, SharePoint Online)\n5. Under **Conditions** > **Device platforms**, include Windows\n6. Under **Session**, enable **Require token protection for sign-in sessions**\n7. Set the policy to **On** and click **Create**",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a Conditional Access policy that enforces Token Protection (secure sign-in session) for supported applications on Windows devices to mitigate token theft and replay.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_token_protection_enforced"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,115 @@
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 (
ClientAppType,
ConditionalAccessPolicyState,
)
# Applications that must be covered by the Token Protection policy.
EXCHANGE_ONLINE_APP_ID = "00000002-0000-0ff1-ce00-000000000000"
SHAREPOINT_ONLINE_APP_ID = "00000003-0000-0ff1-ce00-000000000000"
TEAMS_APP_ID = "cc15fd57-2c6c-4117-a88c-83b1d56b4bbe"
REQUIRED_APP_IDS = {EXCHANGE_ONLINE_APP_ID, SHAREPOINT_ONLINE_APP_ID, TEAMS_APP_ID}
class entra_conditional_access_policy_token_protection_enforced(Check):
"""Check if a Conditional Access policy enforces Token Protection.
Token Protection is a Conditional Access session control that reduces token
replay attacks by requiring device-bound sign-in session tokens. At least one
enabled Conditional Access policy should enable the ``secureSignInSession``
session control for the supported applications (Exchange Online, SharePoint
Online, Teams), target the Windows platform, and apply to mobile apps and desktop
clients.
- PASS: An enabled Conditional Access policy enforces Token Protection for the
supported applications on Windows desktop/mobile clients.
- FAIL: No Conditional Access policy enforces Token Protection with the required
scope.
"""
def _applications_covered(
self, included_applications, excluded_applications
) -> bool:
"""Check whether the policy covers every required application.
Args:
included_applications: Application IDs included by the policy.
excluded_applications: Application IDs excluded by the policy.
Returns:
True if all required applications are included and none are excluded.
"""
if REQUIRED_APP_IDS.intersection(excluded_applications):
return False
if "All" in included_applications:
return True
return REQUIRED_APP_IDS.issubset(set(included_applications))
def _windows_targeted(self, conditions) -> bool:
"""Check whether the policy targets Windows devices.
Args:
conditions: Conditional Access policy conditions.
Returns:
True if Windows is included in the targeted platforms.
"""
platform_conditions = conditions.platform_conditions
if not platform_conditions:
return False
return "windows" in platform_conditions.include_platforms
def _desktop_clients_targeted(self, conditions) -> bool:
"""Check whether the policy targets desktop-capable client apps.
Args:
conditions: Conditional Access policy conditions.
Returns:
True if all clients or mobile apps and desktop clients are targeted.
"""
client_app_types = conditions.client_app_types or []
return (
ClientAppType.ALL in client_app_types
or ClientAppType.MOBILE_APPS_AND_DESKTOP_CLIENTS in client_app_types
)
def execute(self) -> list[CheckReportM365]:
"""Execute the Token Protection Conditional Access policy check.
Returns:
A list containing the Token Protection policy evaluation report.
"""
findings = []
for policy in entra_client.conditional_access_policies.values():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' does not enforce Token Protection for the supported applications on Windows desktop and mobile clients."
enforces_token_protection = (
policy.state != ConditionalAccessPolicyState.DISABLED
and policy.conditions.user_conditions.included_users
and policy.session_controls.secure_sign_in_session_enabled
and self._applications_covered(
policy.conditions.application_conditions.included_applications,
policy.conditions.application_conditions.excluded_applications,
)
and self._windows_targeted(policy.conditions)
and self._desktop_clients_targeted(policy.conditions)
)
if enforces_token_protection:
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status_extended = f"Conditional Access Policy '{policy.display_name}' enables Token Protection but is in report-only mode."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces Token Protection for the supported applications on Windows desktop and mobile clients."
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_untrusted_locations_blocked",
"CheckTitle": "Conditional Access policy blocks access from untrusted locations",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "At least one enabled Conditional Access policy should target **all users** and **all resources**, include **all network locations**, exclude **trusted locations**, and set the grant control to **Block**. This blocks access originating from locations that are not explicitly trusted by the organization.",
"Risk": "Without a location-based block policy, users can authenticate from any geography or network, including regions where the organization has no presence, giving attackers operating from those locations a viable path to access resources with stolen credentials.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-assignment-network"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Policies** > **New policy**\n3. Under **Users**, include **All users** (exclude only documented break-glass accounts)\n4. Under **Target resources**, select **All resources**\n5. Under **Network**, include **Any location** and exclude **All trusted locations** (or selected trusted named locations)\n6. Under **Grant**, select **Block access**\n7. Set the policy to **On** and click **Create**",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a Conditional Access policy that blocks access from all locations except trusted named locations, targeting all users and resources, excluding only documented break-glass accounts.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_untrusted_locations_blocked"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,118 @@
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 (
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
)
class entra_conditional_access_policy_untrusted_locations_blocked(Check):
"""Check if a Conditional Access policy blocks access from untrusted locations.
At least one enabled Conditional Access policy should target all users and all
resources, include all network locations, exclude trusted locations, and block
access, so that access from untrusted locations is denied.
- PASS: An enabled Conditional Access policy blocks access from untrusted
locations.
- FAIL: No Conditional Access policy blocks access from untrusted locations.
"""
def _excludes_only_trusted(self, exclude_locations, trusted_location_ids) -> bool:
"""Check whether every excluded location is trusted.
Args:
exclude_locations: Location IDs excluded by the policy.
trusted_location_ids: IDs of collected trusted named locations.
Returns:
True if at least one location is excluded and all exclusions are trusted.
"""
if not exclude_locations:
return False
return all(
location_id == "AllTrusted" or location_id in trusted_location_ids
for location_id in exclude_locations
)
def execute(self) -> list[CheckReportM365]:
"""Execute the untrusted-locations Conditional Access policy check.
Returns:
A list containing the untrusted-locations policy evaluation report.
"""
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 blocks access from untrusted locations."
)
trusted_location_ids = {
location.id
for location in entra_client.named_locations
if location.is_trusted
}
untrusted_location_ids = {
location.id
for location in entra_client.named_locations
if not location.is_trusted
}
for policy in entra_client.conditional_access_policies.values():
if policy.state == ConditionalAccessPolicyState.DISABLED:
continue
if "All" not in policy.conditions.user_conditions.included_users:
continue
if (
"All"
not in policy.conditions.application_conditions.included_applications
or policy.conditions.application_conditions.excluded_applications
):
continue
locations = policy.conditions.locations
if not locations:
continue
if "All" not in locations.include_locations and not any(
location_id in untrusted_location_ids
for location_id in locations.include_locations
):
continue
# A trusted-location exclusion must exist so trusted networks keep access.
if not self._excludes_only_trusted(
locations.exclude_locations, trusted_location_ids
):
continue
if (
ConditionalAccessGrantControl.BLOCK
not in policy.grant_controls.built_in_controls
):
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}' blocks untrusted locations but is in report-only mode."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks access from untrusted locations."
break
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_trusted_named_location_exists",
"CheckTitle": "A trusted IP-range named location is defined",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Conditional Access **named locations** let an organization define **trusted** IP ranges that can be referenced by Conditional Access policies. At least one **IP-range** named location should be marked as **Trusted** and have at least one IP range defined.",
"Risk": "Without a trusted named location, **Conditional Access** policies cannot differentiate corporate networks from untrusted networks, limiting the ability to build location-aware controls such as blocking access from untrusted locations.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-assignment-network"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Conditional Access** > **Named locations**\n3. Create a **new IP ranges location**, add the organization's public IP ranges\n4. Mark the location as **Trusted**\n5. Click **Create**",
"Terraform": ""
},
"Recommendation": {
"Text": "Define at least one trusted IP-range named location representing the organization's corporate networks so it can be referenced by location-aware Conditional Access policies.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_trusted_named_location_exists"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,46 @@
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
class entra_conditional_access_trusted_named_location_exists(Check):
"""Check if at least one trusted IP-range named location is defined.
Conditional Access named locations let an organization define trusted IP ranges.
At least one IP-range named location should be marked as trusted and have at
least one IP range defined.
- PASS: A trusted IP-range named location with at least one IP range exists.
- FAIL: No trusted IP-range named location with an IP range is defined.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the trusted named location check.
Returns:
A list containing the trusted named location evaluation report.
"""
findings = []
for location in entra_client.named_locations:
location_name = location.display_name or location.id
report = CheckReportM365(
metadata=self.metadata(),
resource=location,
resource_name=location.display_name or "Named Location",
resource_id=location.id,
)
report.status = "FAIL"
report.status_extended = f"Named location '{location_name}' is not a trusted IP-range location with at least one IP range."
if (
location.is_ip_location
and location.is_trusted
and location.ip_ranges_count >= 1
):
report.status = "PASS"
report.status_extended = (
f"Trusted IP-range named location '{location_name}' "
f"is defined with {location.ip_ranges_count} IP range(s)."
)
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_idle_session_timeout_configured",
"CheckTitle": "Idle session timeout of 3 hours or less is configured",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "An activity-based timeout policy should sign out inactive users from Microsoft 365 web apps after a period of inactivity. The web session idle timeout (**WebSessionIdleTimeout**) should be set to **3 hours or less** so that unattended sessions are terminated.",
"Risk": "Without an idle session timeout, an unattended and unlocked session on a shared or public device remains authenticated indefinitely, allowing anyone with physical access to act as the signed-in user.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/microsoft-365/admin/manage/idle-session-timeout-web-apps"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft 365 admin center at https://admin.microsoft.com/\n2. Go to **Settings** > **Org settings** > **Security & Privacy** > **Idle session timeout**\n3. Turn on the setting and set the period of inactivity to **3 hours** or less\n4. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Configure an idle session timeout of 3 hours or less so inactive users are automatically signed out of Microsoft 365 web apps.",
"Url": "https://hub.prowler.com/check/entra_idle_session_timeout_configured"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,70 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
# CIS recommends an idle session timeout of 3 hours or less.
MAX_IDLE_TIMEOUT_SECONDS = 3 * 60 * 60
class entra_idle_session_timeout_configured(Check):
"""Check if an idle session timeout of 3 hours or less is configured.
An activity-based timeout policy should sign out inactive users from Microsoft
365 web apps after a period of inactivity. The web session idle timeout should be
set to 3 hours or less.
- PASS: An activity-based timeout policy enforces an idle timeout of 3 hours or
less.
- FAIL: No activity-based timeout policy enforces an idle timeout of 3 hours or
less.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the idle session timeout policy check.
Returns:
A list of reports for the collected activity-based timeout policies.
"""
findings = []
policies = entra_client.activity_based_timeout_policies
if not policies:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Activity Based Timeout Policies",
resource_id="activityBasedTimeoutPolicies",
)
report.status = "FAIL"
report.status_extended = (
"No activity-based timeout policy enforces an idle session timeout of "
"3 hours or less."
)
findings.append(report)
return findings
for policy in policies:
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name or "Activity Based Timeout Policy",
resource_id=policy.id,
)
timeout = policy.web_session_idle_timeout_seconds
if timeout is not None and timeout <= MAX_IDLE_TIMEOUT_SECONDS:
report.status = "PASS"
report.status_extended = (
f"Activity-based timeout policy '{policy.display_name or policy.id}' "
f"enforces an idle session timeout of {timeout // 60} minutes and "
f"{timeout % 60} seconds."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Activity-based timeout policy '{policy.display_name or policy.id}' "
"does not enforce an idle session timeout of 3 hours or less."
)
findings.append(report)
return findings
@@ -105,6 +105,8 @@ class Entra(M365Service):
self._get_device_registration_policy(),
self._get_directory_settings(),
self._get_b2b_collaboration_policy(),
self._get_activity_based_timeout_policies(),
self._get_named_locations(),
)
)
@@ -130,6 +132,10 @@ class Entra(M365Service):
)
self.directory_settings: Dict[str, Dict[str, str]] = attributes[14]
self.b2b_collaboration_policy: Optional[B2BCollaborationPolicy] = attributes[15]
self.activity_based_timeout_policies: List[ActivityBasedTimeoutPolicy] = (
attributes[16]
)
self.named_locations: List[NamedLocation] = attributes[17]
self.user_accounts_status = {}
# Resolve directory-object identifiers referenced by Conditional Access
@@ -394,6 +400,24 @@ class Entra(M365Service):
authentication_flows=self._parse_authentication_flows(
raw_auth_flows_map.get(policy.id)
),
locations=LocationsCondition(
include_locations=list(
getattr(
getattr(policy.conditions, "locations", None),
"include_locations",
[],
)
or []
),
exclude_locations=list(
getattr(
getattr(policy.conditions, "locations", None),
"exclude_locations",
[],
)
or []
),
),
device_conditions=DeviceConditions(
device_filter_mode=(
DeviceFilterMode(
@@ -524,6 +548,19 @@ class Entra(M365Service):
else False
),
),
secure_sign_in_session_enabled=bool(
getattr(
getattr(
policy.session_controls,
"secure_sign_in_session",
None,
),
"is_enabled",
False,
)
if policy.session_controls
else False
),
),
state=ConditionalAccessPolicyState(
getattr(policy, "state", "disabled")
@@ -1308,6 +1345,151 @@ OAuthAppInfo
)
return b2b_policy
async def _get_named_locations(self):
"""Retrieve Conditional Access named locations from Microsoft Entra.
Fetches ``identity/conditionalAccess/namedLocations`` as raw JSON to handle
the polymorphic ipNamedLocation / countryNamedLocation types and extract
their trust and IP-range attributes.
Returns:
List[NamedLocation]: The parsed named locations.
"""
logger.info("Entra - Getting named locations...")
named_locations = []
try:
request_info = (
self.client.identity.conditional_access.named_locations.to_get_request_information()
)
raw_locations = []
while True:
response = await self.client.request_adapter.send_primitive_async(
request_info, "bytes", {}
)
if not response:
break
data = json.loads(response)
page = data.get("value", []) or []
raw_locations.extend(page)
next_link = data.get("@odata.nextLink") or data.get("nextLink")
if not next_link:
break
request_info = (
self.client.identity.conditional_access.named_locations.with_url(
next_link
).to_get_request_information()
)
for location in raw_locations:
odata_type = location.get("@odata.type", "")
ip_ranges = location.get("ipRanges", []) or []
named_locations.append(
NamedLocation(
id=location.get("id", ""),
display_name=location.get("displayName"),
is_trusted=bool(location.get("isTrusted", False)),
is_ip_location="ipNamedLocation" in odata_type,
ip_ranges_count=len(ip_ranges),
)
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return named_locations
@staticmethod
def _parse_timespan_to_seconds(value) -> "Optional[int]":
"""Parse a .NET TimeSpan string (``[d.]hh:mm:ss[.fffffff]``) to seconds.
Args:
value: The TimeSpan string (e.g. "03:00:00" or "1.00:00:00").
Returns:
The total number of seconds, or None if the value cannot be parsed.
"""
if not value or not isinstance(value, str):
return None
try:
days = 0
remainder = value.strip()
if remainder.startswith("-"):
return None
head = remainder.split(":", 1)[0]
# A dot in the hours component denotes the days separator (d.hh).
if "." in head:
day_part, remainder = remainder.split(".", 1)
days = int(day_part)
hours, minutes, seconds = remainder.split(":")
# Seconds may carry fractional digits (ss.fffffff); truncate them.
seconds = seconds.split(".")[0]
hours = int(hours)
minutes = int(minutes)
seconds = int(seconds)
if days < 0 or not 0 <= hours <= 23:
return None
if not 0 <= minutes <= 59 or not 0 <= seconds <= 59:
return None
return days * 86400 + hours * 3600 + minutes * 60 + seconds
except (ValueError, AttributeError):
return None
async def _get_activity_based_timeout_policies(self):
"""Retrieve activity-based (idle session) timeout policies from Entra.
Fetches ``policies/activityBasedTimeoutPolicies`` and parses each policy's
``definition`` JSON to extract the ``WebSessionIdleTimeout`` for the idle
session timeout check.
Returns:
List[ActivityBasedTimeoutPolicy]: The parsed timeout policies.
"""
logger.info("Entra - Getting activity based timeout policies...")
policies = []
try:
policies_builder = self.client.policies.activity_based_timeout_policies
response = await policies_builder.get()
while response:
for policy in getattr(response, "value", []) or []:
idle_timeout_seconds = None
for definition in getattr(policy, "definition", []) or []:
try:
parsed = json.loads(definition)
except (TypeError, ValueError):
continue
app_policies = (
parsed.get("ActivityBasedTimeoutPolicy", {}).get(
"ApplicationPolicies", []
)
or []
)
for app_policy in app_policies:
if app_policy.get("ApplicationId") != "default":
continue
seconds = self._parse_timespan_to_seconds(
app_policy.get("WebSessionIdleTimeout")
)
if seconds is not None:
idle_timeout_seconds = seconds
break
if idle_timeout_seconds is not None:
break
policies.append(
ActivityBasedTimeoutPolicy(
id=getattr(policy, "id", ""),
display_name=getattr(policy, "display_name", None),
web_session_idle_timeout_seconds=idle_timeout_seconds,
)
)
next_link = getattr(response, "odata_next_link", None)
if not next_link:
break
response = await policies_builder.with_url(next_link).get()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return policies
async def _get_directory_settings(self):
"""Retrieve tenant directory (group) settings from Microsoft Entra.
@@ -1967,6 +2149,13 @@ class AuthenticationFlows(BaseModel):
transfer_methods: List[TransferMethod] = []
class LocationsCondition(BaseModel):
"""Model representing location conditions for Conditional Access policies."""
include_locations: List[str] = []
exclude_locations: List[str] = []
class Conditions(BaseModel):
"""Model representing conditions for Conditional Access policies."""
@@ -1979,6 +2168,7 @@ class Conditions(BaseModel):
platform_conditions: Optional[PlatformConditions] = None
authentication_flows: Optional[AuthenticationFlows] = None
device_conditions: Optional[DeviceConditions] = None
locations: Optional[LocationsCondition] = None
class PersistentBrowser(BaseModel):
@@ -2015,6 +2205,7 @@ class SessionControls(BaseModel):
persistent_browser: PersistentBrowser
sign_in_frequency: SignInFrequency
application_enforced_restrictions: Optional[ApplicationEnforcedRestrictions] = None
secure_sign_in_session_enabled: bool = False
class ConditionalAccessGrantControl(Enum):
@@ -2095,6 +2286,24 @@ class DeviceRegistrationPolicy(BaseModel):
local_admin_password_enabled: Optional[bool] = None
class ActivityBasedTimeoutPolicy(BaseModel):
"""Activity-based (idle session) timeout policy."""
id: str
display_name: Optional[str] = None
web_session_idle_timeout_seconds: Optional[int] = None
class NamedLocation(BaseModel):
"""Conditional Access named location."""
id: str
display_name: Optional[str] = None
is_trusted: bool = False
is_ip_location: bool = False
ip_ranges_count: int = 0
class B2BCollaborationPolicy(BaseModel):
"""Legacy B2B collaboration (invitation domains) policy."""
@@ -0,0 +1,149 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
ApplicationsConditions,
AuthenticationFlows,
ConditionalAccessGrantControl,
ConditionalAccessPolicy,
ConditionalAccessPolicyState,
Conditions,
GrantControlOperator,
GrantControls,
PersistentBrowser,
SessionControls,
SignInFrequency,
TransferMethod,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_authentication_transfer_blocked.entra_conditional_access_policy_authentication_transfer_blocked"
def _make_policy(
policy_id="policy-1",
display_name="Block Authentication Transfer",
state=ConditionalAccessPolicyState.ENABLED,
included_users=None,
included_applications=None,
excluded_applications=None,
transfer_methods=None,
built_in_controls=None,
):
return ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=included_applications or ["All"],
excluded_applications=excluded_applications or [],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=included_users or ["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
client_app_types=[],
authentication_flows=AuthenticationFlows(
transfer_methods=(
transfer_methods
if transfer_methods is not None
else [TransferMethod.AUTHENTICATION_TRANSFER]
)
),
),
grant_controls=GrantControls(
built_in_controls=(
built_in_controls
if built_in_controls is not None
else [ConditionalAccessGrantControl.BLOCK]
),
operator=GrantControlOperator.OR,
authentication_strength=None,
),
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=state,
)
class Test_entra_conditional_access_policy_authentication_transfer_blocked:
def _run(self, policies):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_authentication_transfer_blocked.entra_conditional_access_policy_authentication_transfer_blocked import (
entra_conditional_access_policy_authentication_transfer_blocked,
)
entra_client.conditional_access_policies = policies
entra_client.tenant_domain = DOMAIN
return (
entra_conditional_access_policy_authentication_transfer_blocked().execute()
)
def test_no_resources(self):
result = self._run({})
assert len(result) == 0
def test_policy_blocks_authentication_transfer(self):
policy = _make_policy()
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Conditional Access Policy 'Block Authentication Transfer' blocks authentication transfer."
)
def test_multiple_policies_each_produce_a_report(self):
blocking_policy = _make_policy(policy_id="policy-blocking")
non_blocking_policy = _make_policy(
policy_id="policy-non-blocking",
display_name="Allow Authentication Transfer",
built_in_controls=[ConditionalAccessGrantControl.MFA],
)
result = self._run(
{
blocking_policy.id: blocking_policy,
non_blocking_policy.id: non_blocking_policy,
}
)
assert len(result) == 2
assert [report.resource_id for report in result] == [
"policy-blocking",
"policy-non-blocking",
]
assert [report.status for report in result] == ["PASS", "FAIL"]
def test_policy_excluding_application_does_not_cover_all_applications(self):
policy = _make_policy(excluded_applications=["excluded-app"])
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_policy_report_only(self):
policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING)
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
def test_policy_does_not_block(self):
policy = _make_policy(built_in_controls=[ConditionalAccessGrantControl.MFA])
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
@@ -0,0 +1,119 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
ApplicationsConditions,
ConditionalAccessGrantControl,
ConditionalAccessPolicy,
ConditionalAccessPolicyState,
Conditions,
GrantControlOperator,
GrantControls,
PersistentBrowser,
RiskLevel,
SessionControls,
SignInFrequency,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_high_medium_sign_in_risk.entra_conditional_access_policy_block_high_medium_sign_in_risk"
def _make_policy(
policy_id="policy-1",
display_name="Block Risky Sign-ins",
state=ConditionalAccessPolicyState.ENABLED,
included_users=None,
included_applications=None,
sign_in_risk_levels=None,
built_in_controls=None,
):
return ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=included_applications or ["All"],
excluded_applications=[],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=included_users or ["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
client_app_types=[],
sign_in_risk_levels=(
sign_in_risk_levels
if sign_in_risk_levels is not None
else [RiskLevel.HIGH, RiskLevel.MEDIUM]
),
),
grant_controls=GrantControls(
built_in_controls=(
built_in_controls
if built_in_controls is not None
else [ConditionalAccessGrantControl.BLOCK]
),
operator=GrantControlOperator.OR,
authentication_strength=None,
),
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=state,
)
class Test_entra_conditional_access_policy_block_high_medium_sign_in_risk:
def _run(self, policies):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_high_medium_sign_in_risk.entra_conditional_access_policy_block_high_medium_sign_in_risk import (
entra_conditional_access_policy_block_high_medium_sign_in_risk,
)
entra_client.conditional_access_policies = policies
entra_client.tenant_domain = DOMAIN
return (
entra_conditional_access_policy_block_high_medium_sign_in_risk().execute()
)
def test_no_policies(self):
result = self._run({})
assert len(result) == 1
assert result[0].status == "FAIL"
def test_policy_blocks_high_medium(self):
policy = _make_policy()
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Conditional Access Policy 'Block Risky Sign-ins' blocks high and medium sign-in risk."
)
def test_policy_only_high(self):
policy = _make_policy(sign_in_risk_levels=[RiskLevel.HIGH])
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
def test_policy_report_only(self):
policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING)
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
@@ -0,0 +1,263 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
ApplicationsConditions,
AuthenticationFlows,
ClientAppType,
ConditionalAccessPolicy,
ConditionalAccessPolicyState,
Conditions,
DeviceConditions,
DeviceFilterMode,
GrantControlOperator,
GrantControls,
InsiderRiskLevel,
LocationsCondition,
PersistentBrowser,
PlatformConditions,
RiskLevel,
SessionControls,
SignInFrequency,
SignInFrequencyInterval,
SignInFrequencyType,
TransferMethod,
UserAction,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_sign_in_frequency_all_users.entra_conditional_access_policy_sign_in_frequency_all_users"
def _make_policy(
policy_id="policy-1",
display_name="Sign-in Frequency",
state=ConditionalAccessPolicyState.ENABLED,
included_users=None,
included_applications=None,
excluded_applications=None,
is_enabled=True,
frequency=1,
freq_type=SignInFrequencyType.DAYS,
interval=SignInFrequencyInterval.TIME_BASED,
):
return ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=included_applications or ["All"],
excluded_applications=excluded_applications or [],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=included_users or ["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
client_app_types=[],
),
grant_controls=GrantControls(
built_in_controls=[],
operator=GrantControlOperator.AND,
authentication_strength=None,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(is_enabled=False, mode="always"),
sign_in_frequency=SignInFrequency(
is_enabled=is_enabled,
frequency=frequency,
type=freq_type,
interval=interval,
),
),
state=state,
)
class Test_entra_conditional_access_policy_sign_in_frequency_all_users:
def _run(self, policies):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_sign_in_frequency_all_users.entra_conditional_access_policy_sign_in_frequency_all_users import (
entra_conditional_access_policy_sign_in_frequency_all_users,
)
entra_client.conditional_access_policies = policies
entra_client.tenant_domain = DOMAIN
return (
entra_conditional_access_policy_sign_in_frequency_all_users().execute()
)
def test_no_resources(self):
result = self._run({})
assert len(result) == 0
def test_policy_7_days(self):
policy = _make_policy(frequency=7, freq_type=SignInFrequencyType.DAYS)
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "PASS"
def test_policy_every_time(self):
policy = _make_policy(interval=SignInFrequencyInterval.EVERY_TIME)
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "PASS"
def test_policy_excluding_application_does_not_cover_all_applications(self):
policy = _make_policy(excluded_applications=["excluded-app"])
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_policy_too_long(self):
policy = _make_policy(frequency=30, freq_type=SignInFrequencyType.DAYS)
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
def test_policy_disabled_frequency(self):
policy = _make_policy(
is_enabled=False, frequency=None, freq_type=None, interval=None
)
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
def test_policy_report_only(self):
policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING)
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
assert "report-only mode" in result[0].status_extended
def test_policy_risk_scoped_ignored(self):
# A sign-in-frequency policy scoped only to risky sign-ins must not satisfy
# the "all users" control.
policy = _make_policy(frequency=1, freq_type=SignInFrequencyType.DAYS)
policy.conditions.sign_in_risk_levels = [RiskLevel.HIGH]
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
def test_policy_platform_restricted_ignored(self):
policy = _make_policy(frequency=1, freq_type=SignInFrequencyType.DAYS)
policy.conditions.platform_conditions = PlatformConditions(
include_platforms=["windows"], exclude_platforms=[]
)
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
def test_multiple_policies_each_produce_a_report(self):
compliant_policy = _make_policy(policy_id="policy-compliant")
noncompliant_policy = _make_policy(
policy_id="policy-noncompliant",
display_name="Long Sign-in Frequency",
frequency=30,
)
result = self._run(
{
compliant_policy.id: compliant_policy,
noncompliant_policy.id: noncompliant_policy,
}
)
assert len(result) == 2
assert [report.resource_id for report in result] == [
"policy-compliant",
"policy-noncompliant",
]
assert [report.status for report in result] == ["PASS", "FAIL"]
def test_policy_client_app_type_restricted_ignored(self):
policy = _make_policy()
policy.conditions.client_app_types = [ClientAppType.BROWSER]
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_policy_location_restricted_ignored(self):
policy = _make_policy()
policy.conditions.locations = LocationsCondition(
include_locations=["location-1"], exclude_locations=[]
)
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_policy_device_restricted_ignored(self):
policy = _make_policy()
policy.conditions.device_conditions = DeviceConditions(
device_filter_mode=DeviceFilterMode.INCLUDE,
device_filter_rule="device.isCompliant -eq true",
)
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_policy_authentication_flow_restricted_ignored(self):
policy = _make_policy()
policy.conditions.authentication_flows = AuthenticationFlows(
transfer_methods=[TransferMethod.DEVICE_CODE_FLOW]
)
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_policy_insider_risk_restricted_ignored(self):
policy = _make_policy()
policy.conditions.insider_risk_levels = InsiderRiskLevel.ELEVATED
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_policy_user_exclusion_restricted_ignored(self):
policy = _make_policy()
policy.conditions.user_conditions.excluded_users = ["excluded-user"]
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_policy_user_action_restricted_ignored(self):
policy = _make_policy()
policy.conditions.application_conditions.included_user_actions = [
UserAction.REGISTER_DEVICE
]
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_explicit_unrestricted_conditions_pass(self):
policy = _make_policy()
policy.conditions.client_app_types = [ClientAppType.ALL]
policy.conditions.platform_conditions = PlatformConditions(
include_platforms=["all"], exclude_platforms=[]
)
policy.conditions.locations = LocationsCondition(
include_locations=["All"], exclude_locations=[]
)
policy.conditions.device_conditions = DeviceConditions()
result = self._run({policy.id: policy})
assert result[0].status == "PASS"
@@ -0,0 +1,188 @@
from unittest import mock
import pytest
from prowler.providers.m365.services.entra.entra_service import (
ApplicationsConditions,
ClientAppType,
ConditionalAccessPolicy,
ConditionalAccessPolicyState,
Conditions,
GrantControlOperator,
GrantControls,
PersistentBrowser,
PlatformConditions,
SessionControls,
SignInFrequency,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_token_protection_enforced.entra_conditional_access_policy_token_protection_enforced"
def _make_policy(
policy_id="policy-1",
display_name="Token Protection",
state=ConditionalAccessPolicyState.ENABLED,
included_users=None,
included_applications=None,
excluded_applications=None,
include_platforms=None,
client_app_types=None,
secure_sign_in_session_enabled=True,
):
return ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=(
included_applications
if included_applications is not None
else ["All"]
),
excluded_applications=excluded_applications or [],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=(
included_users if included_users is not None else ["All"]
),
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
client_app_types=(
client_app_types
if client_app_types is not None
else [ClientAppType.MOBILE_APPS_AND_DESKTOP_CLIENTS]
),
platform_conditions=PlatformConditions(
include_platforms=(
include_platforms if include_platforms is not None else ["windows"]
),
exclude_platforms=[],
),
),
grant_controls=GrantControls(
built_in_controls=[],
operator=GrantControlOperator.AND,
authentication_strength=None,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(is_enabled=False, mode="always"),
sign_in_frequency=SignInFrequency(
is_enabled=False, frequency=None, type=None, interval=None
),
secure_sign_in_session_enabled=secure_sign_in_session_enabled,
),
state=state,
)
class Test_entra_conditional_access_policy_token_protection_enforced:
def _run(self, policies):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_token_protection_enforced.entra_conditional_access_policy_token_protection_enforced import (
entra_conditional_access_policy_token_protection_enforced,
)
entra_client.conditional_access_policies = policies
entra_client.tenant_domain = DOMAIN
return entra_conditional_access_policy_token_protection_enforced().execute()
def test_no_resources(self):
assert len(self._run({})) == 0
def test_token_protection_enforced(self):
policy = _make_policy()
result = self._run({policy.id: policy})
assert result[0].status == "PASS"
def test_multiple_policies_each_produce_a_report(self):
enforced_policy = _make_policy(policy_id="policy-enforced")
disabled_policy = _make_policy(
policy_id="policy-disabled",
display_name="Token Protection Disabled",
secure_sign_in_session_enabled=False,
)
result = self._run(
{
enforced_policy.id: enforced_policy,
disabled_policy.id: disabled_policy,
}
)
assert len(result) == 2
assert [report.resource_id for report in result] == [
"policy-enforced",
"policy-disabled",
]
assert [report.status for report in result] == ["PASS", "FAIL"]
def test_token_protection_disabled(self):
policy = _make_policy(secure_sign_in_session_enabled=False)
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_missing_windows_platform(self):
policy = _make_policy(include_platforms=["macOS"])
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_browser_only_client(self):
policy = _make_policy(client_app_types=[ClientAppType.BROWSER])
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_partial_app_coverage(self):
policy = _make_policy(
included_applications=["00000002-0000-0ff1-ce00-000000000000"]
)
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_all_required_apps(self):
policy = _make_policy(
included_applications=[
"00000002-0000-0ff1-ce00-000000000000",
"00000003-0000-0ff1-ce00-000000000000",
"cc15fd57-2c6c-4117-a88c-83b1d56b4bbe",
]
)
result = self._run({policy.id: policy})
assert result[0].status == "PASS"
@pytest.mark.parametrize(
"excluded_application",
[
"00000002-0000-0ff1-ce00-000000000000",
"00000003-0000-0ff1-ce00-000000000000",
"cc15fd57-2c6c-4117-a88c-83b1d56b4bbe",
],
)
def test_all_apps_excluding_required_app_fails(self, excluded_application):
policy = _make_policy(excluded_applications=[excluded_application])
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_all_apps_excluding_unrelated_app_passes(self):
policy = _make_policy(excluded_applications=["unrelated-app"])
result = self._run({policy.id: policy})
assert result[0].status == "PASS"
def test_report_only(self):
policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING)
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
@@ -0,0 +1,181 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
ApplicationsConditions,
ConditionalAccessGrantControl,
ConditionalAccessPolicy,
ConditionalAccessPolicyState,
Conditions,
GrantControlOperator,
GrantControls,
LocationsCondition,
NamedLocation,
PersistentBrowser,
SessionControls,
SignInFrequency,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_untrusted_locations_blocked.entra_conditional_access_policy_untrusted_locations_blocked"
def _make_policy(
state=ConditionalAccessPolicyState.ENABLED,
include_locations=None,
exclude_locations=None,
built_in_controls=None,
excluded_applications=None,
):
return ConditionalAccessPolicy(
id="policy-1",
display_name="Block Untrusted Locations",
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=excluded_applications or [],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
client_app_types=[],
locations=LocationsCondition(
include_locations=(
include_locations if include_locations is not None else ["All"]
),
exclude_locations=(
exclude_locations
if exclude_locations is not None
else ["AllTrusted"]
),
),
),
grant_controls=GrantControls(
built_in_controls=(
built_in_controls
if built_in_controls is not None
else [ConditionalAccessGrantControl.BLOCK]
),
operator=GrantControlOperator.OR,
authentication_strength=None,
),
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=state,
)
class Test_entra_conditional_access_policy_untrusted_locations_blocked:
def _run(self, policies, named_locations=None):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_untrusted_locations_blocked.entra_conditional_access_policy_untrusted_locations_blocked import (
entra_conditional_access_policy_untrusted_locations_blocked,
)
entra_client.conditional_access_policies = policies
entra_client.named_locations = named_locations or []
entra_client.tenant_domain = DOMAIN
return (
entra_conditional_access_policy_untrusted_locations_blocked().execute()
)
def test_no_policies(self):
assert self._run({})[0].status == "FAIL"
def test_blocks_untrusted(self):
policy = _make_policy()
result = self._run({policy.id: policy})
assert result[0].status == "PASS"
def test_policy_excluding_application_does_not_cover_all_applications(self):
policy = _make_policy(excluded_applications=["excluded-app"])
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_blocks_explicit_untrusted_named_location(self):
location_id = "00000000-0000-0000-0000-000000000001"
policy = _make_policy(include_locations=[location_id])
result = self._run(
{policy.id: policy},
[NamedLocation(id=location_id, is_trusted=False)],
)
assert result[0].status == "PASS"
def test_does_not_accept_explicit_trusted_named_location(self):
location_id = "00000000-0000-0000-0000-000000000001"
policy = _make_policy(include_locations=[location_id])
result = self._run(
{policy.id: policy},
[NamedLocation(id=location_id, is_trusted=True)],
)
assert result[0].status == "FAIL"
def test_does_not_accept_unknown_named_location(self):
policy = _make_policy(
include_locations=["00000000-0000-0000-0000-000000000001"]
)
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_does_not_accept_explicit_untrusted_location_when_excluded(self):
location_id = "00000000-0000-0000-0000-000000000001"
policy = _make_policy(
include_locations=[location_id],
exclude_locations=["AllTrusted", location_id],
)
result = self._run(
{policy.id: policy},
[NamedLocation(id=location_id, is_trusted=False)],
)
assert result[0].status == "FAIL"
def test_no_trusted_exclusion(self):
policy = _make_policy(exclude_locations=[])
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
def test_policy_report_only(self):
policy = _make_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING)
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status_extended == (
"Conditional Access Policy 'Block Untrusted Locations' blocks untrusted "
"locations but is in report-only mode."
)
def test_policy_disabled_is_skipped(self):
policy = _make_policy(state=ConditionalAccessPolicyState.DISABLED)
result = self._run({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_id == "conditionalAccessPolicies"
assert result[0].status_extended == (
"No Conditional Access Policy blocks access from untrusted locations."
)
def test_not_block(self):
policy = _make_policy(built_in_controls=[ConditionalAccessGrantControl.MFA])
result = self._run({policy.id: policy})
assert result[0].status == "FAIL"
@@ -0,0 +1,98 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import NamedLocation
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_trusted_named_location_exists.entra_conditional_access_trusted_named_location_exists"
class Test_entra_conditional_access_trusted_named_location_exists:
def _run(self, named_locations):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_trusted_named_location_exists.entra_conditional_access_trusted_named_location_exists import (
entra_conditional_access_trusted_named_location_exists,
)
entra_client.named_locations = named_locations
return entra_conditional_access_trusted_named_location_exists().execute()
def test_no_resources(self):
result = self._run([])
assert len(result) == 0
def test_trusted_ip_location(self):
result = self._run(
[
NamedLocation(
id="loc1",
display_name="Corp",
is_trusted=True,
is_ip_location=True,
ip_ranges_count=2,
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
def test_multiple_locations_each_produce_a_report(self):
result = self._run(
[
NamedLocation(
id="trusted-location",
display_name="Trusted Location",
is_trusted=True,
is_ip_location=True,
ip_ranges_count=1,
),
NamedLocation(
id="untrusted-location",
display_name="Untrusted Location",
is_trusted=False,
is_ip_location=True,
ip_ranges_count=1,
),
]
)
assert len(result) == 2
assert [report.resource_id for report in result] == [
"trusted-location",
"untrusted-location",
]
assert [report.status for report in result] == ["PASS", "FAIL"]
def test_untrusted_ip_location(self):
result = self._run(
[
NamedLocation(
id="loc1",
is_trusted=False,
is_ip_location=True,
ip_ranges_count=2,
)
]
)
assert len(result) == 1
assert result[0].status == "FAIL"
def test_trusted_but_no_ranges(self):
result = self._run(
[
NamedLocation(
id="loc1",
is_trusted=True,
is_ip_location=True,
ip_ranges_count=0,
)
]
)
assert len(result) == 1
assert result[0].status == "FAIL"
@@ -0,0 +1,83 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
ActivityBasedTimeoutPolicy,
)
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_idle_session_timeout_configured.entra_idle_session_timeout_configured"
class Test_entra_idle_session_timeout_configured:
def _run(self, policies):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_idle_session_timeout_configured.entra_idle_session_timeout_configured import (
entra_idle_session_timeout_configured,
)
entra_client.activity_based_timeout_policies = policies
return entra_idle_session_timeout_configured().execute()
def test_no_policies(self):
result = self._run([])
assert len(result) == 1
assert result[0].status == "FAIL"
def test_within_limit(self):
result = self._run(
[
ActivityBasedTimeoutPolicy(
id="p1",
display_name="Timeout",
web_session_idle_timeout_seconds=3 * 60 * 60,
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
def test_within_limit_preserves_seconds_in_status_text(self):
result = self._run(
[
ActivityBasedTimeoutPolicy(
id="p1",
display_name="Exact Timeout",
web_session_idle_timeout_seconds=2 * 60 * 60 + 30 * 60 + 45,
)
]
)
assert result[0].status_extended == (
"Activity-based timeout policy 'Exact Timeout' enforces an idle session "
"timeout of 150 minutes and 45 seconds."
)
def test_exceeds_limit(self):
result = self._run(
[
ActivityBasedTimeoutPolicy(
id="p1",
web_session_idle_timeout_seconds=5 * 60 * 60,
)
]
)
assert len(result) == 1
assert result[0].status == "FAIL"
def test_app_specific_timeout_without_default_fails(self):
result = self._run(
[
ActivityBasedTimeoutPolicy(
id="p1",
web_session_idle_timeout_seconds=None,
)
]
)
assert len(result) == 1
assert result[0].status == "FAIL"
@@ -5,6 +5,8 @@ from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from prowler.providers.m365.models import M365IdentityInfo
from prowler.providers.m365.services.entra import entra_service
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
@@ -275,6 +277,25 @@ class Test_Entra_Service:
== AuthPolicyRoles.GUEST_USER_ACCESS_RESTRICTED.value
)
@pytest.mark.parametrize(
"value",
["-00:00:01", "-1.00:00:00", "24:00:00", "00:60:00", "00:00:60"],
)
def test_parse_timespan_rejects_invalid_component_ranges(self, value):
assert Entra._parse_timespan_to_seconds(value) is None
@pytest.mark.parametrize(
("value", "expected"),
[
("00:00:00", 0),
("23:59:59", 86399),
("1.00:00:00", 86400),
("00:00:01.9999999", 1),
],
)
def test_parse_timespan_accepts_valid_component_boundaries(self, value, expected):
assert Entra._parse_timespan_to_seconds(value) == expected
@patch(
"prowler.providers.m365.services.entra.entra_service.Entra._get_conditional_access_policies",
new=mock_entra_get_conditional_access_policies,
@@ -1301,3 +1322,275 @@ class Test_Entra_Service:
queried_users = {call.args[0] for call in by_user_id.call_args_list}
assert queried_users == {deleted_user, live_user}
assert user_builders[deleted_user].get.await_count == 1
def test__get_named_locations_paginates_through_next_links(self):
entra_service = Entra.__new__(Entra)
page_one = json.dumps(
{
"value": [
{
"@odata.type": "#microsoft.graph.ipNamedLocation",
"id": "loc-1",
"displayName": "Trusted IPs",
"isTrusted": True,
"ipRanges": [{"cidrAddress": "10.0.0.0/8"}],
}
],
"@odata.nextLink": "next-link",
}
)
page_two = json.dumps(
{
"value": [
{
"@odata.type": "#microsoft.graph.countryNamedLocation",
"id": "loc-2",
"displayName": "Countries",
"isTrusted": False,
}
]
}
)
send_mock = AsyncMock(side_effect=[page_one, page_two])
next_link_builder = SimpleNamespace(
to_get_request_information=MagicMock(return_value="req-info-page-two")
)
with_url_mock = MagicMock(return_value=next_link_builder)
named_locations_builder = SimpleNamespace(
to_get_request_information=MagicMock(return_value="req-info-page-one"),
with_url=with_url_mock,
)
entra_service.client = SimpleNamespace(
identity=SimpleNamespace(
conditional_access=SimpleNamespace(
named_locations=named_locations_builder
)
),
request_adapter=SimpleNamespace(send_primitive_async=send_mock),
)
named_locations = asyncio.run(entra_service._get_named_locations())
# Both pages are requested and every entry is accumulated before parsing.
assert send_mock.await_count == 2
with_url_mock.assert_called_once_with("next-link")
assert [loc.id for loc in named_locations] == ["loc-1", "loc-2"]
assert named_locations[0].is_ip_location is True
assert named_locations[0].ip_ranges_count == 1
assert named_locations[1].is_ip_location is False
assert named_locations[1].is_trusted is False
def test__get_named_locations_continues_after_empty_page(self):
entra_service = Entra.__new__(Entra)
page_one = json.dumps({"value": [], "@odata.nextLink": "next-link"})
page_two = json.dumps(
{
"value": [
{
"@odata.type": "#microsoft.graph.ipNamedLocation",
"id": "loc-2",
"displayName": "Trusted IPs",
"isTrusted": True,
"ipRanges": [{"cidrAddress": "10.0.0.0/8"}],
}
]
}
)
send_mock = AsyncMock(side_effect=[page_one, page_two])
next_link_builder = SimpleNamespace(
to_get_request_information=MagicMock(return_value="req-info-page-two")
)
with_url_mock = MagicMock(return_value=next_link_builder)
entra_service.client = SimpleNamespace(
identity=SimpleNamespace(
conditional_access=SimpleNamespace(
named_locations=SimpleNamespace(
to_get_request_information=MagicMock(
return_value="req-info-page-one"
),
with_url=with_url_mock,
)
)
),
request_adapter=SimpleNamespace(send_primitive_async=send_mock),
)
named_locations = asyncio.run(entra_service._get_named_locations())
assert send_mock.await_count == 2
with_url_mock.assert_called_once_with("next-link")
assert [location.id for location in named_locations] == ["loc-2"]
def test__get_activity_based_timeout_policies_uses_default_application(self):
entra_service = Entra.__new__(Entra)
policy = SimpleNamespace(
id="policy-1",
display_name="Idle timeout",
definition=[
json.dumps(
{
"ActivityBasedTimeoutPolicy": {
"ApplicationPolicies": [
{
"ApplicationId": "default",
"WebSessionIdleTimeout": "05:00:00",
},
{
"ApplicationId": "00000000-0000-0000-0000-000000000001",
"WebSessionIdleTimeout": "01:00:00",
},
]
}
}
)
],
)
entra_service.client = SimpleNamespace(
policies=SimpleNamespace(
activity_based_timeout_policies=SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
value=[policy], odata_next_link=None
)
),
with_url=MagicMock(),
)
)
)
policies = asyncio.run(entra_service._get_activity_based_timeout_policies())
assert len(policies) == 1
assert policies[0].web_session_idle_timeout_seconds == 5 * 60 * 60
def test__get_activity_based_timeout_policies_without_default_is_unconfigured(self):
entra_service = Entra.__new__(Entra)
policy = SimpleNamespace(
id="policy-1",
display_name="App-specific timeout",
definition=[
json.dumps(
{
"ActivityBasedTimeoutPolicy": {
"ApplicationPolicies": [
{
"ApplicationId": "00000000-0000-0000-0000-000000000001",
"WebSessionIdleTimeout": "01:00:00",
}
]
}
}
)
],
)
entra_service.client = SimpleNamespace(
policies=SimpleNamespace(
activity_based_timeout_policies=SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
value=[policy], odata_next_link=None
)
),
with_url=MagicMock(),
)
)
)
policies = asyncio.run(entra_service._get_activity_based_timeout_policies())
assert len(policies) == 1
assert policies[0].web_session_idle_timeout_seconds is None
def test__get_activity_based_timeout_policies_uses_first_valid_default(self):
entra_service = Entra.__new__(Entra)
def definition(timeout):
return json.dumps(
{
"ActivityBasedTimeoutPolicy": {
"ApplicationPolicies": [
{
"ApplicationId": "default",
"WebSessionIdleTimeout": timeout,
}
]
}
}
)
policy = SimpleNamespace(
id="policy-1",
display_name="Idle timeout",
definition=[
"invalid-json",
definition("invalid-timeout"),
definition("01:00:00"),
definition("02:00:00"),
],
)
entra_service.client = SimpleNamespace(
policies=SimpleNamespace(
activity_based_timeout_policies=SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
value=[policy], odata_next_link=None
)
),
with_url=MagicMock(),
)
)
)
policies = asyncio.run(entra_service._get_activity_based_timeout_policies())
assert len(policies) == 1
assert policies[0].web_session_idle_timeout_seconds == 60 * 60
def test__get_activity_based_timeout_policies_paginates_through_next_links(self):
entra_service = Entra.__new__(Entra)
def policy(policy_id, timeout):
return SimpleNamespace(
id=policy_id,
display_name=policy_id,
definition=[
json.dumps(
{
"ActivityBasedTimeoutPolicy": {
"ApplicationPolicies": [
{
"ApplicationId": "default",
"WebSessionIdleTimeout": timeout,
}
]
}
}
)
],
)
page_one = SimpleNamespace(
value=[policy("policy-1", "01:00:00")],
odata_next_link="next-link",
)
page_two = SimpleNamespace(
value=[policy("policy-2", "02:00:00")],
odata_next_link=None,
)
next_page_builder = SimpleNamespace(get=AsyncMock(return_value=page_two))
with_url_mock = MagicMock(return_value=next_page_builder)
policies_builder = SimpleNamespace(
get=AsyncMock(return_value=page_one),
with_url=with_url_mock,
)
entra_service.client = SimpleNamespace(
policies=SimpleNamespace(activity_based_timeout_policies=policies_builder)
)
policies = asyncio.run(entra_service._get_activity_based_timeout_policies())
assert [policy.id for policy in policies] == ["policy-1", "policy-2"]
with_url_mock.assert_called_once_with("next-link")
next_page_builder.get.assert_awaited_once()