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'
}