Private/Entra/Checks/Invoke-M365ExchangeChecks.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Invoke-M365ExchangeChecks {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$AuditData
    )

    $checkDefs = Get-AuditCategoryDefinitions -Category 'M365ExchangeChecks'
    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($check in $checkDefs.checks) {
        $funcName = "Test-Infiltration$($check.id -replace '-', '')"
        if (Get-Command $funcName -ErrorAction SilentlyContinue) {
            try {
                $finding = & $funcName -AuditData $AuditData -CheckDefinition $check
                if ($finding) { $findings.Add($finding) }
            } catch {
                $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'ERROR' `
                    -CurrentValue "Check failed: $_"))
            }
        } else {
            $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'SKIP' `
                -CurrentValue 'Check not yet implemented'))
        }
    }

    return @($findings)
}

# ── M365EXO-001: Exchange Organization Configuration ────────────────────
function Test-InfiltrationM365EXO001 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $exo = $AuditData.M365Services.Exchange
    if (-not $exo -or -not $exo.OrganizationConfig) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Exchange Online data not available (EXO module not connected)'
    }

    $orgConfig = $exo.OrganizationConfig
    $auditDisabled = $orgConfig.AuditDisabled
    $oauth2ClientProfileEnabled = $orgConfig.OAuth2ClientProfileEnabled

    $status = if ($auditDisabled -eq $false) { 'PASS' } else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "Exchange Org: AuditDisabled=$auditDisabled, OAuth2ClientProfile=$oauth2ClientProfileEnabled" `
        -Details @{
            AuditDisabled = $auditDisabled
            OAuth2ClientProfileEnabled = $oauth2ClientProfileEnabled
            Name = $orgConfig.Name
            DefaultGroupAccessType = $orgConfig.DefaultGroupAccessType
        }
}

# ── M365EXO-002: Anti-Spam Policies ────────────────────────────────────
function Test-InfiltrationM365EXO002 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $exo = $AuditData.M365Services.Exchange
    if (-not $exo -or -not $exo.AntiSpamPolicies) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Exchange anti-spam data not available'
    }

    $policies = $exo.AntiSpamPolicies
    if ($policies.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No anti-spam policies found'
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "$($policies.Count) anti-spam (hosted content filter) policies configured" `
        -Details @{
            PolicyCount = $policies.Count
            Policies = @($policies | ForEach-Object {
                @{
                    Name = $_.Name
                    Identity = $_.Identity
                    IsDefault = $_.IsDefault
                    SpamAction = $_.SpamAction
                    HighConfidenceSpamAction = $_.HighConfidenceSpamAction
                    BulkSpamAction = $_.BulkSpamAction
                    BulkThreshold = $_.BulkThreshold
                }
            })
        }
}

# ── M365EXO-003: Anti-Phish Policies ───────────────────────────────────
function Test-InfiltrationM365EXO003 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $exo = $AuditData.M365Services.Exchange
    if (-not $exo -or -not $exo.AntiPhishPolicies) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Exchange anti-phish data not available'
    }

    $policies = $exo.AntiPhishPolicies
    if ($policies.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No anti-phish policies found'
    }

    # Check if impersonation protection is enabled
    $impersonationEnabled = @($policies | Where-Object {
        $_.EnableTargetedUserProtection -eq $true -or
        $_.EnableTargetedDomainsProtection -eq $true -or
        $_.EnableMailboxIntelligenceProtection -eq $true
    })

    $status = if ($impersonationEnabled.Count -gt 0) { 'PASS' } else { 'WARN' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($policies.Count) anti-phish policies ($($impersonationEnabled.Count) with impersonation protection)" `
        -Details @{
            PolicyCount = $policies.Count
            ImpersonationProtectedCount = $impersonationEnabled.Count
            Policies = @($policies | ForEach-Object {
                @{
                    Name = $_.Name
                    Enabled = $_.Enabled
                    EnableTargetedUserProtection = $_.EnableTargetedUserProtection
                    EnableTargetedDomainsProtection = $_.EnableTargetedDomainsProtection
                    EnableMailboxIntelligenceProtection = $_.EnableMailboxIntelligenceProtection
                    PhishThresholdLevel = $_.PhishThresholdLevel
                }
            })
        }
}

