Checks/EXO/MET-EXO009-QuarantinePolicyVerdictAlignment.ps1
|
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'METCheckInfo', Justification = 'Check metadata. Read from the AST by Get-METCheck and never executed.')] param() $METCheckInfo = @{ Name = 'Quarantine Policy Verdict Alignment' Severity = 'High' Description = 'Cross-references every filter policy with its assigned quarantine tag and verifies PermissionToRelease is false for Malware and High-Confidence Phish verdicts.' RequiresModule = @('ExchangeOnlineManagement') } # Verifies that quarantine policies assigned to Malware and High-Confidence Phish verdicts # prevent user self-release, the only two verdicts with a restrictive floor per Microsoft's # own Standard/Strict preset matrix. Every # other verdict (Phish, Mailbox Intelligence Phish, Spoof, both impersonation types, spam # tiers, Bulk) uses a full-access quarantine policy even under Strict, so they are not # evaluated. Assignments sourced from preset-generated policies (Standard/Strict Preset # Security Policy*) are skipped entirely - their tags are guaranteed correct by Microsoft and # not admin-actionable. Custom policies are still checked against the actual # PermissionToRelease bit, not the tag's name. $policyPermissions = @{} try { Get-QuarantinePolicy -ErrorAction Stop | ForEach-Object { $policyPermissions[$_.Name] = $_ } } catch { New-METCheckResult -CheckId 'MET-EXO009' -Category EXO -Name 'Quarantine Policy Verdict Alignment' ` -Result Fail -Severity High -AffectedObject 'Quarantine Policies' ` -Finding 'Unable to retrieve quarantine policies' ` -Recommendation 'Ensure the account has Security Reader or higher permissions.' ` -ReferenceUrl 'https://aka.ms/mdo-quarantinepolicies' -ErrorMessage $_.ToString() return } # Only these two verdicts have a restrictive floor (PermissionToRelease must be $false) in # Microsoft's own Default/Standard/Strict matrix. Every other verdict has no floor to enforce. $restrictedVerdicts = @( 'Malware' 'High-Confidence Phish' ) $assignments = [System.Collections.Generic.List[hashtable]]::new() $retrievalErrors = [System.Collections.Generic.List[string]]::new() # Anti-spam (EOP + MDO) try { foreach ($p in (Get-HostedContentFilterPolicy -ErrorAction Stop)) { if (Test-METIsPresetSecurityPolicyName -Name $p.Name) { continue } $verdictMap = [ordered]@{ HighConfidencePhishQuarantineTag = 'High-Confidence Phish' PhishQuarantineTag = 'Phish' HighConfidenceSpamQuarantineTag = 'High-Confidence Spam' SpamQuarantineTag = 'Spam' BulkQuarantineTag = 'Bulk' } foreach ($entry in $verdictMap.GetEnumerator()) { $tag = $p.($entry.Key) if ($tag) { $null = $assignments.Add(@{ Source = $p.Name; Verdict = $entry.Value; Tag = $tag }) } } } } catch { $retrievalErrors.Add("Anti-spam policies: $($_.ToString())") } # Anti-malware try { foreach ($p in (Get-MalwareFilterPolicy -ErrorAction Stop)) { if (Test-METIsPresetSecurityPolicyName -Name $p.Name) { continue } if ($p.QuarantineTag) { $null = $assignments.Add(@{ Source = $p.Name; Verdict = 'Malware'; Tag = $p.QuarantineTag }) } } } catch { $retrievalErrors.Add("Anti-malware policies: $($_.ToString())") } # Anti-phish impersonation verdicts (MDO Plan 1+) try { foreach ($p in (Get-AntiPhishPolicy -ErrorAction Stop)) { if (Test-METIsPresetSecurityPolicyName -Name $p.Name) { continue } $verdictMap = [ordered]@{ TargetedUserQuarantineTag = 'Impersonated User' TargetedDomainQuarantineTag = 'Impersonated Domain' MailboxIntelligenceQuarantineTag = 'Mailbox Intelligence Phish' SpoofQuarantineTag = 'Spoof' } foreach ($entry in $verdictMap.GetEnumerator()) { $tag = $p.($entry.Key) if ($tag) { $null = $assignments.Add(@{ Source = $p.Name; Verdict = $entry.Value; Tag = $tag }) } } } } catch { Write-Verbose "MET-EXO009: Get-AntiPhishPolicy unavailable - may not be MDO licensed" } # Safe Attachments (MDO Plan 1+) - only Block action results in quarantine try { foreach ($p in (Get-SafeAttachmentPolicy -ErrorAction Stop | Where-Object { $_.Action -eq 'Block' })) { if (Test-METIsPresetSecurityPolicyName -Name $p.Name) { continue } if ($p.QuarantineTag) { $null = $assignments.Add(@{ Source = $p.Name; Verdict = 'Malware'; Tag = $p.QuarantineTag }) } } } catch { Write-Verbose "MET-EXO009: Get-SafeAttachmentPolicy unavailable - may not be MDO licensed" $null = $retrievalErrors.Add("Unable to retrieve Safe Attachments policies. $($_.ToString())") } if ($retrievalErrors.Count -gt 0 -and $assignments.Count -eq 0) { New-METCheckResult -CheckId 'MET-EXO009' -Category EXO -Name 'Quarantine Policy Verdict Alignment' ` -Result Fail -Severity High -AffectedObject 'Filter Policies' ` -Finding "Unable to retrieve filter policies needed for verdict alignment check: $($retrievalErrors -join '; ')" ` -Recommendation 'Ensure the account has Security Reader or higher permissions.' ` -ReferenceUrl 'https://aka.ms/mdo-quarantinepolicies' return } $fails = [System.Collections.Generic.List[string]]::new() $permissionWarnings = [System.Collections.Generic.List[string]]::new() foreach ($a in $assignments) { if ($a.Verdict -notin $restrictedVerdicts) { continue } $qp = $policyPermissions[$a.Tag] if (-not $qp) { $null = $fails.Add("Policy '$($a.Source)': $($a.Verdict) verdict references quarantine tag '$($a.Tag)' which does not exist") continue } # Get-QuarantinePolicy returns EndUserQuarantinePermissions as a formatted string, so # $qp.EndUserQuarantinePermissions.PermissionToRelease is always $null regardless of # the real value. Get-METEndUserQuarantinePermission parses the string; it returns # $null (and .PermissionToRelease returns $null) when nothing could be read. Distinguish # "not returned" from "returned and false" before branching, so an unobserved permission # cannot be reported as a prevented one. $permissions = Get-METEndUserQuarantinePermission -QuarantinePolicy $qp if ($null -eq $permissions -or $null -eq $permissions.PermissionToRelease) { $null = $permissionWarnings.Add("Policy '$($a.Source)': $($a.Verdict) verdict uses quarantine tag '$($a.Tag)' whose EndUserQuarantinePermissions.PermissionToRelease was not returned by Get-QuarantinePolicy, so whether users can self-release quarantined messages via this tag was not established") continue } if ($permissions.PermissionToRelease) { $null = $fails.Add("Policy '$($a.Source)': $($a.Verdict) verdict uses '$($a.Tag)' which allows users to self-release quarantined messages") } } if ($fails.Count -gt 0) { # A confirmed failure on one assignment must not swallow an unconfirmed # permission on a different one, or a retrieval failure on a different policy # family - the reader still needs to know that second signal was never # established, distinct from the confirmed failure so it is not mistaken for one. $finding = $fails -join '; ' $failErrorParts = [System.Collections.Generic.List[string]]::new() if ($permissionWarnings.Count -gt 0) { $finding += ' Additionally, the following have an unconfirmed release permission rather than a confirmed failure: ' + ($permissionWarnings -join '; ') + '.' $failErrorParts.Add("Get-QuarantinePolicy did not return EndUserQuarantinePermissions.PermissionToRelease for: $($permissionWarnings -join '; ').") } if ($retrievalErrors.Count -gt 0) { $finding += ' Additionally, one or more filter policy types could not be retrieved, so any verdicts they carry are unverified rather than a confirmed failure: ' + ($retrievalErrors -join '; ') + '.' $failErrorParts.Add($retrievalErrors -join '; ') } $failErrorMessage = if ($failErrorParts.Count -gt 0) { $failErrorParts -join "`n" } else { $null } New-METCheckResult -CheckId 'MET-EXO009' -Category EXO -Name 'Quarantine Policy Verdict Alignment' ` -Result Fail -Severity High -AffectedObject 'Quarantine Tag Assignments' ` -Finding $finding ` -Recommendation 'For Malware and High-Confidence Phish verdicts, assign a quarantine policy with PermissionToRelease disabled. Use AdminOnlyAccessPolicy or a custom policy with equivalent restrictions.' ` -ReferenceUrl 'https://aka.ms/mdo-quarantinepolicies' ` -ErrorMessage $failErrorMessage } elseif ($permissionWarnings.Count -gt 0) { $finding = ($permissionWarnings -join '; ') + '. An unconfirmed state is reported as unassessed rather than a pass, because nothing here distinguishes a quarantine policy that prevents self-release from one that allows it.' if ($retrievalErrors.Count -gt 0) { $finding += " Additionally, one or more filter policy types could not be retrieved: $($retrievalErrors -join '; ')." } New-METCheckResult -CheckId 'MET-EXO009' -Category EXO -Name 'Quarantine Policy Verdict Alignment' ` -Result Warning -Severity High -AffectedObject 'Quarantine Tag Assignments' ` -Finding $finding ` -Recommendation 'Confirm the setting directly with: Get-QuarantinePolicy -Identity <tag name> | Select-Object -ExpandProperty EndUserQuarantinePermissions. An absent property usually means an ExchangeOnlineManagement version that does not expose it - update the module and rerun the assessment.' ` -ReferenceUrl 'https://aka.ms/mdo-quarantinepolicies' ` -ErrorMessage "Get-QuarantinePolicy did not return EndUserQuarantinePermissions.PermissionToRelease for: $($permissionWarnings -join '; ')." } elseif ($retrievalErrors.Count -gt 0) { # A partial retrieval failure must not score as a clean Pass. Some policy families # were enumerated and some were not, so the verdicts carried by the missing ones # went unassessed - the exact exposure this check exists to find. New-METCheckResult -CheckId 'MET-EXO009' -Category EXO -Name 'Quarantine Policy Verdict Alignment' ` -Result Warning -Severity High -AffectedObject 'Quarantine Tag Assignments' ` -Finding "No self-release exposure was found in the filter policies that could be read, but one or more policy types could not be retrieved, so verdict alignment is only partially verified: $($retrievalErrors -join '; ')" ` -Recommendation 'Ensure the account has Security Reader or higher permissions across all filter policy types, then rerun the assessment.' ` -ReferenceUrl 'https://aka.ms/mdo-quarantinepolicies' ` -ErrorMessage ($retrievalErrors -join "`n") } else { New-METCheckResult -CheckId 'MET-EXO009' -Category EXO -Name 'Quarantine Policy Verdict Alignment' ` -Result Pass -Severity High -AffectedObject 'Quarantine Tag Assignments' ` -Finding 'Malware and High-Confidence Phish verdicts use quarantine policies that prevent user self-release' ` -ReferenceUrl 'https://aka.ms/mdo-quarantinepolicies' } |