tests/Test-Assessment.41033.ps1

<#
.SYNOPSIS
    Anti-phishing policies in Microsoft Defender for Office 365 are configured with impersonation and spoof protection.
 
.NOTES
    Test ID: 41033
    Workshop Task: SECOPS-033
    Pillar: SecOps
    Category: Email and collaboration security
    Required Module: ExchangeOnlineManagement
    Required permissions: Get-AntiPhishPolicy, Get-AntiPhishRule
#>


function Test-Assessment-41033 {
    [ZtTest(
        Category           = 'Email and collaboration security',
        CompatibleLicense  = ('ATP_ENTERPRISE'),
        ImplementationCost = 'Low',
        Pillar             = 'SecOps',
        RiskLevel          = 'High',
        Service            = ('ExchangeOnline'),
        SfiPillar          = 'Protect tenants and isolate production systems',
        TenantType         = ('Workforce'),
        TestId             = 41033,
        Title              = 'Anti-phishing policies in Microsoft Defender for Office 365 are configured with impersonation and spoof protection',
        UserImpact         = 'Low'
    )]
    [CmdletBinding()]
    param()

    #region Data Collection
    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose

    $activity = 'Checking anti-phishing policy configuration'

    Write-ZtProgress -Activity $activity -Status 'Querying anti-phishing policies'
    try {
        $policies = @(Get-AntiPhishPolicy -ErrorAction Stop | Select-Object `
            Identity, IsBuiltInProtection, IsDefault, Enabled,
            EnableSpoofIntelligence, AuthenticationFailAction,
            EnableUnauthenticatedSender, EnableViaTag,
            EnableMailboxIntelligence, EnableMailboxIntelligenceProtection,
            MailboxIntelligenceProtectionAction,
            EnableTargetedUserProtection, TargetedUsersToProtect, TargetedUserProtectionAction,
            EnableTargetedDomainsProtection, TargetedDomainsToProtect, TargetedDomainProtectionAction,
            EnableOrganizationDomainsProtection, PhishThresholdLevel,
            HonorDmarcPolicy, DmarcQuarantineAction, DmarcRejectAction,
            EnableFirstContactSafetyTips, EnableSimilarUsersSafetyTips,
            EnableSimilarDomainsSafetyTips, EnableUnusualCharactersSafetyTips,
            TargetedUserQuarantineTag, TargetedDomainQuarantineTag,
            MailboxIntelligenceQuarantineTag, SpoofQuarantineTag)
    }
    catch {
        Write-PSFMessage "Failed to retrieve anti-phishing policies: $_" -Tag Test -Level Warning
        $params = @{
            TestId       = '41033'
            Title        = 'Anti-phishing policies in Microsoft Defender for Office 365 are configured with impersonation and spoof protection'
            Status       = $false
            Result       = "⚠️ Anti-phishing policies could not be retrieved. Verify that the account has the required permissions and that the Exchange Online session is active. Error: $_"
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    Write-ZtProgress -Activity $activity -Status 'Querying anti-phishing rules'
    try {
        $rules = @(Get-AntiPhishRule -ErrorAction Stop | Select-Object `
            Name, AntiPhishPolicy, Priority, State, RecipientDomainIs, SentTo, SentToMemberOf)
    }
    catch {
        Write-PSFMessage "Failed to retrieve anti-phishing rules: $_" -Tag Test -Level Warning
        $params = @{
            TestId       = '41033'
            Title        = 'Anti-phishing policies in Microsoft Defender for Office 365 are configured with impersonation and spoof protection'
            Status       = $false
            Result       = "⚠️ Anti-phishing rules could not be retrieved. Verify that the account has the required permissions and that the Exchange Online session is active. Error: $_"
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    Write-ZtProgress -Activity $activity -Status 'Querying quarantine policies'
    # $quarantineTagMap: tag name → $true when recipients can self-release without notification
    # Falls back to $null on error; assessment then uses built-in tag name list as a fallback
    $quarantineTagMap = $null
    try {
        $quarantineTagMap = @{}
        Get-QuarantinePolicy -ErrorAction Stop | ForEach-Object {
            $canSelfRelease = $_.EndUserQuarantinePermissions.PermissionToRelease -eq $true
            $quarantineTagMap[$_.Name] = $canSelfRelease -and $_.ESNEnabled -ne $true
        }
    }
    catch {
        Write-PSFMessage "Get-QuarantinePolicy failed; self-release check will fall back to built-in tag names. Error: $_" -Tag Test -Level Warning
        $quarantineTagMap = $null
    }
    #endregion Data Collection

    #region Assessment Logic

    # The default policy always exists; zero rows signals a connectivity issue
    if ($policies.Count -eq 0) {
        $params = @{
            TestId       = '41033'
            Title        = 'Anti-phishing policies in Microsoft Defender for Office 365 are configured with impersonation and spoof protection'
            Status       = $false
            Result       = '⚠️ `Get-AntiPhishPolicy` returned zero rows. The default policy always exists; verify Exchange Online connectivity and re-run.'
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    if (-not ($policies | Where-Object { $_.IsDefault -eq $true })) {
        $params = @{
            TestId       = '41033'
            Title        = 'Anti-phishing policies in Microsoft Defender for Office 365 are configured with impersonation and spoof protection'
            Status       = $false
            Result       = '⚠️ `Get-AntiPhishPolicy` did not return the default policy. Verify Exchange Online connectivity and re-run.'
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    # Known built-in tags that permit self-release without notification; used when policy data is unavailable
    $builtInSelfReleaseTags = @('DefaultFullAccessPolicy')
    $builtInQuarantineTags  = @('AdminOnlyAccessPolicy', 'DefaultFullAccessPolicy', 'DefaultFullAccessWithNotificationPolicy')

    $enabledRules   = @($rules | Where-Object { $_.State -eq 'Enabled' })
    $allPolicyNames = @($policies | Select-Object -ExpandProperty Identity)
    $orphanedRules  = @($enabledRules | Where-Object { $_.AntiPhishPolicy -notin $allPolicyNames })

    # Build policy-name → enabled rule names map
    $policyRuleMap = @{}
    foreach ($rule in $enabledRules) {
        if (-not $policyRuleMap.ContainsKey($rule.AntiPhishPolicy)) {
            $policyRuleMap[$rule.AntiPhishPolicy] = [System.Collections.Generic.List[string]]::new()
        }
        [void]$policyRuleMap[$rule.AntiPhishPolicy].Add($rule.Name)
    }

    # Evaluate each policy, producing canonical reason strings per spec §"Canonical Result reason strings"
    $policyResults = foreach ($policy in $policies) {
        $isBuiltIn = $policy.IsBuiltInProtection -eq $true
        $isDefault = $policy.IsDefault -eq $true
        $reasons   = [System.Collections.Generic.List[string]]::new()
        $unverifiedCustomQuarantineTags = [System.Collections.Generic.List[string]]::new()

        if (-not $isBuiltIn) {
            $userCount   = if ($policy.TargetedUsersToProtect)   { @($policy.TargetedUsersToProtect).Count }   else { 0 }
            $domainCount = if ($policy.TargetedDomainsToProtect) { @($policy.TargetedDomainsToProtect).Count } else { 0 }

            # Impersonation checks — custom policies only; default policy is evaluated against the minimum subset only
            if (-not $isDefault) {
                if ($policy.Enabled -ne $true) {
                    [void]$reasons.Add('Fail (policy disabled)')
                }
                if ($policy.EnableTargetedUserProtection -ne $true -or $userCount -eq 0) {
                    [void]$reasons.Add('Fail (targeted users empty)')
                }
                if ($policy.EnableTargetedDomainsProtection -ne $true -or $domainCount -eq 0) {
                    [void]$reasons.Add('Fail (targeted domains empty)')
                }
                if ($policy.EnableOrganizationDomainsProtection -ne $true) {
                    [void]$reasons.Add('Fail (org domains not protected)')
                }
                if ($policy.EnableTargetedUserProtection -eq $true -and $policy.EnableSimilarUsersSafetyTips -ne $true) {
                    [void]$reasons.Add('Fail (similar users safety tips off)')
                }
                if ($policy.EnableTargetedDomainsProtection -eq $true -and $policy.EnableSimilarDomainsSafetyTips -ne $true) {
                    [void]$reasons.Add('Fail (similar domains safety tips off)')
                }
                if ($policy.EnableUnusualCharactersSafetyTips -ne $true) {
                    [void]$reasons.Add('Fail (unusual characters safety tips off)')
                }
            }

            # Required subset for default and custom policies
            if ($policy.EnableMailboxIntelligence -ne $true -or $policy.EnableMailboxIntelligenceProtection -ne $true) {
                [void]$reasons.Add('Fail (mailbox intelligence off)')
            }
            if ($policy.EnableSpoofIntelligence -ne $true) {
                [void]$reasons.Add('Fail (spoof off)')
            }
            if ($policy.HonorDmarcPolicy -ne $true) {
                [void]$reasons.Add('Fail (dmarc not honored)')
            }
            if (-not $isDefault -and $policy.AuthenticationFailAction -ne 'Quarantine') {
                [void]$reasons.Add('Fail (auth-fail delivered)')
            }
            if (-not $isDefault) {
                if ($policy.EnableUnauthenticatedSender -ne $true) {
                    [void]$reasons.Add('Fail (unauthenticated sender indicator off)')
                }
                if ($policy.EnableViaTag -ne $true) {
                    [void]$reasons.Add('Fail (via tag off)')
                }
                if ($policy.EnableFirstContactSafetyTips -ne $true) {
                    [void]$reasons.Add('Fail (first contact safety tips off)')
                }
                if ($policy.HonorDmarcPolicy -eq $true -and $policy.DmarcQuarantineAction -ne 'Quarantine') {
                    [void]$reasons.Add('Fail (DMARC quarantine action not quarantine)')
                }
                if ($policy.HonorDmarcPolicy -eq $true -and $policy.DmarcRejectAction -ne 'Reject') {
                    [void]$reasons.Add('Fail (DMARC reject action not reject)')
                }
            }

            # Per-verdict action checks — only when the feature is enabled; name the offending property
            foreach ($check in @(
                @{ Enabled = $policy.EnableTargetedUserProtection;        Prop = 'TargetedUserProtectionAction' }
                @{ Enabled = $policy.EnableTargetedDomainsProtection;     Prop = 'TargetedDomainProtectionAction' }
                @{ Enabled = $policy.EnableMailboxIntelligenceProtection; Prop = 'MailboxIntelligenceProtectionAction' }
            )) {
                if ($check.Enabled -eq $true -and $policy.($check.Prop) -ne 'Quarantine') {
                    [void]$reasons.Add("Fail (impersonation action not enforced: $($check.Prop))")
                }
            }

            # Quarantine tag self-release — require PermissionToRelease and no end-user notification.
            # When quarantine policy data is unavailable, custom tags cannot be safely evaluated.
            foreach ($tp in @('TargetedUserQuarantineTag', 'TargetedDomainQuarantineTag', 'MailboxIntelligenceQuarantineTag', 'SpoofQuarantineTag')) {
                $val = $policy.$tp
                if (-not [string]::IsNullOrEmpty($val)) {
                    $allowsSelfRelease = if ($null -ne $quarantineTagMap -and $quarantineTagMap.ContainsKey($val)) {
                        $quarantineTagMap[$val]
                    } else {
                        $val -in $builtInSelfReleaseTags
                    }
                    if ($allowsSelfRelease) {
                        [void]$reasons.Add("Fail (self-release quarantine tag: $tp)")
                    }
                    elseif ($null -eq $quarantineTagMap -and $val -notin $builtInQuarantineTags) {
                        [void]$unverifiedCustomQuarantineTags.Add($tp)
                    }
                }
            }

            if (($reasons | Where-Object { $_ -like 'Fail *' }).Count -eq 0) {
                foreach ($tagProperty in $unverifiedCustomQuarantineTags) {
                    [void]$reasons.Add("Investigate (quarantine policy unavailable for custom tag: $tagProperty)")
                }
            }

            # Investigate: threshold below Microsoft-recommended minimum of 3, only when there are no Fail reasons
            if (($reasons | Where-Object { $_ -like 'Fail *' }).Count -eq 0 -and $policy.PhishThresholdLevel -lt 3) {
                [void]$reasons.Add("Investigate (phish threshold=$($policy.PhishThresholdLevel), below recommended minimum of 3)")
            }
        }

        # Derive row status; multiple Fail reasons merge into a single Fail (reason1; reason2) per spec
        $hasFail        = ($reasons | Where-Object { $_ -like 'Fail *' }).Count -gt 0
        $hasInvestigate = ($reasons | Where-Object { $_ -like 'Investigate *' }).Count -gt 0
        $rowStatus      = if ($hasFail) { 'Fail' } elseif ($hasInvestigate) { 'Investigate' } else { 'Pass' }
        $rowResult      = if ($hasFail) {
            "Fail ($(@($reasons | ForEach-Object { $_ -replace '^Fail \((.+)\)$', '$1' }) -join '; '))"
        } elseif ($hasInvestigate) {
            "Investigate ($(@($reasons | ForEach-Object { $_ -replace '^Investigate \((.+)\)$', '$1' }) -join '; '))"
        } else { 'Pass' }

        # Identity with [default] / [built-in] / [disabled] suffixes
        $suffixes = @()
        if ($isDefault) { $suffixes += '[default]' }
        if ($isBuiltIn) { $suffixes += '[built-in]' }
        if ($policy.Enabled -eq $false) { $suffixes += '[disabled]' }
        $identityDisplay = $policy.Identity + $(if ($suffixes) { " $($suffixes -join ' ')" } else { '' })

        # Scope column: "Applied via rule <Name>" or "Not applied"
        $scopeDisplay = if ($policyRuleMap.ContainsKey($policy.Identity)) {
            'Applied via rule ' + ($policyRuleMap[$policy.Identity] -join ', ')
        } else { 'Not applied' }

        # Impersonation column: Users: N • Domains: N • Org: Y/N • MailboxIntel: Y/N
        $uCount  = if ($policy.TargetedUsersToProtect)   { @($policy.TargetedUsersToProtect).Count }   else { 0 }
        $dCount  = if ($policy.TargetedDomainsToProtect) { @($policy.TargetedDomainsToProtect).Count } else { 0 }
        $orgY    = if ($policy.EnableOrganizationDomainsProtection -eq $true) { 'Y' } else { 'N' }
        $mbxY    = if ($policy.EnableMailboxIntelligence -eq $true -and $policy.EnableMailboxIntelligenceProtection -eq $true) { 'Y' } else { 'N' }
        $impersonDisplay = if ($isBuiltIn) { '—' } else {
            "Users: $uCount • Domains: $dCount • Org: $orgY • MailboxIntel: $mbxY"
        }

        # Authentication column: Spoof: Y/N • DMARC: Y/N • AuthFail: <action>
        $spoofY      = if ($policy.EnableSpoofIntelligence -eq $true) { 'Y' } else { 'N' }
        $dmarcY      = if ($policy.HonorDmarcPolicy -eq $true) { 'Y' } else { 'N' }
        $authDisplay = "Spoof: $spoofY • DMARC: $dmarcY • AuthFail: $($policy.AuthenticationFailAction)"

        # Phish threshold with label
        $threshDisplay = switch ([int]$policy.PhishThresholdLevel) {
            1 { '1 Standard' }
            2 { '2 Aggressive' }
            3 { '3 More aggressive' }
            4 { '4 Most aggressive' }
            default { "$($policy.PhishThresholdLevel)" }
        }

        [PSCustomObject]@{
            IdentityDisplay = $identityDisplay
            IsDefault       = $isDefault
            IsBuiltIn       = $isBuiltIn
            Scope           = $scopeDisplay
            Impersonation   = $impersonDisplay
            Authentication  = $authDisplay
            PhishThreshold  = $threshDisplay
            Result          = $rowResult
            RowStatus       = $rowStatus
        }
    }
    $policyResults = @($policyResults)

    # Identify in-scope policy groups
    $defaultResult   = $policyResults | Where-Object { $_.IsDefault } | Select-Object -First 1
    $ruleRefPolicies = @($policyResults | Where-Object { -not $_.IsBuiltIn -and -not $_.IsDefault -and $_.Scope -ne 'Not applied' })
    $allApplicable   = @($policyResults | Where-Object { -not $_.IsBuiltIn -and ($_.IsDefault -or $_.Scope -ne 'Not applied') })

    $defaultMeetsSubset  = (-not $defaultResult) -or ($defaultResult.RowStatus -ne 'Fail')
    $anyCustomPass       = ($ruleRefPolicies | Where-Object { $_.RowStatus -eq 'Pass' }).Count -gt 0
    $anyCustomFail       = ($ruleRefPolicies | Where-Object { $_.RowStatus -eq 'Fail' }).Count -gt 0
    $anyApplicableInvest = ($allApplicable | Where-Object { $_.RowStatus -eq 'Investigate' }).Count -gt 0
    $failCount           = ($allApplicable | Where-Object { $_.RowStatus -eq 'Fail' }).Count

    # Overall verdict: Fail > Investigate > Pass
    $isFail   = (-not $defaultMeetsSubset) -or $anyCustomFail
    $isInvest = -not $isFail -and (-not $anyCustomPass -or $anyApplicableInvest -or $orphanedRules.Count -gt 0)

    $passed       = $false
    $customStatus = $null
    $aggregation  = ''

    if ($isFail) {
        # Orphaned rules appended to Fail aggregation per spec
        $aggParts = @("$failCount $(if ($failCount -eq 1) {'policy'} else {'policies'}) non-compliant")
        if ($orphanedRules.Count -gt 0) {
            $aggParts += "$($orphanedRules.Count) orphaned $(if ($orphanedRules.Count -eq 1) {'rule'} else {'rules'})"
        }
        $aggregation        = $aggParts -join ', '
        $testResultMarkdown = "❌ One or more anti-phishing policies disable a key control (spoof, DMARC, mailbox intelligence, or impersonation protection), have empty impersonation lists, or apply non-quarantine actions to phishing.`n`n%TestResult%"
    }
    elseif ($isInvest) {
        $customStatus = 'Investigate'
        if ($orphanedRules.Count -gt 0) {
            $aggregation        = ($orphanedRules | ForEach-Object { "orphaned rule $($_.Name) references phantom policy" }) -join '; '
            $testResultMarkdown = "⚠️ An enabled anti-phishing rule references a policy that does not exist. Manual review is required to confirm the customer's intent.`n`n%TestResult%"
        }
        else {
            if ($anyApplicableInvest) {
                $triggerText        = if ($defaultResult -and $defaultResult.RowStatus -eq 'Investigate') {
                    'phish threshold below 3 in default policy'
                } else { 'phish threshold below 3 in one or more policies' }
                $aggregation        = $triggerText
                $testResultMarkdown = "⚠️ One or more anti-phishing policies have \`PhishThresholdLevel\` below 3 (Microsoft recommends 3 — More aggressive — for Standard, and 4 — Most aggressive — for Strict); manual review is required to confirm the customer's intent.`n`n%TestResult%"
            }
            else {
                $aggregation        = 'no custom anti-phishing policy with full impersonation controls is applied to any recipient'
                $testResultMarkdown = "⚠️ Only the default anti-phishing policy is in effect. The default policy is evaluated against the minimum spoof and DMARC subset only; it does not enforce targeted-user, domain, or org-domain impersonation protection. Configure a custom policy with impersonation controls and assign it via an enabled rule.`n`n%TestResult%"
            }
        }
    }
    else {
        $passed             = $true
        $aggregation        = 'at least one compliant policy applies, default policy meets required subset, no orphaned rules'
        $testResultMarkdown = "✅ Anti-phishing policies are configured with spoof intelligence, DMARC honoring, mailbox intelligence, and impersonation protection for users and domains; impersonation and spoof verdicts are quarantined without unapproved self-release.`n`n%TestResult%"
    }
    #endregion Assessment Logic

    #region Report Generation
    $portalLink = 'https://security.microsoft.com/antiphishing'
    $maxDisplay = 10
    $totalCount = $policyResults.Count

    $overallVerdict = if ($passed) { 'Pass' } elseif ($customStatus) { 'Investigate' } else { 'Fail' }
    $overallLine    = "**Overall: $overallVerdict** — $aggregation"

    $statusOrder = @{ Fail = 0; Investigate = 1; Pass = 2 }
    $displayRows = @($policyResults | Sort-Object { $statusOrder[$_.RowStatus] }, IdentityDisplay) | Select-Object -First $maxDisplay
    $isTruncated = $totalCount -gt $maxDisplay

    $tableRows = ''
    foreach ($row in $displayRows) {
        $identityMd = Get-SafeMarkdown -Text $row.IdentityDisplay
        $scopeMd    = Get-SafeMarkdown -Text $row.Scope
        $resultMd   = Get-SafeMarkdown -Text $row.Result
        $tableRows += "| $identityMd | $scopeMd | $($row.Impersonation) | $($row.Authentication) | $($row.PhishThreshold) | $resultMd |`n"
    }

    if ($isTruncated) {
        $tableRows += "| ... | ... | ... | ... | ... | ... |`n"
    }

    $preTableLines = if ($isTruncated) {
        "Showing $maxDisplay of $totalCount policies. [View all in Microsoft 365 Defender]($portalLink)`n`n"
    } else { '' }

    $formatTemplate = @'
 
 
{0}
 
### [Microsoft 365 Defender > Policies & rules > Anti-phishing]({1})
 
{2}| Policy | Scope | Impersonation | Authentication | Phish threshold | Result |
| :----- | :---- | :------------ | :------------- | --------------: | :----- |
{3}
'@


    $mdInfo             = $formatTemplate -f $overallLine, $portalLink, $preTableLines, $tableRows
    $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
    #endregion Report Generation

    $params = @{
        TestId = '41033'
        Title  = 'Anti-phishing policies in Microsoft Defender for Office 365 are configured with impersonation and spoof protection'
        Status = $passed
        Result = $testResultMarkdown
    }
    if ($customStatus) {
        $params.CustomStatus = $customStatus
    }
    Add-ZtTestResultDetail @params
}