Private/Find-METRuleContradictions.ps1
|
function Find-METRuleContradictions { # Detects mailboxes that appear in both include and exception conditions of the # same policy rule. In Exchange Online, exception conditions always win - such # users receive no protection from the rule even though they appear to be # explicitly included. # # Only flags rules that have at least one explicit include condition (SentTo, # SentToMemberOf, or RecipientDomainIs). Catch-all rules with no include # conditions rely on exceptions for intentional scoping and are skipped. [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $Rules, [Parameter(Mandatory)] [string[]] $AllMailboxes, [Parameter(Mandatory)] [hashtable] $GroupCache, [Parameter(Mandatory)] [string] $PolicyType, [System.Collections.Generic.List[string]] $RetrievalErrors ) $results = [System.Collections.Generic.List[PSCustomObject]]::new() $domainMap = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new( [System.StringComparer]::OrdinalIgnoreCase) foreach ($mbx in $AllMailboxes) { $d = ($mbx -split '@', 2)[1] if (-not $domainMap.ContainsKey($d)) { $domainMap[$d] = [System.Collections.Generic.List[string]]::new() } $domainMap[$d].Add($mbx) } foreach ($rule in $Rules) { if ($rule.State -ne 'Enabled') { continue } # ── Build include map: address → condition description ───────────── $includeMap = [System.Collections.Generic.Dictionary[string,string]]::new( [System.StringComparer]::OrdinalIgnoreCase) foreach ($addr in @($rule.SentTo)) { if ($addr -and -not $includeMap.ContainsKey($addr)) { $includeMap[$addr] = "directly listed in SentTo" } } foreach ($grp in @($rule.SentToMemberOf)) { if (-not $grp) { continue } foreach ($m in @(Expand-METGroupMembership -Identity $grp -Cache $GroupCache -RetrievalErrors $RetrievalErrors)) { if (-not $includeMap.ContainsKey($m)) { $includeMap[$m] = "member of included group '$grp'" } } } if ($rule.RecipientDomainIs) { foreach ($domain in @($rule.RecipientDomainIs)) { if ($domainMap.ContainsKey($domain)) { foreach ($mbx in $domainMap[$domain]) { if (-not $includeMap.ContainsKey($mbx)) { $includeMap[$mbx] = "matched by included domain '$domain'" } } } } } # No explicit include conditions = catch-all; exceptions are intentional if ($includeMap.Count -eq 0) { continue } # ── Build exclude map: address → condition description ───────────── $excludeMap = [System.Collections.Generic.Dictionary[string,string]]::new( [System.StringComparer]::OrdinalIgnoreCase) foreach ($addr in @($rule.ExceptIfSentTo)) { if ($addr -and -not $excludeMap.ContainsKey($addr)) { $excludeMap[$addr] = "directly listed in ExceptIfSentTo" } } foreach ($grp in @($rule.ExceptIfSentToMemberOf)) { if (-not $grp) { continue } foreach ($m in @(Expand-METGroupMembership -Identity $grp -Cache $GroupCache -RetrievalErrors $RetrievalErrors)) { if (-not $excludeMap.ContainsKey($m)) { $excludeMap[$m] = "member of excluded group '$grp'" } } } if ($rule.ExceptIfRecipientDomainIs) { foreach ($domain in @($rule.ExceptIfRecipientDomainIs)) { if ($domainMap.ContainsKey($domain)) { foreach ($mbx in $domainMap[$domain]) { if (-not $excludeMap.ContainsKey($mbx)) { $excludeMap[$mbx] = "matched by excluded domain '$domain'" } } } } } if ($excludeMap.Count -eq 0) { continue } # ── Intersection = contradictions (exception wins, user gets no cover) ─ foreach ($addr in $includeMap.Keys) { if ($excludeMap.ContainsKey($addr)) { $results.Add([PSCustomObject]@{ PolicyType = $PolicyType RuleName = $rule.Name Priority = $rule.Priority Address = $addr IncludeReason = $includeMap[$addr] ExcludeReason = $excludeMap[$addr] }) } } } return $results.ToArray() } |