Checks/Teams/MET-Teams004-ZAPForTeams.ps1

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'METCheckInfo',
    Justification = 'Check metadata. Read from the AST by Get-METCheck and never executed.')]
param()

$METCheckInfo = @{
    Name           = 'ZAP for Teams'
    Severity       = 'High'
    Description    = 'Checks ZapEnabled on the Teams protection policy and verifies malware and high-confidence phish quarantine tags are set to AdminOnlyAccessPolicy.'
    RequiresModule = @('ExchangeOnlineManagement')
}

$ruleRetrievalError = $null

try {
    $teamsPolicy = Get-TeamsProtectionPolicy -ErrorAction Stop
}
catch {
    New-METCheckResult -CheckId 'MET-Teams004' -Category Teams -Name 'ZAP for Teams' `
        -Result Fail -Severity High -AffectedObject 'Teams Protection Policy' `
        -Finding 'Unable to retrieve Teams protection policy' `
        -Recommendation 'Ensure the account has Security Reader or higher permissions and that Defender for Office 365 Plan 1 or Plan 2 is licensed.' `
        -ReferenceUrl 'https://aka.ms/mdo-teams-zap' -ErrorMessage $_.ToString()
    return
}

if (-not $teamsPolicy) {
    New-METCheckResult -CheckId 'MET-Teams004' -Category Teams -Name 'ZAP for Teams' `
        -Result Fail -Severity High -AffectedObject 'Teams Protection Policy' `
        -Finding 'No Teams protection policy found' `
        -Recommendation 'Configure the Teams protection policy in the Microsoft Defender portal at security.microsoft.com/securitysettings/teamsProtectionPolicy.' `
        -ReferenceUrl 'https://aka.ms/mdo-teams-zap'
    return
}

function Test-QuarantineTagPermission {
    param([string]$TagName, [string]$Label)

    if (-not $TagName) {
        return [PSCustomObject]@{ Severity = 'Fail'; Message = "No quarantine policy is assigned for $Label - the tenant default may allow users to self-release" }
    }

    if ($TagName -eq 'AdminOnlyAccessPolicy') {
        return $null
    }

    try {
        $policy = Get-QuarantinePolicy -Identity $TagName -ErrorAction Stop
    }
    catch {
        return [PSCustomObject]@{ Severity = 'Fail'; Message = "Unable to retrieve quarantine policy '$TagName' for $Label - cannot verify user release permissions" }
    }

    # Get-QuarantinePolicy returns EndUserQuarantinePermissions as a formatted string, so
    # $policy.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 $policy

    if ($null -eq $permissions -or $null -eq $permissions.PermissionToRelease) {
        return [PSCustomObject]@{
            Severity = 'Warning'
            Message  = "$Label quarantine policy '$TagName' whose EndUserQuarantinePermissions.PermissionToRelease was not returned by Get-QuarantinePolicy, so whether users can self-release quarantined messages via this tag was not established"
        }
    }

    if ($permissions.PermissionToRelease) {
        return [PSCustomObject]@{ Severity = 'Fail'; Message = "$Label quarantine policy '$TagName' allows users to self-release quarantined messages - set PermissionToRelease to false or use AdminOnlyAccessPolicy" }
    }

    return $null
}

$issues = [System.Collections.Generic.List[string]]::new()
$permissionWarnings = [System.Collections.Generic.List[string]]::new()

if (-not $teamsPolicy.ZapEnabled) {
    $issues.Add('Zero-hour auto purge (ZAP) for Teams is disabled - malicious messages already delivered to Teams chats are not retroactively removed')
}

$malwareResult = Test-QuarantineTagPermission -TagName $teamsPolicy.MalwareQuarantineTag -Label 'Malware'
if ($malwareResult) {
    if ($malwareResult.Severity -eq 'Fail') { $issues.Add($malwareResult.Message) } else { $permissionWarnings.Add($malwareResult.Message) }
}

$hcpResult = Test-QuarantineTagPermission -TagName $teamsPolicy.HighConfidencePhishQuarantineTag -Label 'High-confidence phish'
if ($hcpResult) {
    if ($hcpResult.Severity -eq 'Fail') { $issues.Add($hcpResult.Message) } else { $permissionWarnings.Add($hcpResult.Message) }
}

$warningIssues = [System.Collections.Generic.List[string]]::new()