# ── M365EXO-004: Malware Filter Policies ───────────────────────────────
function Test-InfiltrationM365EXO004 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $exo = $AuditData.M365Services.Exchange
    if (-not $exo -or -not $exo.MalwarePolicies) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Exchange malware filter data not available'
    }

    $policies = $exo.MalwarePolicies
    if ($policies.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No malware filter policies found'
    }

    # Check ZAP and common attachment filter
    $zapEnabled = @($policies | Where-Object { $_.ZapEnabled -eq $true })
    $fileFilterEnabled = @($policies | Where-Object { $_.EnableFileFilter -eq $true })

    $status = if ($zapEnabled.Count -eq $policies.Count) { 'PASS' } else { 'WARN' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($policies.Count) malware policies ($($zapEnabled.Count) ZAP enabled, $($fileFilterEnabled.Count) file filter enabled)" `
        -Details @{
            PolicyCount = $policies.Count
            ZapEnabledCount = $zapEnabled.Count
            FileFilterEnabledCount = $fileFilterEnabled.Count
            Policies = @($policies | ForEach-Object {
                @{
                    Name = $_.Name
                    ZapEnabled = $_.ZapEnabled
                    EnableFileFilter = $_.EnableFileFilter
                    EnableInternalSenderAdminNotifications = $_.EnableInternalSenderAdminNotifications
                }
            })
        }
}

# ── M365EXO-005: Safe Attachments Policies ─────────────────────────────
function Test-InfiltrationM365EXO005 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $exo = $AuditData.M365Services.Exchange
    if (-not $exo -or -not $exo.SafeAttachmentPolicies) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Safe Attachments data not available (requires Defender for Office 365)'
    }

    $policies = $exo.SafeAttachmentPolicies
    if ($policies.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No Safe Attachment policies configured — Defender for Office 365 may not be licensed' `
            -Details @{ PolicyCount = 0 }
    }

    $dynamicDelivery = @($policies | Where-Object { $_.Action -eq 'DynamicDelivery' })
    $block = @($policies | Where-Object { $_.Action -eq 'Block' })
    $replace = @($policies | Where-Object { $_.Action -eq 'Replace' })

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "$($policies.Count) Safe Attachment policies ($($dynamicDelivery.Count) dynamic delivery, $($block.Count) block)" `
        -Details @{
            PolicyCount = $policies.Count
            DynamicDelivery = $dynamicDelivery.Count
            Block = $block.Count
            Replace = $replace.Count
            Policies = @($policies | ForEach-Object {
                @{
                    Name = $_.Name
                    Action = $_.Action
                    Enable = $_.Enable
                    Redirect = $_.Redirect
                }
            })
        }
}

# ── M365EXO-006: Safe Links Policies ───────────────────────────────────
function Test-InfiltrationM365EXO006 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $exo = $AuditData.M365Services.Exchange
    if (-not $exo -or -not $exo.SafeLinksPolicies) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Safe Links data not available (requires Defender for Office 365)'
    }

    $policies = $exo.SafeLinksPolicies
    if ($policies.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No Safe Links policies configured — Defender for Office 365 may not be licensed' `
            -Details @{ PolicyCount = 0 }
    }

    $urlTracking = @($policies | Where-Object { $_.EnableSafeLinksForEmail -eq $true -or $_.IsEnabled -eq $true })
    $scanUrls = @($policies | Where-Object { $_.ScanUrls -eq $true })

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue "$($policies.Count) Safe Links policies ($($urlTracking.Count) enabled for email, $($scanUrls.Count) scan URLs)" `
        -Details @{
            PolicyCount = $policies.Count
            EnabledForEmail = $urlTracking.Count
            ScanUrls = $scanUrls.Count
            Policies = @($policies | ForEach-Object {
                @{
                    Name = $_.Name
                    EnableSafeLinksForEmail = $_.EnableSafeLinksForEmail
                    ScanUrls = $_.ScanUrls
                    EnableForInternalSenders = $_.EnableForInternalSenders
                    DeliverMessageAfterScan = $_.DeliverMessageAfterScan
                    DisableUrlRewrite = $_.DisableUrlRewrite
                }
            })
        }
}

# ── M365EXO-007: Transport Rules ───────────────────────────────────────
function Test-InfiltrationM365EXO007 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $exo = $AuditData.M365Services.Exchange
    if (-not $exo -or -not $exo.TransportRules) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Exchange transport rule data not available'
    }

    $rules = $exo.TransportRules
    if ($rules.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No mail flow (transport) rules configured' `
            -Details @{ RuleCount = 0 }
    }

    $enabled = @($rules | Where-Object { $_.State -eq 'Enabled' })
    $disabled = @($rules | Where-Object { $_.State -ne 'Enabled' })

    # Flag rules that redirect or forward mail externally
    $forwardingRules = @($rules | Where-Object {
        $_.RedirectMessageTo -or
        $_.BlindCopyTo -or
        $_.CopyTo -or
        $_.AddToRecipients
    })

    $status = if ($forwardingRules.Count -gt 0) { 'WARN' } else { 'PASS' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($rules.Count) transport rules ($($enabled.Count) enabled, $($forwardingRules.Count) with redirect/forward actions)" `
        -Details @{
            RuleCount = $rules.Count
            EnabledCount = $enabled.Count
            DisabledCount = $disabled.Count
            ForwardingRuleCount = $forwardingRules.Count
            Rules = @($rules | ForEach-Object {
                @{
                    Name = $_.Name
                    State = $_.State
                    Priority = $_.Priority
                    Mode = $_.Mode
                }
            })
        }
}

