From 3fd9fea956207dd12df0305d4ac8e158f0b74f01 Mon Sep 17 00:00:00 2001 From: William Yip Date: Thu, 30 Apr 2026 16:49:30 +1000 Subject: [PATCH 1/2] Add Rego policy for CIS 1.3.3 (external calendar sharing) Rewrites the sharing_policy collector to produce structured violation data: each SharingPolicy Domains entry is parsed as a domain:Capability pair, and entries using Anonymous or wildcard (*) domains with CalendarSharing capabilities are flagged as non-compliant. Named SMTP partner domains are not evaluated. The Rego policy (1.3.3_external_calendar_sharing.rego) checks calendar_sharing_violations from the collector output and reports each violating domain entry as an affected resource. Includes a sample JSON file for manual OPA eval testing. Made-with: Cursor --- .../v6.0.0/controls.md | 6 +- .../exchange/organization/sharing_policy.py | 113 +++++++++++++----- .../1.3.3_external_calendar_sharing.rego | 55 +++++++++ .../v6.0.0/metadata.json | 6 +- ...ge_organization_sharing_policy_sample.json | 65 ++++++++++ 5 files changed, 208 insertions(+), 37 deletions(-) create mode 100644 engine/policies/cis/microsoft-365-foundations/v6.0.0/1.3.3_external_calendar_sharing.rego create mode 100644 engine/samples/exchange_organization_sharing_policy_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..18ca83a4e 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 | 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 | 47 | No collector implemented yet | +| Not Started | 46 | No collector implemented yet | --- @@ -54,7 +54,7 @@ This document provides a comprehensive overview of all 140 controls in the CIS M | 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.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 | +| 1.3.3 | L2 | Ensure 'External sharing' of calendars is not available | Automated | Automated | `exchange.organization.sharing_policy` | Implemented | `Get-SharingPolicy` Domains; flags Anonymous/wildcard + CalendarSharing on enabled policies; see metadata notes | | 1.3.4 | L1 | Ensure 'User owned apps and services' is restricted | Automated | Automated | `entra.applications.apps_and_services_settings` | Implemented | | | 1.3.5 | L1 | Ensure internal phishing protection for Forms is enabled | Automated | Automated | `entra.applications.forms_settings` | Implemented | | | 1.3.6 | L2 | Ensure the customer lockbox feature is enabled | Automated | Automated | `exchange.organization.organization_config` | Implemented | | diff --git a/engine/collectors/exchange/organization/sharing_policy.py b/engine/collectors/exchange/organization/sharing_policy.py index 202a89d5b..d856290ab 100644 --- a/engine/collectors/exchange/organization/sharing_policy.py +++ b/engine/collectors/exchange/organization/sharing_policy.py @@ -15,51 +15,102 @@ from collectors.powershell_client import PowerShellClient -class SharingPolicyDataCollector(BasePowerShellCollector): - """Collects sharing policy settings for CIS compliance evaluation. +def _normalize_domains(domains: Any) -> list[str]: + if domains is None: + return [] + if isinstance(domains, str): + s = domains.strip() + if not s: + return [] + for sep in (";", ","): + if sep in s: + return [p.strip() for p in s.split(sep) if p.strip()] + return [s] + if isinstance(domains, list): + out: list[str] = [] + for item in domains: + if not isinstance(item, str): + continue + chunk = item.strip() + if not chunk: + continue + if ";" in chunk or "," in chunk: + sep = ";" if ";" in chunk else "," + out.extend(p.strip() for p in chunk.split(sep) if p.strip()) + else: + out.append(chunk) + return out + return [] + + +def _calendar_external_sharing_violation(domain_entry: str) -> str | None: + """Return a reason code for non-compliant Domains entries. - This collector retrieves external calendar sharing settings - to verify proper sharing restrictions are configured. + SharingPolicy Domains are ``domain:Capability`` pairs. Only Anonymous and wildcard + entries with CalendarSharing capabilities are flagged; named SMTP partner domains + are not evaluated here. """ + if ":" not in domain_entry: + return None + domain_part, cap = domain_entry.split(":", 1) + domain_part = domain_part.strip() + cap = cap.strip() + if not domain_part or not cap: + return None + if "calendarsharing" not in cap.lower(): + return None + low = domain_part.lower() + if low == "anonymous": + return "anonymous_calendar_sharing" + if low == "*": + return "wildcard_calendar_sharing" + return None + + +def _violations_for_policies(policies: list[Any]) -> list[dict[str, Any]]: + violations: list[dict[str, Any]] = [] + for policy in policies: + if not isinstance(policy, dict): + continue + if policy.get("Enabled") is False: + continue + name = policy.get("Name") or "Unknown" + for entry in _normalize_domains(policy.get("Domains")): + reason = _calendar_external_sharing_violation(entry) + if reason: + violations.append( + { + "policy_name": name, + "domain_entry": entry, + "reason": reason, + } + ) + return violations + + +class SharingPolicyDataCollector(BasePowerShellCollector): async def collect(self, client: PowerShellClient) -> dict[str, Any]: - """Collect sharing policy data. - - Returns: - Dict containing: - - sharing_policies: List of sharing policies - - default_policy: The default sharing policy - - policies_allowing_external: Policies that allow external sharing - """ policies = await client.run_cmdlet("ExchangeOnline", "Get-SharingPolicy") - # Handle None, single policy, or list if policies is None: policies = [] elif isinstance(policies, dict): policies = [policies] - # Find default policy - default_policy = next( - (p for p in policies if p.get("Default")), - policies[0] if policies else None - ) - - # Check for policies allowing external sharing - # Domains field contains sharing rules - if it has entries, sharing is enabled - policies_allowing_external = [] - for policy in policies: - domains = policy.get("Domains", []) - if domains: - policies_allowing_external.append({ - "name": policy.get("Name"), - "domains": domains, - "enabled": policy.get("Enabled"), - }) + default_policy = None + if policies: + default_policy = next( + (p for p in policies if p.get("Default")), + policies[0], + ) + + violations = _violations_for_policies(policies) return { "sharing_policies": policies, "total_policies": len(policies), "default_policy": default_policy, - "policies_allowing_external": policies_allowing_external, + "calendar_sharing_violations": violations, + "external_calendar_sharing_restricted": len(violations) == 0, } diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.3.3_external_calendar_sharing.rego b/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.3.3_external_calendar_sharing.rego new file mode 100644 index 000000000..5880ed14e --- /dev/null +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/1.3.3_external_calendar_sharing.rego @@ -0,0 +1,55 @@ +# METADATA +# title: Ensure 'External sharing' of calendars is not available +# description: | +# Calendar information must not be shared using sharing-policy rules that +# apply anonymously or to any unspecified domain. This policy flags Domains +# entries that pair Anonymous or wildcard domains with CalendarSharing +# capabilities on enabled sharing policies. +# related_resources: +# - ref: https://www.cisecurity.org/benchmark/microsoft_365 +# description: CIS Microsoft 365 Foundations Benchmark +# custom: +# control_id: CIS-1.3.3 +# 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_3_3 + +import rego.v1 + +default result := { + "compliant": false, + "message": "Evaluation failed: unable to retrieve sharing policy data", + "details": {}, +} + +result := output if { + input.sharing_policies != null + violations := get_array(input, "calendar_sharing_violations") + affected := [sprintf("%s: %s", [v.policy_name, v.domain_entry]) | + some v in violations + v.policy_name != null + v.domain_entry != null + ] + output := { + "compliant": count(violations) == 0, + "message": build_message(violations), + "affected_resources": affected, + "details": { + "total_policies": object.get(input, "total_policies", null), + "violating_count": count(violations), + "violations": violations, + }, + } +} + +get_array(obj, key) := obj[key] if { obj[key] } else := [] + +build_message(violations) := "No enabled sharing policies allow anonymous or wildcard calendar sharing" if { + count(violations) == 0 +} else := sprintf("%d calendar sharing domain rule(s) allow anonymous or wildcard external-style sharing", [count(violations)]) 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 c6c02e25a..4b5e724ba 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 @@ -136,11 +136,11 @@ "level": "L2", "is_manual": false, "benchmark_audit_type": "Automated", - "automation_status": "not_started", + "automation_status": "ready", "data_collector_id": "exchange.organization.sharing_policy", - "policy_file": null, + "policy_file": "1.3.3_external_calendar_sharing.rego", "requires_permissions": ["Exchange.Manage"], - "notes": "Collector exists but control logic not defined" + "notes": "Flags Anonymous: or *: Domains entries with CalendarSharing capabilities on enabled policies; does not evaluate named external SMTP domains." }, { "control_id": "1.3.4", diff --git a/engine/samples/exchange_organization_sharing_policy_sample.json b/engine/samples/exchange_organization_sharing_policy_sample.json new file mode 100644 index 000000000..b62b9bb81 --- /dev/null +++ b/engine/samples/exchange_organization_sharing_policy_sample.json @@ -0,0 +1,65 @@ +{ + "collector_id": "exchange.organization.sharing_policy", + "description": "Sample output for CIS 1.3.3 (anonymous + wildcard calendar sharing)", + "data": { + "sharing_policies": [ + { + "Name": "Default Sharing Policy", + "Default": true, + "Enabled": true, + "Domains": [ + "Anonymous:CalendarSharingFreeBusySimple", + "*:CalendarSharingFreeBusyReviewer" + ] + } + ], + "total_policies": 1, + "default_policy": { + "Name": "Default Sharing Policy", + "Default": true, + "Enabled": true, + "Domains": [ + "Anonymous:CalendarSharingFreeBusySimple", + "*:CalendarSharingFreeBusyReviewer" + ] + }, + "calendar_sharing_violations": [ + { + "policy_name": "Default Sharing Policy", + "domain_entry": "Anonymous:CalendarSharingFreeBusySimple", + "reason": "anonymous_calendar_sharing" + }, + { + "policy_name": "Default Sharing Policy", + "domain_entry": "*:CalendarSharingFreeBusyReviewer", + "reason": "wildcard_calendar_sharing" + } + ], + "external_calendar_sharing_restricted": false + }, + "opa_result": { + "compliant": false, + "message": "2 calendar sharing domain rule(s) allow anonymous or wildcard external-style sharing", + "affected_resources": [ + "Default Sharing Policy: Anonymous:CalendarSharingFreeBusySimple", + "Default Sharing Policy: *:CalendarSharingFreeBusyReviewer" + ], + "details": { + "total_policies": 1, + "violating_count": 2, + "violations": [ + { + "policy_name": "Default Sharing Policy", + "domain_entry": "Anonymous:CalendarSharingFreeBusySimple", + "reason": "anonymous_calendar_sharing" + }, + { + "policy_name": "Default Sharing Policy", + "domain_entry": "*:CalendarSharingFreeBusyReviewer", + "reason": "wildcard_calendar_sharing" + } + ], + "collector_error": null + } + } +} From 267e86104c1b0d828a8edf968d6ca263f7565baa Mon Sep 17 00:00:00 2001 From: William Yip Date: Mon, 11 May 2026 22:14:26 +1000 Subject: [PATCH 2/2] Restore docstrings for SharingPolicyDataCollector and update sample JSON Restore class and collect method docstrings that were accidentally removed. Replace placeholder sample JSON with real output from live sandbox tenant. Co-authored-by: Cursor --- .../exchange/organization/sharing_policy.py | 14 +++ ...ge_organization_sharing_policy_sample.json | 98 +++++++++++-------- 2 files changed, 72 insertions(+), 40 deletions(-) diff --git a/engine/collectors/exchange/organization/sharing_policy.py b/engine/collectors/exchange/organization/sharing_policy.py index d856290ab..a99db2556 100644 --- a/engine/collectors/exchange/organization/sharing_policy.py +++ b/engine/collectors/exchange/organization/sharing_policy.py @@ -89,8 +89,22 @@ def _violations_for_policies(policies: list[Any]) -> list[dict[str, Any]]: class SharingPolicyDataCollector(BasePowerShellCollector): + """Collects sharing policy settings for CIS compliance evaluation. + + This collector retrieves external calendar sharing settings + to verify proper sharing restrictions are configured. + """ async def collect(self, client: PowerShellClient) -> dict[str, Any]: + """Collect sharing policy data. + + Returns: + Dict containing: + - sharing_policies: List of sharing policies + - default_policy: The default sharing policy + - calendar_sharing_violations: Non-compliant calendar sharing rules + - external_calendar_sharing_restricted: True if no violations found + """ policies = await client.run_cmdlet("ExchangeOnline", "Get-SharingPolicy") if policies is None: diff --git a/engine/samples/exchange_organization_sharing_policy_sample.json b/engine/samples/exchange_organization_sharing_policy_sample.json index b62b9bb81..55ebc4269 100644 --- a/engine/samples/exchange_organization_sharing_policy_sample.json +++ b/engine/samples/exchange_organization_sharing_policy_sample.json @@ -1,65 +1,83 @@ { "collector_id": "exchange.organization.sharing_policy", - "description": "Sample output for CIS 1.3.3 (anonymous + wildcard calendar sharing)", + "timestamp": "2026-05-11T21:54:06.172189", + "elapsed_seconds": 10.545, "data": { "sharing_policies": [ { - "Name": "Default Sharing Policy", - "Default": true, - "Enabled": true, "Domains": [ - "Anonymous:CalendarSharingFreeBusySimple", - "*:CalendarSharingFreeBusyReviewer" - ] + "Anonymous:CalendarSharingFreeBusyReviewer", + "*:CalendarSharingFreeBusySimple" + ], + "Enabled": true, + "Default": true, + "AdminDisplayName": "", + "ExchangeVersion": "0.10 (14.0.100.0)", + "Name": "Default Sharing Policy", + "DistinguishedName": "CN=Default Sharing Policy,CN=Federation,CN=Configuration,CN=t8sjf.onmicrosoft.com,CN=ConfigurationUnits,DC=APCPR03A007,DC=PROD,DC=OUTLOOK,DC=COM", + "Identity": "Default Sharing Policy", + "ObjectCategory": "APCPR03A007.PROD.OUTLOOK.COM/Configuration/Schema/ms-Exch-Sharing-Policy", + "ObjectClass": [ + "top", + "msExchSharingPolicy" + ], + "WhenChanged": "2026-01-11T03:59:50+00:00", + "WhenCreated": "2026-01-11T03:59:43+00:00", + "WhenChangedUTC": "2026-01-11T03:59:50Z", + "WhenCreatedUTC": "2026-01-11T03:59:43Z", + "ExchangeObjectId": "512f2621-2928-4360-8510-277ea11cda72", + "OrganizationalUnitRoot": "t8sjf.onmicrosoft.com", + "OrganizationId": "APCPR03A007.PROD.OUTLOOK.COM/Microsoft Exchange Hosted Organizations/t8sjf.onmicrosoft.com - APCPR03A007.PROD.OUTLOOK.COM/ConfigurationUnits/t8sjf.onmicrosoft.com/Configuration", + "Id": "Default Sharing Policy", + "Guid": "512f2621-2928-4360-8510-277ea11cda72", + "OriginatingServer": "TYZPR03A07DC004.APCPR03A007.PROD.OUTLOOK.COM", + "IsValid": true, + "ObjectState": "Changed" } ], "total_policies": 1, "default_policy": { - "Name": "Default Sharing Policy", - "Default": true, - "Enabled": true, "Domains": [ - "Anonymous:CalendarSharingFreeBusySimple", - "*:CalendarSharingFreeBusyReviewer" - ] + "Anonymous:CalendarSharingFreeBusyReviewer", + "*:CalendarSharingFreeBusySimple" + ], + "Enabled": true, + "Default": true, + "AdminDisplayName": "", + "ExchangeVersion": "0.10 (14.0.100.0)", + "Name": "Default Sharing Policy", + "DistinguishedName": "CN=Default Sharing Policy,CN=Federation,CN=Configuration,CN=t8sjf.onmicrosoft.com,CN=ConfigurationUnits,DC=APCPR03A007,DC=PROD,DC=OUTLOOK,DC=COM", + "Identity": "Default Sharing Policy", + "ObjectCategory": "APCPR03A007.PROD.OUTLOOK.COM/Configuration/Schema/ms-Exch-Sharing-Policy", + "ObjectClass": [ + "top", + "msExchSharingPolicy" + ], + "WhenChanged": "2026-01-11T03:59:50+00:00", + "WhenCreated": "2026-01-11T03:59:43+00:00", + "WhenChangedUTC": "2026-01-11T03:59:50Z", + "WhenCreatedUTC": "2026-01-11T03:59:43Z", + "ExchangeObjectId": "512f2621-2928-4360-8510-277ea11cda72", + "OrganizationalUnitRoot": "t8sjf.onmicrosoft.com", + "OrganizationId": "APCPR03A007.PROD.OUTLOOK.COM/Microsoft Exchange Hosted Organizations/t8sjf.onmicrosoft.com - APCPR03A007.PROD.OUTLOOK.COM/ConfigurationUnits/t8sjf.onmicrosoft.com/Configuration", + "Id": "Default Sharing Policy", + "Guid": "512f2621-2928-4360-8510-277ea11cda72", + "OriginatingServer": "TYZPR03A07DC004.APCPR03A007.PROD.OUTLOOK.COM", + "IsValid": true, + "ObjectState": "Changed" }, "calendar_sharing_violations": [ { "policy_name": "Default Sharing Policy", - "domain_entry": "Anonymous:CalendarSharingFreeBusySimple", + "domain_entry": "Anonymous:CalendarSharingFreeBusyReviewer", "reason": "anonymous_calendar_sharing" }, { "policy_name": "Default Sharing Policy", - "domain_entry": "*:CalendarSharingFreeBusyReviewer", + "domain_entry": "*:CalendarSharingFreeBusySimple", "reason": "wildcard_calendar_sharing" } ], "external_calendar_sharing_restricted": false - }, - "opa_result": { - "compliant": false, - "message": "2 calendar sharing domain rule(s) allow anonymous or wildcard external-style sharing", - "affected_resources": [ - "Default Sharing Policy: Anonymous:CalendarSharingFreeBusySimple", - "Default Sharing Policy: *:CalendarSharingFreeBusyReviewer" - ], - "details": { - "total_policies": 1, - "violating_count": 2, - "violations": [ - { - "policy_name": "Default Sharing Policy", - "domain_entry": "Anonymous:CalendarSharingFreeBusySimple", - "reason": "anonymous_calendar_sharing" - }, - { - "policy_name": "Default Sharing Policy", - "domain_entry": "*:CalendarSharingFreeBusyReviewer", - "reason": "wildcard_calendar_sharing" - } - ], - "collector_error": null - } } }