try {
    $protectionRules = @(Get-TeamsProtectionPolicyRule -ErrorAction Stop)
    $rulesWithExceptions = @($protectionRules | Where-Object {
        $_.State -eq 'Enabled' -and (
            @($_.ExceptIfSentTo | Where-Object { $_ }).Count -gt 0 -or
            @($_.ExceptIfSentToMemberOf | Where-Object { $_ }).Count -gt 0 -or
            @($_.ExceptIfRecipientDomainIs | Where-Object { $_ }).Count -gt 0
        )
    })
    foreach ($rule in $rulesWithExceptions) {
        $exceptedRecipients = @($rule.ExceptIfSentTo | Where-Object { $_ }).Count
        $exceptedGroups = @($rule.ExceptIfSentToMemberOf | Where-Object { $_ }).Count
        $exceptedDomains = @($rule.ExceptIfRecipientDomainIs | Where-Object { $_ }).Count
        $exceptionParts = [System.Collections.Generic.List[string]]::new()
        if ($exceptedRecipients -gt 0) { $exceptionParts.Add("$exceptedRecipients recipient(s)") }
        if ($exceptedGroups -gt 0) { $exceptionParts.Add("$exceptedGroups group(s)") }
        if ($exceptedDomains -gt 0) { $exceptionParts.Add("$exceptedDomains domain(s)") }
        $warningIssues.Add("Teams protection rule '$($rule.Name)' excepts $($exceptionParts -join ', ') from Teams ZAP protection - excluded recipients do not receive retroactive removal of malicious messages")
    }
}
catch {
    # The exception data is what narrows effective ZAP coverage. Losing it silently and
    # then reporting Pass would claim coverage that was never verified.
    Write-Verbose "Could not retrieve Teams protection policy rules - skipping rule exception check: $_"
    $ruleRetrievalError = $_.ToString()
}

if ($issues.Count -gt 0) {
    # A confirmed failure on one tag must not swallow an unconfirmed permission
    # on the other, or a rule-retrieval failure - 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 = ($issues + $warningIssues) -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 ($ruleRetrievalError) {
        $finding += " Additionally, the Teams protection policy rules could not be retrieved, so any recipient exceptions narrowing ZAP coverage are unverified rather than a confirmed failure: $ruleRetrievalError"
        $failErrorParts.Add($ruleRetrievalError)
    }
    $failErrorMessage = if ($failErrorParts.Count -gt 0) { $failErrorParts -join "`n" } else { $null }
    New-METCheckResult -CheckId 'MET-Teams004' -Category Teams -Name 'ZAP for Teams' `
        -Result Fail -Severity High -AffectedObject 'Teams Protection Policy' `
        -Finding $finding `
        -Recommendation 'Enable ZAP for Teams: Set-TeamsProtectionPolicy -ZapEnabled $true. Ensure MalwareQuarantineTag and HighConfidencePhishQuarantineTag use AdminOnlyAccessPolicy or a custom policy with PermissionToRelease disabled.' `
        -ReferenceUrl 'https://aka.ms/mdo-teams-zap' `
        -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 ($warningIssues.Count -gt 0) {
        $finding += ' ' + ($warningIssues -join '; ')
    }
    $warningErrorParts = [System.Collections.Generic.List[string]]::new()
    $warningErrorParts.Add("Get-QuarantinePolicy did not return EndUserQuarantinePermissions.PermissionToRelease for: $($permissionWarnings -join '; ').")
    if ($ruleRetrievalError) {
        $finding += " Additionally, the Teams protection policy rules could not be retrieved, so any recipient exceptions narrowing ZAP coverage are unverified: $ruleRetrievalError"
        $warningErrorParts.Add($ruleRetrievalError)
    }
    New-METCheckResult -CheckId 'MET-Teams004' -Category Teams -Name 'ZAP for Teams' `
        -Result Warning -Severity High -AffectedObject 'Teams Protection Policy' `
        -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-teams-zap' `
        -ErrorMessage ($warningErrorParts -join "`n")
}
elseif ($warningIssues.Count -gt 0) {
    New-METCheckResult -CheckId 'MET-Teams004' -Category Teams -Name 'ZAP for Teams' `
        -Result Warning -Severity High -AffectedObject 'Teams Protection Policy' `
        -Finding ($warningIssues -join '; ') `
        -Recommendation 'Review Teams protection policy rule exceptions (ExceptIfSentTo, ExceptIfSentToMemberOf, ExceptIfRecipientDomainIs) and remove any that are not intentional - excluded recipients do not benefit from ZAP for Teams.' `
        -ReferenceUrl 'https://aka.ms/mdo-teams-zap'
}
elseif ($ruleRetrievalError) {
    New-METCheckResult -CheckId 'MET-Teams004' -Category Teams -Name 'ZAP for Teams' `
        -Result Warning -Severity High -AffectedObject 'Teams Protection Policy' `
        -Finding 'ZAP for Teams is enabled and quarantine policies do not allow user self-release, but the Teams protection policy rules could not be read, so any recipient exceptions narrowing ZAP coverage are unverified.' `
        -Recommendation 'Rerun with a connected MicrosoftTeams session and Security Reader permissions, or review Get-TeamsProtectionPolicyRule exceptions manually.' `
        -ReferenceUrl 'https://aka.ms/mdo-teams-zap' -ErrorMessage $ruleRetrievalError
}
else {
    New-METCheckResult -CheckId 'MET-Teams004' -Category Teams -Name 'ZAP for Teams' `
        -Result Pass -Severity High -AffectedObject 'Teams Protection Policy' `
        -Finding 'ZAP for Teams is enabled and quarantine policies do not allow user self-release' `
        -ReferenceUrl 'https://aka.ms/mdo-teams-zap'
}