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..a99db2556 100644 --- a/engine/collectors/exchange/organization/sharing_policy.py +++ b/engine/collectors/exchange/organization/sharing_policy.py @@ -15,6 +15,79 @@ from collectors.powershell_client import PowerShellClient +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. + + 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): """Collects sharing policy settings for CIS compliance evaluation. @@ -29,37 +102,29 @@ async def collect(self, client: PowerShellClient) -> dict[str, Any]: Dict containing: - sharing_policies: List of sharing policies - default_policy: The default sharing policy - - policies_allowing_external: Policies that allow external sharing + - 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") - # 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..55ebc4269 --- /dev/null +++ b/engine/samples/exchange_organization_sharing_policy_sample.json @@ -0,0 +1,83 @@ +{ + "collector_id": "exchange.organization.sharing_policy", + "timestamp": "2026-05-11T21:54:06.172189", + "elapsed_seconds": 10.545, + "data": { + "sharing_policies": [ + { + "Domains": [ + "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": { + "Domains": [ + "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:CalendarSharingFreeBusyReviewer", + "reason": "anonymous_calendar_sharing" + }, + { + "policy_name": "Default Sharing Policy", + "domain_entry": "*:CalendarSharingFreeBusySimple", + "reason": "wildcard_calendar_sharing" + } + ], + "external_calendar_sharing_restricted": false + } +}