mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
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:
co-authored by
Hugo P.Brito
parent
286685a4f3
commit
356036fe1f
@@ -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
|
||||
@@ -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",
|
||||
|
||||
+37
@@ -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": ""
|
||||
}
|
||||
+62
@@ -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
|
||||
+38
@@ -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": ""
|
||||
}
|
||||
+80
@@ -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
|
||||
+37
@@ -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": ""
|
||||
}
|
||||
+146
@@ -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
|
||||
+37
@@ -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": ""
|
||||
}
|
||||
+115
@@ -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
|
||||
+37
@@ -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": ""
|
||||
}
|
||||
+118
@@ -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
|
||||
+37
@@ -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": ""
|
||||
}
|
||||
+46
@@ -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
|
||||
+37
@@ -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": ""
|
||||
}
|
||||
+70
@@ -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."""
|
||||
|
||||
|
||||
+149
@@ -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"
|
||||
+119
@@ -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"
|
||||
+263
@@ -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"
|
||||
+188
@@ -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"
|
||||
+181
@@ -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"
|
||||
+98
@@ -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"
|
||||
+83
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user