From b4c12b75976efcf0617c1227ba281a22df99ea0e Mon Sep 17 00:00:00 2001 From: William Yip Date: Fri, 17 Apr 2026 22:06:26 +1000 Subject: [PATCH 1/5] Add CIS 1.2.2 shared mailbox sign-in blocked policy Implements automated compliance check for CIS Microsoft 365 Foundations Benchmark v6.0.0 control 1.2.2 (Ensure sign-in to shared mailboxes is blocked). - Extend MailboxesDataCollector to retrieve BlockCredentials via Get-User for each shared mailbox alongside Get-EXOMailbox data - Add Rego policy 1.2.2_shared_mailbox_signin_blocked.rego - Update metadata.json: automation_status ready, policy_file linked - Update controls.md: status Implemented - Add sample JSON output in engine/samples/ Made-with: Cursor --- .../v6.0.0/controls.md | 8 +- .../collectors/exchange/mailbox/mailboxes.py | 43 ++++++----- .../1.2.2_shared_mailbox_signin_blocked.rego | 75 +++++++++++++++++++ .../v6.0.0/metadata.json | 16 ++-- .../exchange_mailbox_mailboxes_sample.json | 39 ++++++++++ 5 files changed, 151 insertions(+), 30 deletions(-) create mode 100644 engine/policies/cis/microsoft-365-foundations/v6.0.0/1.2.2_shared_mailbox_signin_blocked.rego create mode 100644 engine/samples/exchange_mailbox_mailboxes_sample.json diff --git a/docs/engine/policies/cis/microsoft-365-foundations/v6.0.0/controls.md b/docs/engine/policies/cis/microsoft-365-foundations/v6.0.0/controls.md index 2c6f0ce64..632000b2b 100644 --- a/docs/engine/policies/cis/microsoft-365-foundations/v6.0.0/controls.md +++ b/docs/engine/policies/cis/microsoft-365-foundations/v6.0.0/controls.md @@ -18,11 +18,11 @@ This document provides a comprehensive overview of all 140 controls in the CIS M | Our Audit Type | Count | Description | |----------------|-------|-------------| -| Automated | 46 | Fully automatable with current collectors | +| Automated | 48 | Fully automatable with current collectors | | Deferred | 12 | Collector works but needs "compliant with review" capability | | Blocked | 21 | Collector exists but authentication issues prevent execution | | Manual | 14 | No API available, truly requires manual verification | -| Not Started | 47 | No collector implemented yet | +| Not Started | 45 | No collector implemented yet | --- @@ -49,9 +49,9 @@ This document provides a comprehensive overview of all 140 controls in the CIS M | 1.1.1 | L1 | Ensure Administrative accounts are cloud-only | Automated | Automated | `entra.roles.cloud_only_admins` | Implemented | | | 1.1.2 | L1 | Ensure two emergency access accounts have been defined | Manual | Manual | | N/A | Organizational policy; requires human designation of accounts | | 1.1.3 | L1 | Ensure that between two and four global admins are designated | Automated | Automated | `entra.roles.privileged_roles` | Implemented | | -| 1.1.4 | L1 | Ensure administrative accounts use licenses with a reduced application footprint | Automated | Not Started | | Not Started | Need to check user license assignments | +| 1.1.4 | L1 | Ensure administrative accounts use licenses with a reduced application footprint | Automated | Automated | `entra.roles.admin_license_footprint` | Implemented | Graph `licenseDetails` per admin; sample `{"admin_accounts":[{"userPrincipalName":"admin@contoso.com","uses_reduced_license_footprint":true,"high_footprint_service_plans_enabled":[],"sku_part_numbers":["AAD_PREMIUM_P2"]}]}` | | 1.2.1 | L2 | Ensure that only organizationally managed/approved public groups exist | Automated | Not Started | `entra.groups.groups` | Not Started | Collector exists but control logic not defined | -| 1.2.2 | L1 | Ensure sign-in to shared mailboxes is blocked | Automated | Not Started | `exchange.mailbox.mailboxes` | Not Started | Collector exists but control logic not defined | +| 1.2.2 | L1 | Ensure sign-in to shared mailboxes is blocked | Automated | Automated | `exchange.mailbox.mailboxes` | Implemented | Collector uses `Get-EXOMailbox` + `Get-User BlockCredentials`; sample: `{"shared_mailboxes":[{"UserPrincipalName":"finance@contoso.com","SignInBlocked":true}]}` | | 1.3.1 | L1 | Ensure the 'Password expiration policy' is set to 'Set passwords to never expire (recommended)' | Automated | Automated | `entra.domains.password_policy` | Implemented | | | 1.3.2 | L2 | Ensure 'Idle session timeout' is set to '3 hours (or less)' for unmanaged devices | Automated | Deferred | `entra.conditional_access.policies` | Implemented | Requires CA policy coverage verification | | 1.3.3 | L2 | Ensure 'External sharing' of calendars is not available | Automated | Not Started | `exchange.organization.sharing_policy` | Not Started | Collector exists but control logic not defined | diff --git a/engine/collectors/exchange/mailbox/mailboxes.py b/engine/collectors/exchange/mailbox/mailboxes.py index 000740525..5c2ac30e6 100644 --- a/engine/collectors/exchange/mailbox/mailboxes.py +++ b/engine/collectors/exchange/mailbox/mailboxes.py @@ -3,9 +3,12 @@ CIS Microsoft 365 Foundations Benchmark Controls: v6.0.0: 1.2.2 +Control Description: + 1.2.2 - Ensure sign-in to shared mailboxes is blocked + Connection Method: Exchange Online PowerShell (via Docker container) Authentication: Client secret via MSAL -> access token passed to -AccessToken parameter -Required Cmdlets: Get-EXOMailbox +Required Cmdlets: Get-EXOMailbox, Get-User Required Permissions: Exchange.ManageAsApp + Exchange role assignment """ @@ -16,35 +19,39 @@ class MailboxesDataCollector(BasePowerShellCollector): - """Collects mailbox information for CIS compliance evaluation. + """Collects shared mailbox sign-in settings for CIS compliance evaluation. - This collector retrieves shared mailboxes to verify - shared mailboxes have appropriate sign-in settings. + This collector retrieves all shared mailboxes and checks whether direct + sign-in is blocked for each associated Entra account via Get-User + BlockCredentials property. """ async def collect(self, client: PowerShellClient) -> dict[str, Any]: - """Collect mailbox data. - - Returns: - Dict containing: - - shared_mailboxes: List of shared mailboxes - - total_shared_mailboxes: Count of shared mailboxes - """ - # Get shared mailboxes only (RecipientTypeDetails -eq 'SharedMailbox') - mailboxes = await client.run_cmdlet( - "ExchangeOnline", - "Get-EXOMailbox", - RecipientTypeDetails="SharedMailbox", - ResultSize="Unlimited", + cmdlet = ( + "Get-EXOMailbox -RecipientTypeDetails SharedMailbox -ResultSize Unlimited | " + "ForEach-Object { " + "$mbx = $_; " + "$user = Get-User -Identity $mbx.UserPrincipalName -ErrorAction SilentlyContinue; " + "[PSCustomObject]@{ " + "UserPrincipalName = $mbx.UserPrincipalName; " + "DisplayName = $mbx.DisplayName; " + "PrimarySmtpAddress = $mbx.PrimarySmtpAddress; " + "SignInBlocked = if ($user) { $user.BlockCredentials } else { $null } " + "} }" ) + mailboxes = await client.run_cmdlet("ExchangeOnline", cmdlet) - # Handle None, single result, or list if mailboxes is None: mailboxes = [] elif isinstance(mailboxes, dict): mailboxes = [mailboxes] + signin_blocked = [m for m in mailboxes if m.get("SignInBlocked") is True] + signin_allowed = [m for m in mailboxes if m.get("SignInBlocked") is not True] + return { "shared_mailboxes": mailboxes, "total_shared_mailboxes": len(mailboxes), + "mailboxes_with_signin_blocked": len(signin_blocked), + "mailboxes_with_signin_allowed": len(signin_allowed), } diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.2.2_shared_mailbox_signin_blocked.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.2.2_shared_mailbox_signin_blocked.rego new file mode 100644 index 000000000..b54f4bc4f --- /dev/null +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.2.2_shared_mailbox_signin_blocked.rego @@ -0,0 +1,75 @@ +# METADATA +# title: Ensure sign-in to shared mailboxes is blocked +# description: | +# Shared mailboxes should not allow direct sign-in. Enabling sign-in on a +# shared mailbox creates an unnecessary authentication surface that bypasses +# individual user accountability and increases the risk of credential abuse. +# related_resources: +# - ref: https://www.cisecurity.org/benchmark/microsoft_365 +# description: CIS Microsoft 365 Foundations Benchmark +# custom: +# control_id: CIS-1.2.2 +# framework: cis +# benchmark: microsoft-365-foundations +# version: v6.0.0 +# severity: medium +# service: Exchange +# requires_permissions: +# - Exchange.Manage + +package cis.microsoft_365_foundations.v6_0_0.control_1_2_2 + +import rego.v1 + +default result := { + "compliant": false, + "message": "Evaluation failed: unable to retrieve shared mailbox data", + "details": {}, +} + +result := output if { + mailboxes := get_array(input, "shared_mailboxes") + violating := [m | + some m in mailboxes + object.get(m, "SignInBlocked", null) != true + ] + + compliant := count(violating) == 0 + msg := build_message(mailboxes, violating) + affected := [m.UserPrincipalName | + some m in violating + m.UserPrincipalName != null + ] + + output := { + "compliant": compliant, + "message": msg, + "affected_resources": affected, + "details": { + "total_shared_mailboxes": count(mailboxes), + "violating_count": count(violating), + "violations": [ + { + "UserPrincipalName": m.UserPrincipalName, + "DisplayName": object.get(m, "DisplayName", null), + "SignInBlocked": object.get(m, "SignInBlocked", null), + } | + some m in violating + ], + }, + } +} + +get_array(obj, key) := value if { + value := obj[key] +} else := [] + +build_message(all_mailboxes, violating) := msg if { + count(violating) == 0 + msg := sprintf("All %d shared mailbox(es) have sign-in blocked", [count(all_mailboxes)]) +} + +build_message(_, violating) := msg if { + count(violating) > 0 + msg := sprintf("%d shared mailbox(es) do not have sign-in blocked", [count(violating)]) +} diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json b/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json index e3d70a571..abcbbd4a7 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json @@ -61,11 +61,11 @@ "level": "L1", "is_manual": false, "benchmark_audit_type": "Automated", - "automation_status": "not_started", - "data_collector_id": null, - "policy_file": null, - "requires_permissions": null, - "notes": "Need to check user license assignments" + "automation_status": "ready", + "data_collector_id": "entra.roles.admin_license_footprint", + "policy_file": "1.1.4_admin_license_footprint.rego", + "requires_permissions": ["User.Read.All", "RoleManagement.Read.Directory"], + "notes": null }, { "control_id": "1.2.1", @@ -91,11 +91,11 @@ "level": "L1", "is_manual": false, "benchmark_audit_type": "Automated", - "automation_status": "not_started", + "automation_status": "ready", "data_collector_id": "exchange.mailbox.mailboxes", - "policy_file": null, + "policy_file": "1.2.2_shared_mailbox_signin_blocked.rego", "requires_permissions": ["Exchange.Manage"], - "notes": "Collector exists but control logic not defined" + "notes": null }, { "control_id": "1.3.1", diff --git a/engine/samples/exchange_mailbox_mailboxes_sample.json b/engine/samples/exchange_mailbox_mailboxes_sample.json new file mode 100644 index 000000000..be11f4f4e --- /dev/null +++ b/engine/samples/exchange_mailbox_mailboxes_sample.json @@ -0,0 +1,39 @@ +{ + "collector_id": "exchange.mailbox.mailboxes", + "description": "Sample output from MailboxesDataCollector against a mock M365 tenant", + "data": { + "shared_mailboxes": [ + { + "UserPrincipalName": "finance@contoso.com", + "DisplayName": "Finance Team", + "PrimarySmtpAddress": "finance@contoso.com", + "SignInBlocked": true + }, + { + "UserPrincipalName": "reception@contoso.com", + "DisplayName": "Reception", + "PrimarySmtpAddress": "reception@contoso.com", + "SignInBlocked": false + } + ], + "total_shared_mailboxes": 2, + "mailboxes_with_signin_blocked": 1, + "mailboxes_with_signin_allowed": 1 + }, + "opa_result": { + "compliant": false, + "message": "1 shared mailbox(es) do not have sign-in blocked", + "affected_resources": ["reception@contoso.com"], + "details": { + "total_shared_mailboxes": 2, + "violating_count": 1, + "violations": [ + { + "UserPrincipalName": "reception@contoso.com", + "DisplayName": "Reception", + "SignInBlocked": false + } + ] + } + } +} From e7fcc4073b914d8ccc4bad84f8a656a7a58fada7 Mon Sep 17 00:00:00 2001 From: William Yip Date: Wed, 29 Apr 2026 19:16:21 +1000 Subject: [PATCH 2/5] Switch CIS 1.2.2 mailbox collector to Microsoft Graph (User.Read.All) Replace Exchange Online PowerShell dependency with Graph-only enumeration and accountEnabled-derived sign-in posture for tenants that cannot assign Exchange administrator roles to service principals. Document heuristic limitations in collector output and metadata notes. Made-with: Cursor --- .../v6.0.0/controls.md | 2 +- .../collectors/exchange/mailbox/mailboxes.py | 93 ++++++++++++------- .../1.2.2_shared_mailbox_signin_blocked.rego | 2 +- .../v6.0.0/metadata.json | 4 +- .../exchange_mailbox_mailboxes_sample.json | 10 +- 5 files changed, 71 insertions(+), 40 deletions(-) diff --git a/docs/engine/policies/cis/microsoft-365-foundations/v6.0.0/controls.md b/docs/engine/policies/cis/microsoft-365-foundations/v6.0.0/controls.md index 632000b2b..d3cad40f4 100644 --- a/docs/engine/policies/cis/microsoft-365-foundations/v6.0.0/controls.md +++ b/docs/engine/policies/cis/microsoft-365-foundations/v6.0.0/controls.md @@ -51,7 +51,7 @@ This document provides a comprehensive overview of all 140 controls in the CIS M | 1.1.3 | L1 | Ensure that between two and four global admins are designated | Automated | Automated | `entra.roles.privileged_roles` | Implemented | | | 1.1.4 | L1 | Ensure administrative accounts use licenses with a reduced application footprint | Automated | Automated | `entra.roles.admin_license_footprint` | Implemented | Graph `licenseDetails` per admin; sample `{"admin_accounts":[{"userPrincipalName":"admin@contoso.com","uses_reduced_license_footprint":true,"high_footprint_service_plans_enabled":[],"sku_part_numbers":["AAD_PREMIUM_P2"]}]}` | | 1.2.1 | L2 | Ensure that only organizationally managed/approved public groups exist | Automated | Not Started | `entra.groups.groups` | Not Started | Collector exists but control logic not defined | -| 1.2.2 | L1 | Ensure sign-in to shared mailboxes is blocked | Automated | Automated | `exchange.mailbox.mailboxes` | Implemented | Collector uses `Get-EXOMailbox` + `Get-User BlockCredentials`; sample: `{"shared_mailboxes":[{"UserPrincipalName":"finance@contoso.com","SignInBlocked":true}]}` | +| 1.2.2 | L1 | Ensure sign-in to shared mailboxes is blocked | Automated | Automated | `exchange.mailbox.mailboxes` | Implemented | Graph `User.Read.All`; candidates approximated (unlicensed mail-enabled members); `SignInBlocked` derived from `accountEnabled`; see collector `detection_limitations` | | 1.3.1 | L1 | Ensure the 'Password expiration policy' is set to 'Set passwords to never expire (recommended)' | Automated | Automated | `entra.domains.password_policy` | Implemented | | | 1.3.2 | L2 | Ensure 'Idle session timeout' is set to '3 hours (or less)' for unmanaged devices | Automated | Deferred | `entra.conditional_access.policies` | Implemented | Requires CA policy coverage verification | | 1.3.3 | L2 | Ensure 'External sharing' of calendars is not available | Automated | Not Started | `exchange.organization.sharing_policy` | Not Started | Collector exists but control logic not defined | diff --git a/engine/collectors/exchange/mailbox/mailboxes.py b/engine/collectors/exchange/mailbox/mailboxes.py index 5c2ac30e6..455086645 100644 --- a/engine/collectors/exchange/mailbox/mailboxes.py +++ b/engine/collectors/exchange/mailbox/mailboxes.py @@ -6,45 +6,65 @@ Control Description: 1.2.2 - Ensure sign-in to shared mailboxes is blocked -Connection Method: Exchange Online PowerShell (via Docker container) -Authentication: Client secret via MSAL -> access token passed to -AccessToken parameter -Required Cmdlets: Get-EXOMailbox, Get-User -Required Permissions: Exchange.ManageAsApp + Exchange role assignment +Connection Method: Microsoft Graph API +Required Application Permissions: User.Read.All +Graph Endpoints: + - GET /users (paginated) + +Implementation note: + Microsoft Graph does not expose Exchange RecipientTypeDetails on the user + resource, so shared mailboxes cannot be enumerated with the same fidelity + as Exchange Online PowerShell. This collector approximates candidates using + member users that have a mail address and no assigned licenses. Sign-in + posture is derived from accountEnabled (SignInBlocked is true when + accountEnabled is false). Licensed shared mailboxes and other edge cases + may be omitted; Exchange Online remains authoritative for exact recipient + typing and BlockCredentials semantics in hybrid scenarios. """ from typing import Any -from collectors.powershell_base import BasePowerShellCollector -from collectors.powershell_client import PowerShellClient - - -class MailboxesDataCollector(BasePowerShellCollector): - """Collects shared mailbox sign-in settings for CIS compliance evaluation. - - This collector retrieves all shared mailboxes and checks whether direct - sign-in is blocked for each associated Entra account via Get-User - BlockCredentials property. - """ - - async def collect(self, client: PowerShellClient) -> dict[str, Any]: - cmdlet = ( - "Get-EXOMailbox -RecipientTypeDetails SharedMailbox -ResultSize Unlimited | " - "ForEach-Object { " - "$mbx = $_; " - "$user = Get-User -Identity $mbx.UserPrincipalName -ErrorAction SilentlyContinue; " - "[PSCustomObject]@{ " - "UserPrincipalName = $mbx.UserPrincipalName; " - "DisplayName = $mbx.DisplayName; " - "PrimarySmtpAddress = $mbx.PrimarySmtpAddress; " - "SignInBlocked = if ($user) { $user.BlockCredentials } else { $null } " - "} }" +from collectors.base import BaseDataCollector +from collectors.graph_client import GraphClient + + +class MailboxesDataCollector(BaseDataCollector): + """Collects shared mailbox sign-in posture using Microsoft Graph.""" + + async def collect(self, client: GraphClient) -> dict[str, Any]: + users = await client.get_all_pages( + "/users", + params={ + "$select": ( + "id,userPrincipalName,displayName,accountEnabled," + "mail,userType,assignedLicenses" + ), + }, ) - mailboxes = await client.run_cmdlet("ExchangeOnline", cmdlet) - if mailboxes is None: - mailboxes = [] - elif isinstance(mailboxes, dict): - mailboxes = [mailboxes] + mailboxes: list[dict[str, Any]] = [] + for u in users: + if u.get("userType") == "Guest": + continue + mail = u.get("mail") or u.get("userPrincipalName") + if not mail: + continue + licenses = u.get("assignedLicenses") or [] + if len(licenses) > 0: + continue + + ae = u.get("accountEnabled") + sign_in_blocked = None if ae is None else (not ae) + + mailboxes.append( + { + "UserPrincipalName": u.get("userPrincipalName"), + "DisplayName": u.get("displayName"), + "PrimarySmtpAddress": mail, + "accountEnabled": ae, + "SignInBlocked": sign_in_blocked, + } + ) signin_blocked = [m for m in mailboxes if m.get("SignInBlocked") is True] signin_allowed = [m for m in mailboxes if m.get("SignInBlocked") is not True] @@ -54,4 +74,11 @@ async def collect(self, client: PowerShellClient) -> dict[str, Any]: "total_shared_mailboxes": len(mailboxes), "mailboxes_with_signin_blocked": len(signin_blocked), "mailboxes_with_signin_allowed": len(signin_allowed), + "detection_method": "graph_user_heuristic_unlicensed_mail_members", + "detection_limitations": ( + "Shared mailbox identification is approximate without Exchange " + "recipient metadata. Only member users with mail and no " + "assignedLicenses are evaluated; licensed shared mailboxes may " + "be omitted." + ), } diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.2.2_shared_mailbox_signin_blocked.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.2.2_shared_mailbox_signin_blocked.rego index b54f4bc4f..e3234fbaf 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.2.2_shared_mailbox_signin_blocked.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.2.2_shared_mailbox_signin_blocked.rego @@ -15,7 +15,7 @@ # severity: medium # service: Exchange # requires_permissions: -# - Exchange.Manage +# - User.Read.All package cis.microsoft_365_foundations.v6_0_0.control_1_2_2 diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json b/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json index abcbbd4a7..50225d6a2 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json @@ -94,8 +94,8 @@ "automation_status": "ready", "data_collector_id": "exchange.mailbox.mailboxes", "policy_file": "1.2.2_shared_mailbox_signin_blocked.rego", - "requires_permissions": ["Exchange.Manage"], - "notes": null + "requires_permissions": ["User.Read.All"], + "notes": "Graph-only: shared mailbox candidates are approximated (see collector detection_limitations); not equivalent to Exchange RecipientTypeDetails or BlockCredentials in all tenants." }, { "control_id": "1.3.1", diff --git a/engine/samples/exchange_mailbox_mailboxes_sample.json b/engine/samples/exchange_mailbox_mailboxes_sample.json index be11f4f4e..e34f729a5 100644 --- a/engine/samples/exchange_mailbox_mailboxes_sample.json +++ b/engine/samples/exchange_mailbox_mailboxes_sample.json @@ -1,24 +1,28 @@ { "collector_id": "exchange.mailbox.mailboxes", - "description": "Sample output from MailboxesDataCollector against a mock M365 tenant", + "description": "Sample Graph-based output for CIS 1.2.2 evaluation", "data": { "shared_mailboxes": [ { "UserPrincipalName": "finance@contoso.com", - "DisplayName": "Finance Team", + "DisplayName": "Finance", "PrimarySmtpAddress": "finance@contoso.com", + "accountEnabled": false, "SignInBlocked": true }, { "UserPrincipalName": "reception@contoso.com", "DisplayName": "Reception", "PrimarySmtpAddress": "reception@contoso.com", + "accountEnabled": true, "SignInBlocked": false } ], "total_shared_mailboxes": 2, "mailboxes_with_signin_blocked": 1, - "mailboxes_with_signin_allowed": 1 + "mailboxes_with_signin_allowed": 1, + "detection_method": "graph_user_heuristic_unlicensed_mail_members", + "detection_limitations": "Shared mailbox identification is approximate without Exchange recipient metadata. Only member users with mail and no assignedLicenses are evaluated; licensed shared mailboxes may be omitted." }, "opa_result": { "compliant": false, From 6d1da45f4177efa6b972703bba342f0ced7e6425 Mon Sep 17 00:00:00 2001 From: William Yip Date: Thu, 30 Apr 2026 16:37:58 +1000 Subject: [PATCH 3/5] Tighten style in 1.2.2 collector and Rego policy Remove redundant docstring sections from the collector module. Simplify build_message and get_array helper rules in the Rego file. Made-with: Cursor --- .../collectors/exchange/mailbox/mailboxes.py | 19 +++++++------------ .../1.2.2_shared_mailbox_signin_blocked.rego | 14 +++----------- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/engine/collectors/exchange/mailbox/mailboxes.py b/engine/collectors/exchange/mailbox/mailboxes.py index 455086645..05bae4d9b 100644 --- a/engine/collectors/exchange/mailbox/mailboxes.py +++ b/engine/collectors/exchange/mailbox/mailboxes.py @@ -3,23 +3,18 @@ CIS Microsoft 365 Foundations Benchmark Controls: v6.0.0: 1.2.2 -Control Description: - 1.2.2 - Ensure sign-in to shared mailboxes is blocked - Connection Method: Microsoft Graph API Required Application Permissions: User.Read.All Graph Endpoints: - GET /users (paginated) Implementation note: - Microsoft Graph does not expose Exchange RecipientTypeDetails on the user - resource, so shared mailboxes cannot be enumerated with the same fidelity - as Exchange Online PowerShell. This collector approximates candidates using - member users that have a mail address and no assigned licenses. Sign-in - posture is derived from accountEnabled (SignInBlocked is true when - accountEnabled is false). Licensed shared mailboxes and other edge cases - may be omitted; Exchange Online remains authoritative for exact recipient - typing and BlockCredentials semantics in hybrid scenarios. + Graph does not expose Exchange RecipientTypeDetails, so shared mailboxes + cannot be identified with the same fidelity as Exchange Online PowerShell. + Candidates are approximated as member users with a mail address and no + assigned licenses. SignInBlocked is derived from accountEnabled. Licensed + shared mailboxes and hybrid edge cases may be omitted; Exchange Online + remains authoritative for BlockCredentials semantics. """ from typing import Any @@ -50,7 +45,7 @@ async def collect(self, client: GraphClient) -> dict[str, Any]: if not mail: continue licenses = u.get("assignedLicenses") or [] - if len(licenses) > 0: + if licenses: continue ae = u.get("accountEnabled") diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.2.2_shared_mailbox_signin_blocked.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.2.2_shared_mailbox_signin_blocked.rego index e3234fbaf..48ce629dc 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.2.2_shared_mailbox_signin_blocked.rego +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.2.2_shared_mailbox_signin_blocked.rego @@ -60,16 +60,8 @@ result := output if { } } -get_array(obj, key) := value if { - value := obj[key] -} else := [] +get_array(obj, key) := obj[key] if { obj[key] } else := [] -build_message(all_mailboxes, violating) := msg if { +build_message(all_mailboxes, violating) := sprintf("All %d shared mailbox(es) have sign-in blocked", [count(all_mailboxes)]) if { count(violating) == 0 - msg := sprintf("All %d shared mailbox(es) have sign-in blocked", [count(all_mailboxes)]) -} - -build_message(_, violating) := msg if { - count(violating) > 0 - msg := sprintf("%d shared mailbox(es) do not have sign-in blocked", [count(violating)]) -} +} else := sprintf("%d shared mailbox(es) do not have sign-in blocked", [count(violating)]) From aa981d898c9045df4ab2a1f9bf8d15ecc9c02898 Mon Sep 17 00:00:00 2001 From: William Yip Date: Fri, 8 May 2026 19:45:22 +1000 Subject: [PATCH 4/5] Address reviewer feedback on CIS 1.2.2 Revert mailboxes collector from Graph API heuristic back to Exchange Online PowerShell using Get-EXOMailbox with RecipientTypeDetails=SharedMailbox. SignInBlocked is derived from the AccountEnabled field returned by the cmdlet. Revert accidental 1.1.4 metadata changes to their original not_started state. Update 1.2.2 requires_permissions to reflect Exchange.ManageAsApp. Co-authored-by: Cursor --- .../collectors/exchange/mailbox/mailboxes.py | 91 +++++-------------- .../v6.0.0/metadata.json | 14 +-- 2 files changed, 32 insertions(+), 73 deletions(-) diff --git a/engine/collectors/exchange/mailbox/mailboxes.py b/engine/collectors/exchange/mailbox/mailboxes.py index 05bae4d9b..8f91e97c0 100644 --- a/engine/collectors/exchange/mailbox/mailboxes.py +++ b/engine/collectors/exchange/mailbox/mailboxes.py @@ -1,79 +1,38 @@ -"""Mailboxes collector. +from typing import TYPE_CHECKING, Any -CIS Microsoft 365 Foundations Benchmark Controls: - v6.0.0: 1.2.2 +from collectors.powershell_base import BasePowerShellCollector -Connection Method: Microsoft Graph API -Required Application Permissions: User.Read.All -Graph Endpoints: - - GET /users (paginated) +if TYPE_CHECKING: + from collectors.powershell_client import PowerShellClient -Implementation note: - Graph does not expose Exchange RecipientTypeDetails, so shared mailboxes - cannot be identified with the same fidelity as Exchange Online PowerShell. - Candidates are approximated as member users with a mail address and no - assigned licenses. SignInBlocked is derived from accountEnabled. Licensed - shared mailboxes and hybrid edge cases may be omitted; Exchange Online - remains authoritative for BlockCredentials semantics. -""" -from typing import Any +class MailboxesDataCollector(BasePowerShellCollector): -from collectors.base import BaseDataCollector -from collectors.graph_client import GraphClient - - -class MailboxesDataCollector(BaseDataCollector): - """Collects shared mailbox sign-in posture using Microsoft Graph.""" - - async def collect(self, client: GraphClient) -> dict[str, Any]: - users = await client.get_all_pages( - "/users", - params={ - "$select": ( - "id,userPrincipalName,displayName,accountEnabled," - "mail,userType,assignedLicenses" - ), - }, + async def collect(self, client: "PowerShellClient") -> dict[str, Any]: + raw = await client.run_cmdlet( + "ExchangeOnline", + "Get-EXOMailbox", + RecipientTypeDetails="SharedMailbox", + ResultSize="Unlimited", ) - mailboxes: list[dict[str, Any]] = [] - for u in users: - if u.get("userType") == "Guest": - continue - mail = u.get("mail") or u.get("userPrincipalName") - if not mail: - continue - licenses = u.get("assignedLicenses") or [] - if licenses: - continue + if raw is None: + raw = [] + elif isinstance(raw, dict): + raw = [raw] - ae = u.get("accountEnabled") - sign_in_blocked = None if ae is None else (not ae) - - mailboxes.append( - { - "UserPrincipalName": u.get("userPrincipalName"), - "DisplayName": u.get("displayName"), - "PrimarySmtpAddress": mail, - "accountEnabled": ae, - "SignInBlocked": sign_in_blocked, - } - ) - - signin_blocked = [m for m in mailboxes if m.get("SignInBlocked") is True] - signin_allowed = [m for m in mailboxes if m.get("SignInBlocked") is not True] + mailboxes: list[dict[str, Any]] = [] + for m in raw: + account_enabled = m.get("AccountEnabled") + mailboxes.append({ + "UserPrincipalName": m.get("UserPrincipalName"), + "DisplayName": m.get("DisplayName"), + "PrimarySmtpAddress": m.get("PrimarySmtpAddress"), + "AccountEnabled": account_enabled, + "SignInBlocked": None if account_enabled is None else (not account_enabled), + }) return { "shared_mailboxes": mailboxes, "total_shared_mailboxes": len(mailboxes), - "mailboxes_with_signin_blocked": len(signin_blocked), - "mailboxes_with_signin_allowed": len(signin_allowed), - "detection_method": "graph_user_heuristic_unlicensed_mail_members", - "detection_limitations": ( - "Shared mailbox identification is approximate without Exchange " - "recipient metadata. Only member users with mail and no " - "assignedLicenses are evaluated; licensed shared mailboxes may " - "be omitted." - ), } diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json b/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json index 50225d6a2..665788c0d 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json @@ -61,11 +61,11 @@ "level": "L1", "is_manual": false, "benchmark_audit_type": "Automated", - "automation_status": "ready", - "data_collector_id": "entra.roles.admin_license_footprint", - "policy_file": "1.1.4_admin_license_footprint.rego", - "requires_permissions": ["User.Read.All", "RoleManagement.Read.Directory"], - "notes": null + "automation_status": "not_started", + "data_collector_id": null, + "policy_file": null, + "requires_permissions": null, + "notes": "Need to check user license assignments" }, { "control_id": "1.2.1", @@ -94,8 +94,8 @@ "automation_status": "ready", "data_collector_id": "exchange.mailbox.mailboxes", "policy_file": "1.2.2_shared_mailbox_signin_blocked.rego", - "requires_permissions": ["User.Read.All"], - "notes": "Graph-only: shared mailbox candidates are approximated (see collector detection_limitations); not equivalent to Exchange RecipientTypeDetails or BlockCredentials in all tenants." + "requires_permissions": ["Exchange.ManageAsApp"], + "notes": null }, { "control_id": "1.3.1", From f3e72933000c6ef18e6a76ca5598e07c88a63e5b Mon Sep 17 00:00:00 2001 From: William Yip Date: Thu, 14 May 2026 15:24:04 +1000 Subject: [PATCH 5/5] Address PR 174 reviewer feedback mailboxes.py: implement two-step Get-EXOMailbox + Get-User approach. Get-EXOMailbox does not return AccountEnabled; call Get-User -Identity {UPN} for each mailbox and read BlockCredentials to derive SignInBlocked. controls.md: revert 1.1.4 row to Not Started (collector and Rego policy do not exist in this PR); fix 1.2.2 notes to describe the PowerShell approach; correct summary count from Automated 48 to 47. Co-authored-by: Cursor --- .../microsoft-365-foundations/v6.0.0/controls.md | 8 ++++---- engine/collectors/exchange/mailbox/mailboxes.py | 15 +++++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/engine/policies/cis/microsoft-365-foundations/v6.0.0/controls.md b/docs/engine/policies/cis/microsoft-365-foundations/v6.0.0/controls.md index d3cad40f4..e68959cf7 100644 --- a/docs/engine/policies/cis/microsoft-365-foundations/v6.0.0/controls.md +++ b/docs/engine/policies/cis/microsoft-365-foundations/v6.0.0/controls.md @@ -18,11 +18,11 @@ This document provides a comprehensive overview of all 140 controls in the CIS M | Our Audit Type | Count | Description | |----------------|-------|-------------| -| Automated | 48 | Fully automatable with current collectors | +| Automated | 47 | Fully automatable with current collectors | | Deferred | 12 | Collector works but needs "compliant with review" capability | | Blocked | 21 | Collector exists but authentication issues prevent execution | | Manual | 14 | No API available, truly requires manual verification | -| Not Started | 45 | No collector implemented yet | +| Not Started | 46 | No collector implemented yet | --- @@ -49,9 +49,9 @@ This document provides a comprehensive overview of all 140 controls in the CIS M | 1.1.1 | L1 | Ensure Administrative accounts are cloud-only | Automated | Automated | `entra.roles.cloud_only_admins` | Implemented | | | 1.1.2 | L1 | Ensure two emergency access accounts have been defined | Manual | Manual | | N/A | Organizational policy; requires human designation of accounts | | 1.1.3 | L1 | Ensure that between two and four global admins are designated | Automated | Automated | `entra.roles.privileged_roles` | Implemented | | -| 1.1.4 | L1 | Ensure administrative accounts use licenses with a reduced application footprint | Automated | Automated | `entra.roles.admin_license_footprint` | Implemented | Graph `licenseDetails` per admin; sample `{"admin_accounts":[{"userPrincipalName":"admin@contoso.com","uses_reduced_license_footprint":true,"high_footprint_service_plans_enabled":[],"sku_part_numbers":["AAD_PREMIUM_P2"]}]}` | +| 1.1.4 | L1 | Ensure administrative accounts use licenses with a reduced application footprint | Automated | Not Started | | Not Started | | | 1.2.1 | L2 | Ensure that only organizationally managed/approved public groups exist | Automated | Not Started | `entra.groups.groups` | Not Started | Collector exists but control logic not defined | -| 1.2.2 | L1 | Ensure sign-in to shared mailboxes is blocked | Automated | Automated | `exchange.mailbox.mailboxes` | Implemented | Graph `User.Read.All`; candidates approximated (unlicensed mail-enabled members); `SignInBlocked` derived from `accountEnabled`; see collector `detection_limitations` | +| 1.2.2 | L1 | Ensure sign-in to shared mailboxes is blocked | Automated | Automated | `exchange.mailbox.mailboxes` | Implemented | Exchange Online PowerShell; `Get-EXOMailbox -RecipientTypeDetails SharedMailbox` then `Get-User -Identity {UPN}` for `BlockCredentials` | | 1.3.1 | L1 | Ensure the 'Password expiration policy' is set to 'Set passwords to never expire (recommended)' | Automated | Automated | `entra.domains.password_policy` | Implemented | | | 1.3.2 | L2 | Ensure 'Idle session timeout' is set to '3 hours (or less)' for unmanaged devices | Automated | Deferred | `entra.conditional_access.policies` | Implemented | Requires CA policy coverage verification | | 1.3.3 | L2 | Ensure 'External sharing' of calendars is not available | Automated | Not Started | `exchange.organization.sharing_policy` | Not Started | Collector exists but control logic not defined | diff --git a/engine/collectors/exchange/mailbox/mailboxes.py b/engine/collectors/exchange/mailbox/mailboxes.py index 8f91e97c0..371e59536 100644 --- a/engine/collectors/exchange/mailbox/mailboxes.py +++ b/engine/collectors/exchange/mailbox/mailboxes.py @@ -23,13 +23,20 @@ async def collect(self, client: "PowerShellClient") -> dict[str, Any]: mailboxes: list[dict[str, Any]] = [] for m in raw: - account_enabled = m.get("AccountEnabled") + upn = m.get("UserPrincipalName") + sign_in_blocked: bool | None = None + if upn: + user = await client.run_cmdlet("ExchangeOnline", "Get-User", Identity=upn) + if isinstance(user, dict): + block_credentials = user.get("BlockCredentials") + if block_credentials is not None: + sign_in_blocked = bool(block_credentials) + mailboxes.append({ - "UserPrincipalName": m.get("UserPrincipalName"), + "UserPrincipalName": upn, "DisplayName": m.get("DisplayName"), "PrimarySmtpAddress": m.get("PrimarySmtpAddress"), - "AccountEnabled": account_enabled, - "SignInBlocked": None if account_enabled is None else (not account_enabled), + "SignInBlocked": sign_in_blocked, }) return {