Public/Send-Signal.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 Send-Signal { [CmdletBinding()] param( [Parameter(ValueFromPipeline)] [PSCustomObject]$ScanResult, [ValidateSet('SendGrid', 'Mailgun', 'Twilio', 'Teams', 'Slack', 'Webhook', 'PagerDuty', 'Pushover', 'Syslog', 'EventLog', 'All')] [string[]]$Provider, [ValidateSet('CRITICAL', 'HIGH', 'MEDIUM', 'LOW')] [string]$MinimumThreatLevel, [bool]$NewOnly = $true, [switch]$IncludeHtmlReport, [switch]$DryRun, [switch]$Force, [Alias('RuntimeConfig')] [string]$ConfigPath, [Alias('MissionConfig')] [string]$ConfigFile ) process { $validTypes = @( 'PSGuerrilla.ScanResult' 'PSGuerrilla.SurveillanceResult' 'PSGuerrilla.WatchtowerResult' 'PSGuerrilla.WiretapResult' ) $isValid = $false if ($ScanResult) { foreach ($typeName in $ScanResult.PSObject.TypeNames) { if ($typeName -in $validTypes) { $isValid = $true; break } } } if (-not $isValid) { Write-Warning 'Send-Signal requires a PSGuerrilla result object. Pipe from Invoke-Recon, Invoke-Surveillance, Invoke-Watchtower, or Invoke-Wiretap.' return } # Normalize property access: all result types use NewThreats and FlaggedUsers/FlaggedEntities/FlaggedChanges if (-not $ScanResult.PSObject.Properties['FlaggedUsers']) { $flaggedProp = $ScanResult.PSObject.Properties['FlaggedEntities'] ?? $ScanResult.PSObject.Properties['FlaggedChanges'] if ($flaggedProp) { $ScanResult | Add-Member -NotePropertyName 'FlaggedUsers' -NotePropertyValue $flaggedProp.Value -Force } } # Load config $cfgPath = if ($ConfigPath) { $ConfigPath } else { $script:ConfigPath } $config = $null if (Test-Path $cfgPath) { $config = Get-Content -Path $cfgPath -Raw | ConvertFrom-Json -AsHashtable } # --- Resolve alerting credentials from vault via mission config --- if ($ConfigFile) { $missionCfg = Read-MissionConfig -Path $ConfigFile $vaultName = $missionCfg.VaultName # Resolve alerting channel credentials from vault and inject into config if ($missionCfg.Alerting -and $missionCfg.Alerting.channels -and $config) { if (-not $config.alerting) { $config.alerting = @{ enabled = $true; providers = @{} } } if (-not $config.alerting.providers) { $config.alerting.providers = @{} } $config.alerting.enabled = $true foreach ($channel in $missionCfg.Alerting.channels) { if ($channel.vaultKey) { try { $credValue = Get-GuerrillaCredential -VaultKey $channel.vaultKey -VaultName $vaultName $providerKey = $channel.type.ToLower() switch ($channel.type) { 'teams' { if (-not $config.alerting.providers.teams) { $config.alerting.providers.teams = @{} } $config.alerting.providers.teams.enabled = $true $config.alerting.providers.teams.webhookUrl = $credValue } 'slack' { if (-not $config.alerting.providers.slack) { $config.alerting.providers.slack = @{} } $config.alerting.providers.slack.enabled = $true $config.alerting.providers.slack.webhookUrl = $credValue } 'webhook' { if (-not $config.alerting.providers.webhook) { $config.alerting.providers.webhook = @{} } $config.alerting.providers.webhook.enabled = $true $config.alerting.providers.webhook.url = $credValue } 'pagerduty' { if (-not $config.alerting.providers.pagerduty) { $config.alerting.providers.pagerduty = @{} } $config.alerting.providers.pagerduty.enabled = $true $config.alerting.providers.pagerduty.routingKey = $credValue } 'email' { # Email credential from vault is JSON with apiKey, fromEmail, toEmails, and optionally provider/domain try { $emailCfg = $credValue | ConvertFrom-Json -AsHashtable $emailProvider = if ($emailCfg.provider -eq 'mailgun') { 'mailgun' } else { 'sendgrid' } if ($emailProvider -eq 'mailgun') { if (-not $config.alerting.providers.mailgun) { $config.alerting.providers.mailgun = @{} } $config.alerting.providers.mailgun.enabled = $true foreach ($k in $emailCfg.Keys) { $config.alerting.providers.mailgun[$k] = $emailCfg[$k] } # Ensure domain is set — fall back to from address domain if (-not $config.alerting.providers.mailgun.domain -and $emailCfg.fromEmail -match '@(.+)$') { $config.alerting.providers.mailgun.domain = $Matches[1] } } else { if (-not $config.alerting.providers.sendgrid) { $config.alerting.providers.sendgrid = @{} } $config.alerting.providers.sendgrid.enabled = $true foreach ($k in $emailCfg.Keys) { $config.alerting.providers.sendgrid[$k] = $emailCfg[$k] } } } catch { Write-Warning "Failed to parse email credential from vault: $_" } } 'sms' { # SMS/Twilio credential from vault is expected to be JSON try { $smsCfg = $credValue | ConvertFrom-Json -AsHashtable if (-not $config.alerting.providers.twilio) { $config.alerting.providers.twilio = @{} } $config.alerting.providers.twilio.enabled = $true foreach ($k in $smsCfg.Keys) { $config.alerting.providers.twilio[$k] = $smsCfg[$k] } } catch { Write-Warning "Failed to parse SMS credential from vault: $_" } } 'pushover' { # Pushover credential from vault is JSON with apiToken, userKey try { $pushCfg = $credValue | ConvertFrom-Json -AsHashtable if (-not $config.alerting.providers.pushover) { $config.alerting.providers.pushover = @{} } $config.alerting.providers.pushover.enabled = $true foreach ($k in $pushCfg.Keys) { $config.alerting.providers.pushover[$k] = $pushCfg[$k] } } catch { Write-Warning "Failed to parse Pushover credential from vault: $_" } } } } catch { Write-Warning "Failed to resolve alerting credential '$($channel.vaultKey)' from vault: $_" } } } } } if (-not $config -or -not $config.alerting) { Write-Warning 'No alerting configuration found. Run Set-Safehouse to configure alert providers.' return } if (-not $config.alerting.enabled -and -not $Force) { Write-Verbose 'Alerting is disabled in config. Use -Force to override.' return } # Determine global minimum level $minLevel = if ($MinimumThreatLevel) { $MinimumThreatLevel } elseif ($config.alerting.minimumThreatLevel) { $config.alerting.minimumThreatLevel } else { 'HIGH' } $levelOrder = @{ 'LOW' = 1; 'MEDIUM' = 2; 'HIGH' = 3; 'CRITICAL' = 4 } $minOrdinal = $levelOrder[$minLevel] # Select threats to alert on $threats = if ($NewOnly -and -not $Force) { $ScanResult.NewThreats } else { $ScanResult.FlaggedUsers } $threats = @($threats | Where-Object { $levelOrder[$_.ThreatLevel] -ge $minOrdinal }) if ($threats.Count -eq 0) { Write-Verbose "No threats at $minLevel or above to alert on." return [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.AlertResult' Sent = $false Reason = "No threats at $minLevel or above" Results = @() } } # --- Alert deduplication --- $suppressionHours = $config.alerting.suppression.windowHours ?? 24 if (-not $Force -and $config.alerting.suppression.enabled) { $dedupResults = @() $unsuppressed = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($t in $threats) { $dedup = Get-AlertDeduplication -Threat $t -SuppressionHours $suppressionHours if ($dedup.IsSuppressed) { Write-Verbose "Suppressed duplicate alert for $($dedup.Email) ($($dedup.ThreatLevel))" } else { $unsuppressed.Add($t) $dedupResults += $dedup } } if ($unsuppressed.Count -eq 0) { Write-Verbose "All $($threats.Count) threat(s) suppressed by deduplication." return [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.AlertResult' Sent = $false Reason = "All threats suppressed (within ${suppressionHours}h window)" Results = @() } } $threats = @($unsuppressed) } # Format content $htmlContent = Format-SignalContent -ScanResult $ScanResult -Format Html -Threats $threats $textContent = Format-SignalContent -ScanResult $ScanResult -Format Text -Threats $threats $smsContent = Format-SignalContent -ScanResult $ScanResult -Format Sms -Threats $threats $critCount = @($threats | Where-Object ThreatLevel -eq 'CRITICAL').Count $highCount = @($threats | Where-Object ThreatLevel -eq 'HIGH').Count $subject = "[PSGuerrilla] $($threats.Count) threat(s) detected" if ($critCount -gt 0) { $subject += " - $critCount CRITICAL" } if ($highCount -gt 0) { $subject += " - $highCount HIGH" } if ($DryRun) { Write-GuerrillaText '=== DRY RUN - No signals sent ===' -Color Amber Write-GuerrillaText "Subject: $subject" -Color Olive Write-GuerrillaText "Threats: $($threats.Count)" -Color Olive Write-Host '' Write-GuerrillaText '--- Text Content ---' -Color Parchment Write-Host $textContent Write-Host '' Write-GuerrillaText '--- SMS Content ---' -Color Parchment Write-Host $smsContent return [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.AlertResult' Sent = $false Reason = 'DryRun' Results = @() } } # Determine which providers to use $enabledProviders = @() if (-not $Provider -or 'All' -in $Provider) { if ($config.alerting.providers.sendgrid.enabled) { $enabledProviders += 'SendGrid' } if ($config.alerting.providers.mailgun.enabled) { $enabledProviders += 'Mailgun' } if ($config.alerting.providers.twilio.enabled) { $enabledProviders += 'Twilio' } if ($config.alerting.providers.teams.enabled) { $enabledProviders += 'Teams' } if ($config.alerting.providers.slack.enabled) { $enabledProviders += 'Slack' } if ($config.alerting.providers.webhook.enabled) { $enabledProviders += 'Webhook' } if ($config.alerting.providers.pagerduty.enabled) { $enabledProviders += 'PagerDuty' } if ($config.alerting.providers.pushover.enabled) { $enabledProviders += 'Pushover' } if ($config.alerting.providers.syslog.enabled) { $enabledProviders += 'Syslog' } if ($config.alerting.providers.eventlog.enabled) { $enabledProviders += 'EventLog' } } else { $enabledProviders = $Provider } if ($enabledProviders.Count -eq 0) { Write-Warning 'No alert providers are enabled. Configure providers with Set-Safehouse.' return [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.AlertResult' Sent = $false Reason = 'No providers enabled' Results = @() } } $allResults = [System.Collections.Generic.List[PSCustomObject]]::new() # --- Helper: filter threats by per-provider threshold --- $filterThreats = { param($ProviderName, $AllThreats) $provConfig = $config.alerting.providers[$ProviderName.ToLower()] if ($provConfig -and $provConfig.minimumThreatLevel) { $provMin = $levelOrder[$provConfig.minimumThreatLevel] ?? $minOrdinal @($AllThreats | Where-Object { $levelOrder[$_.ThreatLevel] -ge $provMin }) } else { $AllThreats } } # SendGrid if ('SendGrid' -in $enabledProviders) { $provThreats = & $filterThreats 'sendgrid' $threats if ($provThreats.Count -gt 0) { $sg = $config.alerting.providers.sendgrid if ($sg.apiKey -and $sg.fromEmail -and $sg.toEmails.Count -gt 0) { $result = Send-SignalSendGrid ` -ApiKey $sg.apiKey ` -FromEmail $sg.fromEmail ` -ToEmails $sg.toEmails ` -Subject $subject ` -HtmlBody $htmlContent ` -TextBody $textContent ` -FromName ($sg.fromName ?? 'PSGuerrilla Signals') $allResults.Add($result) Write-Verbose "SendGrid: $($result.Message)" } else { Write-Warning 'SendGrid enabled but missing apiKey, fromEmail, or toEmails.' } } } # Mailgun if ('Mailgun' -in $enabledProviders) { $provThreats = & $filterThreats 'mailgun' $threats if ($provThreats.Count -gt 0) { $mg = $config.alerting.providers.mailgun if ($mg.apiKey -and $mg.domain -and $mg.fromEmail -and $mg.toEmails.Count -gt 0) { $result = Send-SignalMailgun ` -ApiKey $mg.apiKey ` -Domain $mg.domain ` -FromEmail $mg.fromEmail ` -ToEmails $mg.toEmails ` -Subject $subject ` -HtmlBody $htmlContent ` -TextBody $textContent $allResults.Add($result) Write-Verbose "Mailgun: $($result.Message)" } else { Write-Warning 'Mailgun enabled but missing apiKey, domain, fromEmail, or toEmails.' } } } # Twilio SMS if ('Twilio' -in $enabledProviders) { $provThreats = & $filterThreats 'twilio' $threats if ($provThreats.Count -gt 0) { $tw = $config.alerting.providers.twilio if ($tw.accountSid -and $tw.authToken -and $tw.fromNumber -and $tw.toNumbers.Count -gt 0) { $results = Send-SignalTwilio ` -AccountSid $tw.accountSid ` -AuthToken $tw.authToken ` -FromNumber $tw.fromNumber ` -ToNumbers $tw.toNumbers ` -MessageBody $smsContent foreach ($r in $results) { $allResults.Add($r) } Write-Verbose "Twilio: $($results.Count) SMS sent" } else { Write-Warning 'Twilio enabled but missing accountSid, authToken, fromNumber, or toNumbers.' } } } # Teams if ('Teams' -in $enabledProviders) { $provThreats = & $filterThreats 'teams' $threats if ($provThreats.Count -gt 0) { $tm = $config.alerting.providers.teams if ($tm.webhookUrl) { $result = Send-SignalTeams ` -WebhookUrl $tm.webhookUrl ` -Subject $subject ` -Threats $provThreats $allResults.Add($result) Write-Verbose "Teams: $($result.Message)" } else { Write-Warning 'Teams enabled but missing webhookUrl.' } } } # Slack if ('Slack' -in $enabledProviders) { $provThreats = & $filterThreats 'slack' $threats if ($provThreats.Count -gt 0) { $sl = $config.alerting.providers.slack if ($sl.webhookUrl) { $result = Send-SignalSlack ` -WebhookUrl $sl.webhookUrl ` -Subject $subject ` -Threats $provThreats ` -TextBody $textContent $allResults.Add($result) Write-Verbose "Slack: $($result.Message)" } else { Write-Warning 'Slack enabled but missing webhookUrl.' } } } # Generic Webhook (SIEM) if ('Webhook' -in $enabledProviders) { $provThreats = & $filterThreats 'webhook' $threats if ($provThreats.Count -gt 0) { $wh = $config.alerting.providers.webhook if ($wh.url) { $whHeaders = @{} if ($wh.headers) { foreach ($key in $wh.headers.Keys) { $whHeaders[$key] = $wh.headers[$key] } } $result = Send-SignalWebhook ` -WebhookUrl $wh.url ` -Threats $provThreats ` -ScanResult $ScanResult ` -Headers $whHeaders ` -AuthToken ($wh.authToken ?? '') $allResults.Add($result) Write-Verbose "Webhook: $($result.Message)" } else { Write-Warning 'Webhook enabled but missing url.' } } } # PagerDuty if ('PagerDuty' -in $enabledProviders) { $provThreats = & $filterThreats 'pagerduty' $threats if ($provThreats.Count -gt 0) { $pd = $config.alerting.providers.pagerduty if ($pd.routingKey) { $result = Send-SignalPagerDuty ` -RoutingKey $pd.routingKey ` -Subject $subject ` -Threats $provThreats $allResults.Add($result) Write-Verbose "PagerDuty: $($result.Message)" } else { Write-Warning 'PagerDuty enabled but missing routingKey.' } } } # Pushover if ('Pushover' -in $enabledProviders) { $provThreats = & $filterThreats 'pushover' $threats if ($provThreats.Count -gt 0) { $po = $config.alerting.providers.pushover if ($po.apiToken -and $po.userKey) { # Map highest threat level to Pushover priority $maxLevel = ($provThreats | Sort-Object { $levelOrder[$_.ThreatLevel] } -Descending | Select-Object -First 1).ThreatLevel $pushPriority = switch ($maxLevel) { 'CRITICAL' { 2 } 'HIGH' { 1 } 'MEDIUM' { 0 } 'LOW' { -1 } default { 0 } } # Build concise push message from threat summary $critCount = @($provThreats | Where-Object ThreatLevel -eq 'CRITICAL').Count $highCount = @($provThreats | Where-Object ThreatLevel -eq 'HIGH').Count $pushLines = @("$($provThreats.Count) threat(s) detected") if ($critCount) { $pushLines += "$critCount CRITICAL" } if ($highCount) { $pushLines += "$highCount HIGH" } $top3 = $provThreats | Sort-Object { $levelOrder[$_.ThreatLevel] } -Descending | Select-Object -First 3 foreach ($t in $top3) { $id = if ($t.Email) { $t.Email } elseif ($t.Entity) { $t.Entity } else { $t.Description } $pushLines += "<b>$($t.ThreatLevel)</b> $id" } $pushParams = @{ ApiToken = $po.apiToken UserKey = $po.userKey Message = ($pushLines -join "`n") Title = $subject Priority = $pushPriority } if ($po.sound) { $pushParams['Sound'] = $po.sound } if ($pushPriority -eq 2) { $pushParams['Retry'] = $po.retry ?? 60 $pushParams['Expire'] = $po.expire ?? 3600 } $result = Send-SignalPushover @pushParams $allResults.Add($result) Write-Verbose "Pushover: $($result.Message)" } else { Write-Warning 'Pushover enabled but missing apiToken or userKey.' } } } # Syslog if ('Syslog' -in $enabledProviders) { $provThreats = & $filterThreats 'syslog' $threats if ($provThreats.Count -gt 0) { $sy = $config.alerting.providers.syslog if ($sy.server) { $result = Send-SignalSyslog ` -Server $sy.server ` -Port ($sy.port ?? 514) ` -Protocol ($sy.protocol ?? 'UDP') ` -Format ($sy.format ?? 'CEF') ` -Threats $provThreats ` -Subject $subject ` -Facility ($sy.facility ?? 1) $allResults.Add($result) Write-Verbose "Syslog: $($result.Message)" } else { Write-Warning 'Syslog enabled but missing server.' } } } # EventLog if ('EventLog' -in $enabledProviders) { $provThreats = & $filterThreats 'eventlog' $threats if ($provThreats.Count -gt 0) { $el = $config.alerting.providers.eventlog $result = Send-SignalEventLog ` -Threats $provThreats ` -Subject $subject ` -Source ($el.source ?? 'PSGuerrilla') ` -LogName ($el.logName ?? 'Application') $allResults.Add($result) Write-Verbose "EventLog: $($result.Message)" } } $anySuccess = @($allResults | Where-Object Success).Count -gt 0 # --- Save dedup history for successfully sent alerts --- if ($anySuccess -and $config.alerting.suppression.enabled) { foreach ($t in $threats) { $dedup = Get-AlertDeduplication -Threat $t -SuppressionHours $suppressionHours Save-AlertHistory -DedupKey $dedup.DedupKey -Email $dedup.Email -ThreatLevel $dedup.ThreatLevel } } # --- Trigger escalation check --- if ($config.alerting.escalation.enabled) { try { $escResult = Invoke-AlertEscalation -Config $config -ScanResult $ScanResult if ($escResult.Escalated -gt 0) { $allResults.Add($escResult) Write-Verbose "Escalation: $($escResult.Message)" } } catch { Write-Verbose "Escalation check failed: $_" } } return [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.AlertResult' Sent = $anySuccess Reason = if ($anySuccess) { 'Alerts dispatched' } else { 'All providers failed' } Results = @($allResults) } } } |