Checks/MDO/MET-MDO008-PresetPolicyCoverage.ps1
|
# ── Mailbox list (lazy-cached in context) ──────────────────────────────────── $allMailboxes = $null if ($METContext -and $METContext.AllMailboxes) { $allMailboxes = $METContext.AllMailboxes } else { try { $allMailboxes = @(Get-METAssessableMailboxes) if ($METContext) { $METContext.AllMailboxes = $allMailboxes } } catch { New-METCheckResult -CheckId 'MET-MDO008' -Category MDO -Name 'Preset Policy Coverage' ` -Result Fail -Severity High -AffectedObject 'All Mailboxes' ` -Finding 'Unable to retrieve mailbox list to assess coverage.' ` -Recommendation 'Ensure the account has Exchange View-Only Recipients permission.' ` -ReferenceUrl 'https://aka.ms/mdo-presetpolicies' -ErrorMessage $_.ToString() return } } $total = $allMailboxes.Count if ($total -eq 0) { New-METCheckResult -CheckId 'MET-MDO008' -Category MDO -Name 'Preset Policy Coverage' ` -Result NotApplicable -Severity High -AffectedObject 'All Mailboxes' ` -Finding 'No mailboxes found in the tenant.' ` -ReferenceUrl 'https://aka.ms/mdo-presetpolicies' return } $groupCache = if ($METContext -and $METContext.GroupMembers) { $METContext.GroupMembers } else { @{} } # ── Build per-mailbox EOP + ATP coverage matrix ─────────────────────────────── $retrievalErrors = [System.Collections.Generic.List[string]]::new() $matrix = Resolve-METCoverageMatrix -AllMailboxes $allMailboxes -GroupCache $groupCache -RetrievalErrors $retrievalErrors if ($retrievalErrors.Count -gt 0) { New-METCheckResult -CheckId 'MET-MDO008' -Category MDO -Name 'Preset Policy Coverage' ` -Result Fail -Severity High -AffectedObject 'Policy Coverage Data' ` -Finding 'Unable to complete policy coverage assessment because required policy or recipient data could not be retrieved.' ` -Recommendation 'Verify Exchange Online and Microsoft Graph permissions and retry the assessment.' ` -ReferenceUrl 'https://aka.ms/mdo-presetpolicies' -ErrorMessage ($retrievalErrors -join "`n") return } $contradictionSets = @( @{ Label = 'EOP Preset'; Getter = { @(Get-EOPProtectionPolicyRule -ErrorAction Stop | Where-Object State -eq 'Enabled') }; Rules = @() }, @{ Label = 'MDO Preset'; Getter = { @(Get-ATPProtectionPolicyRule -ErrorAction Stop | Where-Object State -eq 'Enabled') }; Rules = @() }, @{ Label = 'Safe Links'; Getter = { @(Get-SafeLinksRule -ErrorAction Stop | Where-Object State -eq 'Enabled') }; Rules = @() }, @{ Label = 'Safe Attachments'; Getter = { @(Get-SafeAttachmentRule -ErrorAction Stop | Where-Object State -eq 'Enabled') }; Rules = @() }, @{ Label = 'Anti-Phishing'; Getter = { @(Get-AntiPhishRule -ErrorAction Stop | Where-Object { $_.State -eq 'Enabled' -and $_.Name -ne 'Office365 AntiPhish Default' }) }; Rules = @() }, @{ Label = 'Anti-Spam'; Getter = { @(Get-HostedContentFilterRule -ErrorAction Stop | Where-Object { $_.State -eq 'Enabled' -and $_.Name -ne 'Default' }) }; Rules = @() } ) foreach ($set in $contradictionSets) { try { $set.Rules = @(& $set.Getter) } catch { $retrievalErrors.Add("Unable to retrieve $($set.Label) rules for contradiction analysis. $($_.ToString())") } } if ($retrievalErrors.Count -gt 0) { New-METCheckResult -CheckId 'MET-MDO008' -Category MDO -Name 'Preset Policy Coverage' ` -Result Fail -Severity High -AffectedObject 'Policy Contradiction Data' ` -Finding 'Unable to complete policy contradiction assessment because one or more rule collections could not be retrieved.' ` -Recommendation 'Verify Exchange Online permissions and retry the assessment.' ` -ReferenceUrl 'https://aka.ms/mdo-presetpolicies' -ErrorMessage ($retrievalErrors -join "`n") return } $allContradictions = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($set in $contradictionSets) { $rules = @($set.Rules) if ($rules.Count -eq 0) { continue } $found = @(Find-METRuleContradictions -Rules $rules -AllMailboxes $allMailboxes -GroupCache $groupCache -PolicyType $set.Label -RetrievalErrors $retrievalErrors) foreach ($c in $found) { $allContradictions.Add($c) } } if ($retrievalErrors.Count -gt 0) { New-METCheckResult -CheckId 'MET-MDO008' -Category MDO -Name 'Preset Policy Coverage' ` -Result Fail -Severity High -AffectedObject 'Policy Recipient Data' ` -Finding 'Unable to complete policy contradiction assessment because one or more group scopes could not be expanded.' ` -Recommendation 'Verify Exchange Online and Microsoft Graph group-read permissions and retry the assessment.' ` -ReferenceUrl 'https://aka.ms/mdo-presetpolicies' -ErrorMessage ($retrievalErrors -join "`n") return } # Cache only a matrix whose underlying rule collections and recipient expansion # completed successfully. A later check must never reuse incomplete coverage. if ($METContext) { $METContext.CoverageMatrix = $matrix } # ── Analyse gaps ────────────────────────────────────────────────────────────── $eopRank = @{ Default = 0; Custom = 1; Standard = 2; Strict = 3 } $atpRank = @{ BuiltIn = 0; Custom = 1; Standard = 2; Strict = 3 } $eopGap = @($allMailboxes | Where-Object { $matrix[$_].EopTier -eq 'Default' }) $atpGap = @($allMailboxes | Where-Object { $matrix[$_].AtpTier -eq 'BuiltIn' }) $mismatch = @($allMailboxes | Where-Object { $m = $matrix[$_] $m.EopTier -ne 'Default' -and $m.AtpTier -ne 'BuiltIn' -and $eopRank[$m.EopTier] -gt $atpRank[$m.AtpTier] }) # ── Coverage distribution counts ───────────────────────────────────────────── $eopDist = @{ Strict = 0; Standard = 0; Custom = 0; Default = 0 } $atpDist = @{ Strict = 0; Standard = 0; Custom = 0; BuiltIn = 0 } foreach ($mbx in $allMailboxes) { $eopDist[$matrix[$mbx].EopTier]++ $atpDist[$matrix[$mbx].AtpTier]++ } function Format-Pct { param([int]$n, [int]$of) if ($of -eq 0) { return '0%' } "$([int][math]::Round($n / $of * 100))%" } function Format-TierSummary { param([hashtable]$Dist, [int]$Total, [string[]]$Keys) ($Keys | Where-Object { $Dist[$_] -gt 0 } | ForEach-Object { "$_`: $($Dist[$_]) ($(Format-Pct $Dist[$_] $Total))" }) -join ' | ' } function Format-Sample { param([string[]]$Lines, [int]$Max = 5) if ($Lines.Count -le $Max) { return $Lines -join "`n " } "$($Lines[0..($Max-1)] -join "`n ")`n … (+$($Lines.Count - $Max) more)" } $checker = 'https://microsoft.github.io/CSS-Exchange/M365/MDO/MDOThreatPolicyChecker/' $drillDown = "To confirm the effective policy for specific users, use MDOThreatPolicyChecker: $checker" # ── EOP gap: Default-policy-only mailboxes ──────────────────────────────────── if ($eopGap.Count -gt 0) { $pct = Format-Pct $eopGap.Count $total $level = if ($eopGap.Count -gt [int]($total * 0.10)) { 'Fail' } else { 'Warning' } $sampleLines = @($eopGap | ForEach-Object { $m = $matrix[$_] "$_`: ATP=$($m.AtpTier) via '$($m.AtpPolicy)'" }) New-METCheckResult -CheckId 'MET-MDO008' -Category MDO ` -Name 'Preset Policy Coverage - EOP Gap' ` -Result $level -Severity High ` -AffectedObject "$($eopGap.Count) of $total mailboxes" ` -Finding "$($eopGap.Count) mailbox(es) ($pct) are covered only by the EOP Default policy - no Standard/Strict preset or custom anti-spam/anti-malware rule matches them.`n $(Format-Sample $sampleLines)" ` -Recommendation "1. Open https://security.microsoft.com > Email & collaboration > Policies & rules > Preset security policies.`n2. Edit the Standard or Strict preset and add these mailboxes (or their groups/domains) to the EOP included recipients list.`n3. Ensure the EOP and MDO preset rules share the same conditions so protection is consistent across both stacks.`n4. $drillDown" ` -ReferenceUrl 'https://aka.ms/mdo-presetpolicies' } # ── ATP gap: Built-in-only mailboxes ───────────────────────────────────────── if ($atpGap.Count -gt 0) { $pct = Format-Pct $atpGap.Count $total $level = if ($atpGap.Count -gt [int]($total * 0.10)) { 'Fail' } else { 'Warning' } $sampleLines = @($atpGap | ForEach-Object { $m = $matrix[$_] "$_`: EOP=$($m.EopTier) via '$($m.EopPolicy)'" }) New-METCheckResult -CheckId 'MET-MDO008' -Category MDO ` -Name 'Preset Policy Coverage - MDO Gap' ` -Result $level -Severity High ` -AffectedObject "$($atpGap.Count) of $total mailboxes" ` -Finding "$($atpGap.Count) mailbox(es) ($pct) have no explicit Safe Links, Safe Attachments, or Anti-Phish policy - they receive only the MDO Built-in Protection baseline.`n $(Format-Sample $sampleLines)" ` -Recommendation "1. Open https://security.microsoft.com > Email & collaboration > Policies & rules > Preset security policies.`n2. Edit the Standard or Strict MDO preset rule and ensure these mailboxes are included.`n3. Verify the MDO preset conditions match the EOP preset conditions - they are configured separately and can diverge.`n4. $drillDown" ` -ReferenceUrl 'https://aka.ms/mdo-presetpolicies' } # ── EOP/ATP mismatch: preset conditions diverged ───────────────────────────── if ($mismatch.Count -gt 0) { $sampleLines = @($mismatch | ForEach-Object { $m = $matrix[$_] "$_`: EOP=$($m.EopTier) via '$($m.EopPolicy)' / ATP=$($m.AtpTier) via '$($m.AtpPolicy)'" }) New-METCheckResult -CheckId 'MET-MDO008' -Category MDO ` -Name 'Preset Policy Coverage - EOP/MDO Mismatch' ` -Result Warning -Severity Medium ` -AffectedObject "$($mismatch.Count) of $total mailboxes" ` -Finding "$($mismatch.Count) mailbox(es) have a higher EOP protection tier than their MDO protection tier - the EOP and MDO preset policy conditions have diverged.`n $(Format-Sample $sampleLines)" ` -Recommendation "1. Open https://security.microsoft.com > Email & collaboration > Policies & rules > Preset security policies.`n2. Compare the 'Apply to' conditions of the EOP rule and MDO rule for the same tier.`n3. Align the SentTo, MemberOf, and RecipientDomainIs conditions so both rules cover the same recipients.`n4. $drillDown" ` -ReferenceUrl 'https://aka.ms/mdo-presetpolicies' } # ── Contradiction detection ─────────────────────────────────────────────────── # Checks all active policy rules for users who appear in both include and exception # conditions. Exception conditions always win in Exchange Online, so such users # silently fall through to a lower-priority policy even though they seem covered. if ($allContradictions.Count -gt 0) { $byRule = $allContradictions | Group-Object RuleName $findingLines = $byRule | ForEach-Object { $entries = @($_.Group) $ptype = $entries[0].PolicyType $priority = $entries[0].Priority $addrLines = $entries | ForEach-Object { "$($_.Address)`: included via $($_.IncludeReason); exception via $($_.ExcludeReason)" } "$ptype rule '$($_.Name)' (Priority $priority) - $($entries.Count) user(s) included but overridden by an exception:`n$($addrLines -join "`n")" } New-METCheckResult -CheckId 'MET-MDO008' -Category MDO ` -Name 'Preset Policy Coverage - Condition Contradictions' ` -Result Warning -Severity Medium ` -AffectedObject "$($byRule.Count) rule(s) with include/exception conflicts" ` -Finding ($findingLines -join "`n`n") ` -Recommendation "For each rule listed, verify whether the exception was intentional. If a user must be excluded from a policy, confirm they are explicitly covered by a higher-priority policy so they do not fall to a weaker default.`n$drillDown" ` -ReferenceUrl 'https://aka.ms/mdo-presetpolicies' } # ── No gaps, no mismatch, no contradictions: single Pass result ─────────────── if ($eopGap.Count -eq 0 -and $atpGap.Count -eq 0 -and $mismatch.Count -eq 0 -and $allContradictions.Count -eq 0) { $eopSummary = Format-TierSummary -Dist $eopDist -Total $total -Keys Strict, Standard, Custom, Default $atpSummary = Format-TierSummary -Dist $atpDist -Total $total -Keys Strict, Standard, Custom, BuiltIn New-METCheckResult -CheckId 'MET-MDO008' -Category MDO -Name 'Preset Policy Coverage' ` -Result Pass -Severity High -AffectedObject "Tenant ($total mailboxes)" ` -Finding "All $total mailboxes have consistent EOP and MDO policy coverage with no condition contradictions detected.`nEOP: $eopSummary`nMDO: $atpSummary" ` -ReferenceUrl 'https://aka.ms/mdo-presetpolicies' } |