mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(m365): add entra_conditional_access_policy_no_exclusion_gaps check (#11577)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com> Co-authored-by: Ariel Eli <207917221+arieleli01212@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
### 🚀 Added
|
||||
|
||||
- `entra_conditional_access_policy_explicitly_targets_azure_devops` check for M365 provider, verifying at least one enabled Conditional Access policy explicitly includes the Azure DevOps cloud application instead of relying on a broad "All cloud apps" policy [(#11182)](https://github.com/prowler-cloud/prowler/pull/11182)
|
||||
- `entra_conditional_access_policy_no_exclusion_gaps` check for M365 provider, verifying every user, group, role, or application excluded from an enabled Conditional Access policy stays in scope of another enabled policy [(#11577)](https://github.com/prowler-cloud/prowler/pull/11577)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "m365",
|
||||
"CheckID": "entra_conditional_access_policy_no_exclusion_gaps",
|
||||
"CheckTitle": "Conditional Access exclusions are covered by another policy (no exclusion gaps)",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "Verifies that every object excluded from an enabled Microsoft Entra **Conditional Access** policy (users, groups, roles, or applications) is still included by at least one enabled policy, so the exclusion keeps a compensating control. The Directory Synchronization Accounts role and confirmed emergency access (break glass) accounts are treated as intentional and not reported.",
|
||||
"Risk": "An object excluded from a Conditional Access policy but never included by any other enabled policy sits completely outside Conditional Access enforcement. This creates a silent **MFA bypass** and **lateral movement** path: a principal exempted as a one-off remains permanently uncontrolled if no compensating policy covers it.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/plan-conditional-access",
|
||||
"https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0",
|
||||
"https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessusers?view=graph-rest-1.0",
|
||||
"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Navigate to Protection > Conditional Access > Policies in the Microsoft Entra admin center.\n2. For each object reported as an exclusion gap, decide whether the exclusion is still required.\n3. If the exclusion must stay, add the object to the Include scope of another enabled Conditional Access policy that enforces compensating controls (for example MFA).\n4. If the exclusion is no longer required, remove it so the object falls back under the original policy.\n5. Re-run the check to confirm no exclusion gaps remain.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Ensure every object excluded from a Conditional Access policy is included by at least one other enabled policy that applies compensating controls. Reserve exclusions for break-glass accounts and the Directory Synchronization Accounts role, and review exclusion lists regularly so that exempted principals never drift outside Conditional Access enforcement.",
|
||||
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_no_exclusion_gaps"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"entra_conditional_access_policy_directory_sync_account_excluded",
|
||||
"entra_emergency_access_exclusion"
|
||||
],
|
||||
"Notes": "Covers user, group, role, and application exclusions. Platform and location exclusions are out of scope because they are scoping conditions rather than principals removed from enforcement. Service-principal exclusions require additional fields on the ConditionalAccessPolicy service model."
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
"""Check that Conditional Access exclusions do not create coverage gaps."""
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# Directory Synchronization Accounts built-in role template ID. Prowler enforces
|
||||
# excluding this role (see entra_conditional_access_policy_directory_sync_account_excluded);
|
||||
# it is intended to have no fallback, so it never counts as a gap here.
|
||||
DIRECTORY_SYNC_ROLE_TEMPLATE_ID = "d29b2b05-8046-44ba-8758-1e26182fcf32"
|
||||
|
||||
|
||||
class entra_conditional_access_policy_no_exclusion_gaps(Check):
|
||||
"""Check that objects excluded from Conditional Access policies remain covered.
|
||||
|
||||
Excluding a principal from a Conditional Access (CA) policy is only safe when
|
||||
that principal is still covered by *some* enabled CA policy that enforces
|
||||
compensating controls. An object excluded everywhere and included nowhere
|
||||
sits completely outside CA enforcement, which is how MFA bypass and lateral
|
||||
movement against admin accounts happen in real incidents.
|
||||
|
||||
For every enabled CA policy this check walks each exclusion collection and
|
||||
verifies the excluded object is still in scope of another enabled policy: one
|
||||
that includes it (explicitly, or via the "All" wildcard) and does not itself
|
||||
exclude it. A wildcard belonging to the policy that excludes the object does
|
||||
not count, so a one-off exclusion with no compensating policy is reported as
|
||||
a gap.
|
||||
|
||||
Only principals and target apps are evaluated (users, groups, roles,
|
||||
applications). Platform and location exclusions are scoping conditions rather
|
||||
than principals removed from enforcement, so they are out of scope.
|
||||
|
||||
- PASS: Every excluded object stays in scope of another enabled policy, or no
|
||||
enabled policy uses any exclusion.
|
||||
- FAIL: At least one excluded object is in scope of no other enabled policy.
|
||||
"""
|
||||
|
||||
# (label, conditions attribute, included attr, excluded attr, wildcard token).
|
||||
# The wildcard token, when present in an include collection, scopes a policy
|
||||
# to every object of that type. Groups and roles have no wildcard: they are
|
||||
# always explicit identifiers and transitive group/role expansion is out of
|
||||
# scope for v1, so an excluded group/role is only "covered" when the same
|
||||
# identifier is explicitly included by another enabled policy.
|
||||
_COLLECTIONS = [
|
||||
("users", "user_conditions", "included_users", "excluded_users", "All"),
|
||||
("groups", "user_conditions", "included_groups", "excluded_groups", None),
|
||||
("roles", "user_conditions", "included_roles", "excluded_roles", None),
|
||||
(
|
||||
"applications",
|
||||
"application_conditions",
|
||||
"included_applications",
|
||||
"excluded_applications",
|
||||
"All",
|
||||
),
|
||||
]
|
||||
|
||||
def execute(self) -> list[CheckReportM365]:
|
||||
"""Execute the Conditional Access exclusion-gap check.
|
||||
|
||||
Returns:
|
||||
list[CheckReportM365]: A single-element list with the aggregate result.
|
||||
"""
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource={},
|
||||
resource_name="Conditional Access Policies",
|
||||
resource_id="conditionalAccessPolicies",
|
||||
)
|
||||
|
||||
enabled_policies = [
|
||||
policy
|
||||
for policy in entra_client.conditional_access_policies.values()
|
||||
if policy.state == ConditionalAccessPolicyState.ENABLED
|
||||
]
|
||||
|
||||
if not enabled_policies:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
"No enabled Conditional Access policies found; "
|
||||
"no exclusion coverage gaps are possible."
|
||||
)
|
||||
return [report]
|
||||
|
||||
emergency_users, emergency_groups = self._emergency_access_objects()
|
||||
|
||||
# gaps: type label -> set of excluded object IDs with no compensating policy
|
||||
gaps = defaultdict(set)
|
||||
any_exclusion = False
|
||||
|
||||
for policy in enabled_policies:
|
||||
for (
|
||||
label,
|
||||
conditions_attr,
|
||||
included_attr,
|
||||
excluded_attr,
|
||||
wildcard,
|
||||
) in self._COLLECTIONS:
|
||||
conditions = getattr(policy.conditions, conditions_attr)
|
||||
if not conditions:
|
||||
continue
|
||||
for object_id in getattr(conditions, excluded_attr):
|
||||
any_exclusion = True
|
||||
if self._is_expected_exclusion(
|
||||
label, object_id, emergency_users, emergency_groups
|
||||
):
|
||||
continue
|
||||
if not self._is_covered(
|
||||
object_id,
|
||||
conditions_attr,
|
||||
included_attr,
|
||||
excluded_attr,
|
||||
wildcard,
|
||||
enabled_policies,
|
||||
):
|
||||
gaps[label].add(object_id)
|
||||
|
||||
if not any_exclusion:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
"No enabled Conditional Access policy uses exclusions; "
|
||||
"no coverage gaps are possible."
|
||||
)
|
||||
return [report]
|
||||
|
||||
if not gaps:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
"Every object excluded from an enabled Conditional Access policy is "
|
||||
"still in scope of another enabled policy, so a compensating control "
|
||||
"remains in effect."
|
||||
)
|
||||
return [report]
|
||||
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
"Conditional Access exclusion gaps found "
|
||||
f"({self._format_gaps(gaps, self._build_name_index())}). These objects "
|
||||
"are excluded but in scope of no other enabled policy, leaving them "
|
||||
"outside CA enforcement."
|
||||
)
|
||||
return [report]
|
||||
|
||||
def _build_name_index(self) -> dict:
|
||||
"""Map excluded object IDs to display names per type, for readable findings.
|
||||
|
||||
Users, groups, and applications resolve to their display name; roles have
|
||||
no loaded name catalog, so role template IDs are shown as-is. Unresolved
|
||||
IDs (for example deleted principals still referenced by a policy) fall
|
||||
back to the raw identifier.
|
||||
"""
|
||||
users = {
|
||||
uid: user.name
|
||||
for uid, user in (getattr(entra_client, "users", {}) or {}).items()
|
||||
if getattr(user, "name", None)
|
||||
}
|
||||
groups = {
|
||||
group.id: group.name
|
||||
for group in (getattr(entra_client, "groups", []) or [])
|
||||
if getattr(group, "name", None)
|
||||
}
|
||||
applications = {
|
||||
sp.app_id: sp.name
|
||||
for sp in (getattr(entra_client, "service_principals", {}) or {}).values()
|
||||
if getattr(sp, "app_id", None) and getattr(sp, "name", None)
|
||||
}
|
||||
return {"users": users, "groups": groups, "applications": applications}
|
||||
|
||||
def _is_covered(
|
||||
self,
|
||||
object_id,
|
||||
conditions_attr,
|
||||
included_attr,
|
||||
excluded_attr,
|
||||
wildcard,
|
||||
enabled_policies,
|
||||
) -> bool:
|
||||
"""Return True if any enabled policy keeps ``object_id`` in scope.
|
||||
|
||||
A policy keeps the object in scope when it includes it —explicitly or via
|
||||
the type's wildcard token— and does not also exclude it. The wildcard of a
|
||||
policy that itself excludes the object does not count, which is what makes
|
||||
a one-off exclusion with no compensating policy a real gap.
|
||||
"""
|
||||
for policy in enabled_policies:
|
||||
conditions = getattr(policy.conditions, conditions_attr)
|
||||
if not conditions:
|
||||
continue
|
||||
if object_id in getattr(conditions, excluded_attr):
|
||||
continue
|
||||
included = getattr(conditions, included_attr)
|
||||
if object_id in included or (wildcard is not None and wildcard in included):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _emergency_access_objects(self) -> tuple[set, set]:
|
||||
"""Return user and group IDs that act as emergency access (break-glass).
|
||||
|
||||
Objects excluded from *every* enabled (enforced) Conditional Access policy
|
||||
with a Block grant control are intended, compensating gaps and must not be
|
||||
reported here. Only ENABLED policies count: report-only policies are not
|
||||
enforced, so including them would dilute the "excluded everywhere" check
|
||||
and could hide a genuine break-glass account (consistent with execute()).
|
||||
"""
|
||||
blocking_policies = [
|
||||
policy
|
||||
for policy in entra_client.conditional_access_policies.values()
|
||||
if policy.state == ConditionalAccessPolicyState.ENABLED
|
||||
and ConditionalAccessGrantControl.BLOCK
|
||||
in policy.grant_controls.built_in_controls
|
||||
]
|
||||
if not blocking_policies:
|
||||
return set(), set()
|
||||
|
||||
total = len(blocking_policies)
|
||||
excluded_users = Counter()
|
||||
excluded_groups = Counter()
|
||||
for policy in blocking_policies:
|
||||
user_conditions = policy.conditions.user_conditions
|
||||
if not user_conditions:
|
||||
continue
|
||||
for user_id in user_conditions.excluded_users:
|
||||
excluded_users[user_id] += 1
|
||||
for group_id in user_conditions.excluded_groups:
|
||||
excluded_groups[group_id] += 1
|
||||
|
||||
emergency_users = {uid for uid, n in excluded_users.items() if n == total}
|
||||
emergency_groups = {gid for gid, n in excluded_groups.items() if n == total}
|
||||
return emergency_users, emergency_groups
|
||||
|
||||
def _is_expected_exclusion(
|
||||
self, label, object_id, emergency_users, emergency_groups
|
||||
) -> bool:
|
||||
"""Exclusions that are intentional by design and must not count as gaps."""
|
||||
if label == "roles" and object_id == DIRECTORY_SYNC_ROLE_TEMPLATE_ID:
|
||||
return True
|
||||
if label == "users" and object_id in emergency_users:
|
||||
return True
|
||||
if label == "groups" and object_id in emergency_groups:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _format_gaps(self, gaps, name_index) -> str:
|
||||
"""Render the orphaned objects grouped by type, by display name when known.
|
||||
|
||||
Each ID is shown as its display name when resolvable; unresolved IDs (and
|
||||
all roles, which have no name catalog) fall back to the raw identifier.
|
||||
"""
|
||||
parts = []
|
||||
for label in ("users", "groups", "roles", "applications"):
|
||||
if label not in gaps:
|
||||
continue
|
||||
names = name_index.get(label, {})
|
||||
rendered = sorted(
|
||||
names.get(object_id, object_id) for object_id in gaps[label]
|
||||
)
|
||||
parts.append(f"{label}: {', '.join(rendered)}")
|
||||
return " | ".join(parts)
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
import re
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
ApplicationsConditions,
|
||||
ConditionalAccessGrantControl,
|
||||
ConditionalAccessPolicy,
|
||||
ConditionalAccessPolicyState,
|
||||
Conditions,
|
||||
GrantControlOperator,
|
||||
GrantControls,
|
||||
PersistentBrowser,
|
||||
PlatformConditions,
|
||||
SessionControls,
|
||||
SignInFrequency,
|
||||
SignInFrequencyInterval,
|
||||
UsersConditions,
|
||||
)
|
||||
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
|
||||
|
||||
CHECK_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_no_exclusion_gaps.entra_conditional_access_policy_no_exclusion_gaps"
|
||||
DIRECTORY_SYNC_ROLE_TEMPLATE_ID = "d29b2b05-8046-44ba-8758-1e26182fcf32"
|
||||
|
||||
|
||||
def _policy(
|
||||
display_name="Policy",
|
||||
state=ConditionalAccessPolicyState.ENABLED,
|
||||
included_users=None,
|
||||
excluded_users=None,
|
||||
included_groups=None,
|
||||
excluded_groups=None,
|
||||
included_roles=None,
|
||||
excluded_roles=None,
|
||||
included_applications=None,
|
||||
excluded_applications=None,
|
||||
include_platforms=None,
|
||||
exclude_platforms=None,
|
||||
block=False,
|
||||
) -> ConditionalAccessPolicy:
|
||||
"""Build a fully-populated ConditionalAccessPolicy for tests.
|
||||
|
||||
Args:
|
||||
display_name: Policy display name.
|
||||
state: Policy state (default ENABLED).
|
||||
included_users: Included user IDs, or None.
|
||||
excluded_users: Excluded user IDs, or None.
|
||||
included_groups: Included group IDs, or None.
|
||||
excluded_groups: Excluded group IDs, or None.
|
||||
included_roles: Included role template IDs, or None.
|
||||
excluded_roles: Excluded role template IDs, or None.
|
||||
included_applications: Included application IDs, or None.
|
||||
excluded_applications: Excluded application IDs, or None.
|
||||
include_platforms: Included platform names, or None.
|
||||
exclude_platforms: Excluded platform names, or None.
|
||||
block: Whether the policy uses a Block grant control (default False).
|
||||
|
||||
Returns:
|
||||
A ConditionalAccessPolicy instance with the specified conditions.
|
||||
"""
|
||||
return ConditionalAccessPolicy(
|
||||
id=str(uuid4()),
|
||||
display_name=display_name,
|
||||
conditions=Conditions(
|
||||
application_conditions=ApplicationsConditions(
|
||||
included_applications=included_applications or [],
|
||||
excluded_applications=excluded_applications or [],
|
||||
included_user_actions=[],
|
||||
),
|
||||
user_conditions=UsersConditions(
|
||||
included_groups=included_groups or [],
|
||||
excluded_groups=excluded_groups or [],
|
||||
included_users=included_users or [],
|
||||
excluded_users=excluded_users or [],
|
||||
included_roles=included_roles or [],
|
||||
excluded_roles=excluded_roles or [],
|
||||
),
|
||||
platform_conditions=PlatformConditions(
|
||||
include_platforms=include_platforms or [],
|
||||
exclude_platforms=exclude_platforms or [],
|
||||
),
|
||||
),
|
||||
grant_controls=GrantControls(
|
||||
built_in_controls=(
|
||||
[ConditionalAccessGrantControl.BLOCK]
|
||||
if block
|
||||
else [ConditionalAccessGrantControl.MFA]
|
||||
),
|
||||
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=SignInFrequencyInterval.EVERY_TIME,
|
||||
),
|
||||
),
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
def _run(
|
||||
policies: list[ConditionalAccessPolicy],
|
||||
users=None,
|
||||
groups=None,
|
||||
service_principals=None,
|
||||
) -> list:
|
||||
"""Run the check with a mocked entra_client holding the given policies.
|
||||
|
||||
Args:
|
||||
policies: ConditionalAccessPolicy objects to inject into the mocked client.
|
||||
users: Optional id -> User mapping used to resolve display names.
|
||||
groups: Optional list of Group objects used to resolve display names.
|
||||
service_principals: Optional id -> ServicePrincipal mapping for app names.
|
||||
|
||||
Returns:
|
||||
The list of check report objects returned by ``execute()``.
|
||||
"""
|
||||
entra_client = mock.MagicMock()
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(f"{CHECK_PATH}.entra_client", new=entra_client),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_exclusion_gaps.entra_conditional_access_policy_no_exclusion_gaps import (
|
||||
entra_conditional_access_policy_no_exclusion_gaps,
|
||||
)
|
||||
|
||||
entra_client.conditional_access_policies = {p.id: p for p in policies}
|
||||
entra_client.users = users or {}
|
||||
entra_client.groups = groups or []
|
||||
entra_client.service_principals = service_principals or {}
|
||||
check = entra_conditional_access_policy_no_exclusion_gaps()
|
||||
return check.execute()
|
||||
|
||||
|
||||
class Test_entra_conditional_access_policy_no_exclusion_gaps:
|
||||
"""Tests for the Conditional Access exclusion-gap check.
|
||||
|
||||
Verifies that objects excluded from enabled Conditional Access policies stay
|
||||
in scope of another enabled policy (explicitly or via the type's wildcard),
|
||||
with the directory-sync role and break-glass accounts treated as intended
|
||||
exclusions.
|
||||
"""
|
||||
|
||||
def test_no_policies(self):
|
||||
result = _run([])
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "No enabled Conditional Access policies" in result[0].status_extended
|
||||
assert result[0].resource == {}
|
||||
assert result[0].resource_name == "Conditional Access Policies"
|
||||
assert result[0].resource_id == "conditionalAccessPolicies"
|
||||
assert result[0].location == "global"
|
||||
|
||||
def test_only_disabled_policies(self):
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
state=ConditionalAccessPolicyState.DISABLED,
|
||||
included_users=["All"],
|
||||
excluded_users=["user-1"],
|
||||
)
|
||||
]
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
assert "No enabled Conditional Access policies" in result[0].status_extended
|
||||
|
||||
def test_report_only_policies_out_of_scope(self):
|
||||
# An exclusion in a report-only policy must not be evaluated.
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
|
||||
included_users=["All"],
|
||||
excluded_users=["orphan-user"],
|
||||
)
|
||||
]
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
assert "No enabled Conditional Access policies" in result[0].status_extended
|
||||
|
||||
def test_no_exclusions_used(self):
|
||||
result = _run([_policy(included_users=["All"], included_applications=["All"])])
|
||||
assert result[0].status == "PASS"
|
||||
assert "no coverage gaps are possible" in result[0].status_extended
|
||||
|
||||
def test_exclusion_covered_by_another_policy(self):
|
||||
# Policy A excludes user-1; Policy B includes user-1 explicitly -> covered.
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="A", included_users=["All"], excluded_users=["user-1"]
|
||||
),
|
||||
_policy(display_name="B", included_users=["user-1"]),
|
||||
]
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
assert "compensating control" in result[0].status_extended
|
||||
|
||||
def test_user_exclusion_gap(self):
|
||||
# user-1 is excluded but never included anywhere -> FAIL.
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="A", included_users=["All"], excluded_users=["user-1"]
|
||||
)
|
||||
]
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
assert "users: user-1" in result[0].status_extended
|
||||
|
||||
def test_gap_reports_display_name_when_resolvable(self):
|
||||
# A resolvable user shows its display name; an unresolved user (e.g.
|
||||
# deleted but still referenced) falls back to its raw ID.
|
||||
from prowler.providers.m365.services.entra.entra_service import User
|
||||
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="A",
|
||||
included_users=["All"],
|
||||
excluded_users=["user-1", "ghost-2"],
|
||||
)
|
||||
],
|
||||
users={
|
||||
"user-1": User(
|
||||
id="user-1",
|
||||
name="Alice Admin",
|
||||
on_premises_sync_enabled=False,
|
||||
)
|
||||
},
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
assert "Alice Admin" in result[0].status_extended
|
||||
assert "user-1" not in result[0].status_extended
|
||||
# Unresolved ID still surfaces as the raw identifier.
|
||||
assert "ghost-2" in result[0].status_extended
|
||||
|
||||
def test_group_and_role_gaps_reported_by_type(self):
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="P",
|
||||
included_users=["All"],
|
||||
excluded_groups=["group-x"],
|
||||
excluded_roles=["role-y"],
|
||||
)
|
||||
]
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
assert "groups: group-x" in result[0].status_extended
|
||||
assert "roles: role-y" in result[0].status_extended
|
||||
|
||||
def test_application_exclusion_gap(self):
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="AppPolicy",
|
||||
included_applications=["All"],
|
||||
excluded_applications=["app-123"],
|
||||
)
|
||||
]
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
assert "applications: app-123" in result[0].status_extended
|
||||
|
||||
def test_application_exclusion_covered(self):
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="A",
|
||||
included_applications=["All"],
|
||||
excluded_applications=["app-123"],
|
||||
),
|
||||
_policy(display_name="B", included_applications=["app-123"]),
|
||||
]
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_exclusion_covered_by_all_wildcard_in_another_policy(self):
|
||||
# Policy A excludes user-1; Policy B targets "All" users and does NOT
|
||||
# exclude user-1, so user-1 stays in scope of B -> covered -> PASS.
|
||||
# The "All" wildcard of the policy that excludes the user (A) must not
|
||||
# count, but the wildcard of an unrelated policy (B) does.
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="A",
|
||||
included_users=["All"],
|
||||
excluded_users=["user-1"],
|
||||
),
|
||||
_policy(display_name="B", included_users=["All"]),
|
||||
]
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
assert "compensating control" in result[0].status_extended
|
||||
|
||||
def test_exclusion_only_wildcard_is_self_excluding_is_gap(self):
|
||||
# The only "All" users policy is the one that excludes user-1, and no
|
||||
# other policy covers user-1 -> real gap -> FAIL. This is the case
|
||||
# #11375's global-union "All" handling would have wrongly passed.
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="A",
|
||||
included_users=["All"],
|
||||
excluded_users=["user-1"],
|
||||
),
|
||||
_policy(
|
||||
display_name="B",
|
||||
included_users=["All"],
|
||||
excluded_users=["user-1"],
|
||||
),
|
||||
]
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
assert "users: user-1" in result[0].status_extended
|
||||
|
||||
def test_platform_exclusions_are_ignored(self):
|
||||
# Platform exclusions are scoping conditions, not principals removed from
|
||||
# enforcement, so they are out of scope even with no covering policy.
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="MDM",
|
||||
included_users=["All"],
|
||||
exclude_platforms=["android", "ios", "macos", "linux"],
|
||||
)
|
||||
]
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_directory_sync_role_exclusion_skipped(self):
|
||||
# Dir-sync role excluded with no fallback must NOT be a gap.
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="P",
|
||||
included_users=["All"],
|
||||
excluded_roles=[DIRECTORY_SYNC_ROLE_TEMPLATE_ID],
|
||||
)
|
||||
]
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
assert "compensating control" in result[0].status_extended
|
||||
|
||||
def test_emergency_access_user_exclusion_skipped(self):
|
||||
# A break-glass user excluded from EVERY enabled blocking policy is an
|
||||
# intended gap and must not be reported.
|
||||
emergency = "breakglass-user"
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="Block1",
|
||||
block=True,
|
||||
included_users=["All"],
|
||||
excluded_users=[emergency],
|
||||
),
|
||||
_policy(
|
||||
display_name="Block2",
|
||||
block=True,
|
||||
included_users=["All"],
|
||||
excluded_users=[emergency],
|
||||
),
|
||||
]
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
assert "compensating control" in result[0].status_extended
|
||||
|
||||
def test_emergency_access_ignores_report_only_blocking_policy(self):
|
||||
# A break-glass user excluded from every ENABLED blocking policy is an
|
||||
# intended gap, even if a report-only (non-enforced) blocking policy that
|
||||
# does NOT exclude them also exists. Report-only policies must not dilute
|
||||
# the emergency determination.
|
||||
emergency = "breakglass-user"
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="Block1",
|
||||
block=True,
|
||||
included_users=["All"],
|
||||
excluded_users=[emergency],
|
||||
),
|
||||
_policy(
|
||||
display_name="Block2",
|
||||
block=True,
|
||||
included_users=["All"],
|
||||
excluded_users=[emergency],
|
||||
),
|
||||
_policy(
|
||||
display_name="ReportOnlyBlock",
|
||||
block=True,
|
||||
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
|
||||
included_users=["All"],
|
||||
),
|
||||
]
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
assert "compensating control" in result[0].status_extended
|
||||
|
||||
def test_mixed_gap_and_covered(self):
|
||||
# user-1 covered, user-2 orphaned -> FAIL listing only user-2.
|
||||
result = _run(
|
||||
[
|
||||
_policy(
|
||||
display_name="A",
|
||||
included_users=["All"],
|
||||
excluded_users=["user-1", "user-2"],
|
||||
),
|
||||
_policy(display_name="B", included_users=["user-1"]),
|
||||
]
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
assert "user-2" in result[0].status_extended
|
||||
# user-1 is covered, so it must not appear as a gap (whitespace-robust).
|
||||
assert not re.search(r"\busers:\s*user-1\b", result[0].status_extended)
|
||||
Reference in New Issue
Block a user