# ── M365EXO-008: Remote Domains ────────────────────────────────────────
function Test-InfiltrationM365EXO008 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $exo = $AuditData.M365Services.Exchange
    if (-not $exo -or -not $exo.RemoteDomains) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Exchange remote domain data not available'
    }

    $domains = $exo.RemoteDomains
    # Check default remote domain settings
    $defaultDomain = $domains | Where-Object { $_.DomainName -eq '*' } | Select-Object -First 1

    $autoForwardEnabled = $false
    if ($defaultDomain) {
        $autoForwardEnabled = $defaultDomain.AutoForwardEnabled -eq $true
    }

    $status = if ($autoForwardEnabled) { 'FAIL' } else { 'PASS' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "Remote domains: $($domains.Count) configured. Default domain auto-forward: $autoForwardEnabled" `
        -Details @{
            DomainCount = $domains.Count
            DefaultAutoForward = $autoForwardEnabled
            Domains = @($domains | ForEach-Object {
                @{
                    DomainName = $_.DomainName
                    AutoForwardEnabled = $_.AutoForwardEnabled
                    AutoReplyEnabled = $_.AutoReplyEnabled
                    AllowedOOFType = $_.AllowedOOFType
                }
            })
        }
}

# ── M365EXO-009: DKIM Signing Configuration ────────────────────────────
function Test-InfiltrationM365EXO009 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $exo = $AuditData.M365Services.Exchange
    if (-not $exo -or -not $exo.DkimSigningConfig) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'DKIM signing configuration data not available'
    }

    $configs = $exo.DkimSigningConfig
    if ($configs.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No DKIM signing configurations found'
    }

    $enabled = @($configs | Where-Object { $_.Enabled -eq $true })
    $disabled = @($configs | Where-Object { $_.Enabled -ne $true })

    $status = if ($disabled.Count -eq 0) { 'PASS' }
              elseif ($enabled.Count -gt 0) { 'WARN' }
              else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($configs.Count) DKIM configs: $($enabled.Count) enabled, $($disabled.Count) disabled" `
        -Details @{
            TotalConfigs = $configs.Count
            EnabledCount = $enabled.Count
            DisabledCount = $disabled.Count
            Configs = @($configs | ForEach-Object {
                @{
                    Domain = $_.Domain
                    Enabled = $_.Enabled
                    Status = $_.Status
                    Selector1CNAME = $_.Selector1CNAME
                    Selector2CNAME = $_.Selector2CNAME
                    KeyCreationTime = $_.KeyCreationTime
                }
            })
        }
}

