Private/Core/Invoke-AlertEscalation.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-AlertEscalation {
    <#
    .SYNOPSIS
        Checks for unresolved alerts that should be escalated and re-dispatches them.
    .DESCRIPTION
        Reads the alert history, finds alerts older than the escalation window that
        haven't been resolved, and re-dispatches them via the escalation provider list
        with an [ESCALATED] subject prefix.
    .PARAMETER Config
        The full PSGuerrilla config hashtable.
    .PARAMETER ScanResult
        The current scan result (used to verify threats are still active).
    .PARAMETER Force
        Force escalation even if window hasn't elapsed.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Config,

        [Parameter(Mandatory)]
        [PSCustomObject]$ScanResult,

        [switch]$Force
    )

    $escalation = $Config.alerting.escalation
    if (-not $escalation -or -not $escalation.enabled) {
        Write-Verbose 'Escalation is not enabled.'
        return [PSCustomObject]@{
            Provider = 'Escalation'
            Success  = $false
            Message  = 'Escalation not enabled'
            Error    = $null
            Escalated = 0
        }
    }

    $windowMinutes = $escalation.windowMinutes ?? 120
    $threshold = $escalation.threshold ?? 'HIGH'
    $escalationProviders = @($escalation.providers ?? @())

    if ($escalationProviders.Count -eq 0) {
        Write-Verbose 'No escalation providers configured.'
        return [PSCustomObject]@{
            Provider = 'Escalation'
            Success  = $false
            Message  = 'No escalation providers configured'
            Error    = $null
            Escalated = 0
        }
    }

    $levelOrder = @{ 'LOW' = 1; 'MEDIUM' = 2; 'HIGH' = 3; 'CRITICAL' = 4 }
    $minOrdinal = $levelOrder[$threshold] ?? 3

    # Load alert history
    $dataDir = Get-PSGuerrillaDataRoot
    $historyPath = Join-Path $dataDir 'alert-history.json'
    $history = @{}
    if (Test-Path $historyPath) {
        try {
            $history = Get-Content -Path $historyPath -Raw | ConvertFrom-Json -AsHashtable
            if ($history -isnot [hashtable]) { $history = @{} }
        } catch { $history = @{} }
    }

    if ($history.Count -eq 0) {
        return [PSCustomObject]@{
            Provider = 'Escalation'
            Success  = $true
            Message  = 'No alert history to escalate'
            Error    = $null
            Escalated = 0
        }
    }

    # Find alerts older than escalation window that are still active threats
    $escalationCutoff = [datetime]::UtcNow.AddMinutes(-$windowMinutes)

    # Get current active threat emails for cross-reference
    $activeThreats = @{}
    $flagged = $ScanResult.FlaggedUsers ?? $ScanResult.FlaggedEntities ?? $ScanResult.FlaggedChanges ?? @()
    foreach ($t in $flagged) {
        $email = $t.Email ?? $t.UserPrincipalName ?? ''
        if ($email) { $activeThreats[$email] = $t }
    }

    $threatsToEscalate = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($key in $history.Keys) {
        $entry = $history[$key]
        $entryTime = [datetime]::MinValue
        try { $entryTime = [datetime]::Parse($entry.timestamp) } catch { continue }

        # Must be older than escalation window
        if (-not $Force -and $entryTime -gt $escalationCutoff) { continue }

        # Must meet severity threshold
        $entryOrdinal = $levelOrder[$entry.threatLevel] ?? 0
        if ($entryOrdinal -lt $minOrdinal) { continue }

        # Must still be an active threat
        $entryEmail = $entry.email ?? ''
        if ($activeThreats.ContainsKey($entryEmail)) {
            $threatsToEscalate.Add($activeThreats[$entryEmail])
        }
    }

    if ($threatsToEscalate.Count -eq 0) {
        return [PSCustomObject]@{
            Provider  = 'Escalation'
            Success   = $true
            Message   = 'No threats require escalation'
            Error     = $null
            Escalated = 0
        }
    }

    # Dispatch via escalation providers
    $subject = "[ESCALATED] [PSGuerrilla] $($threatsToEscalate.Count) unresolved threat(s)"
    $allResults = [System.Collections.Generic.List[PSCustomObject]]::new()

    $htmlContent = Format-SignalContent -ScanResult $ScanResult -Format Html -Threats $threatsToEscalate
    $textContent = Format-SignalContent -ScanResult $ScanResult -Format Text -Threats $threatsToEscalate

    foreach ($prov in $escalationProviders) {
        switch ($prov) {
            'SendGrid' {
                $sg = $Config.alerting.providers.sendgrid
                if ($sg.apiKey -and $sg.fromEmail -and $sg.toEmails.Count -gt 0) {
                    $r = Send-SignalSendGrid -ApiKey $sg.apiKey -FromEmail $sg.fromEmail `
                        -ToEmails $sg.toEmails -Subject $subject -HtmlBody $htmlContent -TextBody $textContent `
                        -FromName ($sg.fromName ?? 'PSGuerrilla Escalation')
                    $allResults.Add($r)
                }
            }
            'Teams' {
                $tm = $Config.alerting.providers.teams
                if ($tm.webhookUrl) {
                    $r = Send-SignalTeams -WebhookUrl $tm.webhookUrl -Subject $subject -Threats @($threatsToEscalate)
                    $allResults.Add($r)
                }
            }
            'Slack' {
                $sl = $Config.alerting.providers.slack
                if ($sl.webhookUrl) {
                    $r = Send-SignalSlack -WebhookUrl $sl.webhookUrl -Subject $subject -Threats @($threatsToEscalate) -TextBody $textContent
                    $allResults.Add($r)
                }
            }
            'PagerDuty' {
                $pd = $Config.alerting.providers.pagerduty
                if ($pd.routingKey) {
                    $r = Send-SignalPagerDuty -RoutingKey $pd.routingKey -Subject $subject -Threats @($threatsToEscalate)
                    $allResults.Add($r)
                }
            }
            'Webhook' {
                $wh = $Config.alerting.providers.webhook
                if ($wh.url) {
                    $whHeaders = @{}
                    if ($wh.headers) { foreach ($k in $wh.headers.Keys) { $whHeaders[$k] = $wh.headers[$k] } }
                    $r = Send-SignalWebhook -WebhookUrl $wh.url -Threats @($threatsToEscalate) -ScanResult $ScanResult -Headers $whHeaders -AuthToken ($wh.authToken ?? '')
                    $allResults.Add($r)
                }
            }
        }
    }

    $anySuccess = @($allResults | Where-Object Success).Count -gt 0
    return [PSCustomObject]@{
        Provider  = 'Escalation'
        Success   = $anySuccess
        Message   = "Escalation: $($threatsToEscalate.Count) threat(s) escalated via $($allResults.Count) provider(s)"
        Error     = if (-not $anySuccess -and $allResults.Count -gt 0) { ($allResults | Where-Object { -not $_.Success } | Select-Object -First 1).Error } else { $null }
        Escalated = $threatsToEscalate.Count
        Details   = @($allResults)
    }
}