# ── M365EXO-010: CAS Mailbox Plans (Protocol Access) ──────────────────
function Test-InfiltrationM365EXO010 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $exo = $AuditData.M365Services.Exchange
    if (-not $exo -or -not $exo.CASMailboxPlans) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'CAS mailbox plan data not available'
    }

    $plans = $exo.CASMailboxPlans
    if ($plans.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
            -CurrentValue 'No CAS mailbox plans found'
    }

    # Check for legacy protocols enabled
    $legacyEnabled = @($plans | Where-Object {
        $_.ImapEnabled -eq $true -or
        $_.PopEnabled -eq $true
    })

    $status = if ($legacyEnabled.Count -eq 0) { 'PASS' }
              elseif ($legacyEnabled.Count -le 1) { 'WARN' }
              else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "$($plans.Count) CAS mailbox plans ($($legacyEnabled.Count) with IMAP/POP enabled)" `
        -Details @{
            PlanCount = $plans.Count
            LegacyProtocolEnabledCount = $legacyEnabled.Count
            Plans = @($plans | ForEach-Object {
                @{
                    Name = $_.Name
                    ImapEnabled = $_.ImapEnabled
                    PopEnabled = $_.PopEnabled
                    ActiveSyncEnabled = $_.ActiveSyncEnabled
                    OWAEnabled = $_.OWAEnabled
                    MAPIEnabled = $_.MAPIEnabled
                    EwsEnabled = $_.EwsEnabled
                }
            })
        }
}

# ── M365EXO-011: External Email Forwarding ─────────────────────────────
function Test-InfiltrationM365EXO011 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $exo = $AuditData.M365Services.Exchange
    if (-not $exo) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Exchange Online data not available'
    }

    # Check transport rules for forwarding AND remote domain auto-forward
    $issues = [System.Collections.Generic.List[string]]::new()

    # Check remote domain auto-forward
    if ($exo.RemoteDomains) {
        $defaultDomain = $exo.RemoteDomains | Where-Object { $_.DomainName -eq '*' } | Select-Object -First 1
        if ($defaultDomain -and $defaultDomain.AutoForwardEnabled -eq $true) {
            $issues.Add('Default remote domain allows auto-forwarding')
        }
    }

    # Check transport rules for forwarding
    if ($exo.TransportRules) {
        $forwardRules = @($exo.TransportRules | Where-Object {
            $_.State -eq 'Enabled' -and
            ($_.RedirectMessageTo -or $_.BlindCopyTo)
        })
        if ($forwardRules.Count -gt 0) {
            $issues.Add("$($forwardRules.Count) active transport rules redirect/BCC mail")
        }
    }

    $status = if ($issues.Count -eq 0) { 'PASS' }
              elseif ($issues.Count -eq 1) { 'WARN' }
              else { 'FAIL' }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue "External forwarding controls: $($issues.Count) issue(s). $(if ($issues.Count -gt 0) { $issues -join '; ' } else { 'All controls in place' })" `
        -Details @{
            IssueCount = $issues.Count
            Issues = @($issues)
        }
}

# ── M365EXO-012: Exchange Audit Configuration ──────────────────────────
function Test-InfiltrationM365EXO012 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $exo = $AuditData.M365Services.Exchange
    if (-not $exo -or -not $exo.OrganizationConfig) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Exchange organization config not available'
    }

    $orgConfig = $exo.OrganizationConfig
    $auditDisabled = $orgConfig.AuditDisabled

    if ($auditDisabled -eq $true) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
            -CurrentValue 'Exchange mailbox auditing is DISABLED at the organization level' `
            -Details @{ AuditDisabled = $true }
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue 'Exchange mailbox auditing is enabled at the organization level' `
        -Details @{
            AuditDisabled = $false
        }
}