Public/Send-SignalDigest.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-SignalDigest { <# .SYNOPSIS Sends an aggregated digest report of recent threat activity. .DESCRIPTION Reads recent state files, aggregates threat counts and scores, computes deltas from the previous digest, and dispatches a summary via configured providers. .PARAMETER Period Digest period: Daily or Weekly. Default: Daily. .PARAMETER Providers Specific providers to dispatch the digest through. If not specified, uses providers listed in config alerting.digest.providers. .PARAMETER ConfigPath Override config file path. .PARAMETER Force Bypass the digest interval check and send immediately. #> [CmdletBinding()] param( [ValidateSet('Daily', 'Weekly')] [string]$Period = 'Daily', [string[]]$Providers, [Alias('RuntimeConfig')] [string]$ConfigPath, [switch]$Force ) $cfgPath = if ($ConfigPath) { $ConfigPath } else { $script:ConfigPath } $config = $null if (Test-Path $cfgPath) { $config = Get-Content -Path $cfgPath -Raw | ConvertFrom-Json -AsHashtable } if (-not $config) { Write-Warning 'No configuration found. Run Set-Safehouse first.' return [PSCustomObject]@{ Provider = 'Digest' Success = $false Message = 'No config' Error = 'Configuration not found' } } $dataDir = Get-PSGuerrillaDataRoot $digestHistoryPath = Join-Path $dataDir 'digest-history.json' # Check if digest interval has elapsed if (-not $Force -and (Test-Path $digestHistoryPath)) { $history = Get-Content -Path $digestHistoryPath -Raw | ConvertFrom-Json -AsHashtable $lastSent = if ($history.lastSent) { [datetime]::Parse($history.lastSent) } else { [datetime]::MinValue } $intervalHours = if ($Period -eq 'Weekly') { 168 } else { 24 } $nextDue = $lastSent.AddHours($intervalHours) if ([datetime]::UtcNow -lt $nextDue) { Write-Verbose "Digest not due until $($nextDue.ToString('o')). Use -Force to override." return [PSCustomObject]@{ Provider = 'Digest' Success = $false Message = "Digest not due until $($nextDue.ToString('yyyy-MM-dd HH:mm')) UTC" Error = $null } } } # Collect state files $stateFiles = @() if (Test-Path $dataDir) { $stateFiles = @(Get-ChildItem -Path $dataDir -Filter '*.state.json' -ErrorAction SilentlyContinue) } # Determine time window $windowHours = if ($Period -eq 'Weekly') { 168 } else { 24 } $cutoff = [datetime]::UtcNow.AddHours(-$windowHours) # Aggregate data from state files $totalScans = 0 $totalThreats = 0 $criticalTotal = 0 $highTotal = 0 $mediumTotal = 0 $lowTotal = 0 $theatersActive = [System.Collections.Generic.HashSet[string]]::new() foreach ($file in $stateFiles) { try { $state = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json -AsHashtable $stateTime = if ($state.timestamp) { [datetime]::Parse($state.timestamp) } else { $file.LastWriteTimeUtc } if ($stateTime -lt $cutoff) { continue } $totalScans++ if ($state.theater) { $theatersActive.Add($state.theater) | Out-Null } $criticalTotal += ($state.criticalCount ?? 0) $highTotal += ($state.highCount ?? 0) $mediumTotal += ($state.mediumCount ?? 0) $lowTotal += ($state.lowCount ?? 0) $totalThreats += ($state.criticalCount ?? 0) + ($state.highCount ?? 0) + ($state.mediumCount ?? 0) + ($state.lowCount ?? 0) } catch { Write-Verbose "Failed to parse state file $($file.Name): $_" } } # Load previous digest for delta $previousThreats = 0 if (Test-Path $digestHistoryPath) { try { $prevDigest = Get-Content -Path $digestHistoryPath -Raw | ConvertFrom-Json -AsHashtable $previousThreats = $prevDigest.totalThreats ?? 0 } catch { } } $delta = $totalThreats - $previousThreats $trend = if ($delta -gt 0) { "+$delta" } elseif ($delta -lt 0) { "$delta" } else { 'no change' } # Build digest content $subject = "[PSGuerrilla] $Period Digest - $totalThreats threat(s) ($trend)" $textBody = @" PSGuerrilla $Period Security Digest ======================================== Period: Last $windowHours hours Scans completed: $totalScans Theaters active: $($theatersActive.Count) ($($theatersActive -join ', ')) Threat Summary: CRITICAL: $criticalTotal HIGH: $highTotal MEDIUM: $mediumTotal LOW: $lowTotal TOTAL: $totalThreats ($trend from previous digest) Generated: $([datetime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss')) UTC PSGuerrilla v2.1.0 "@ $htmlBody = @" <html><body style="font-family:Consolas,monospace;background:#1a1a1a;color:#ffd7af;padding:20px;"> <h2 style="color:#afaf5f;">PSGuerrilla $Period Digest</h2> <p>Period: Last $windowHours hours | Scans: $totalScans | Theaters: $($theatersActive -join ', ')</p> <table style="border-collapse:collapse;margin:10px 0;"> <tr><td style="color:#af0000;padding:4px 12px;">CRITICAL</td><td style="color:#fff;padding:4px 12px;">$criticalTotal</td></tr> <tr><td style="color:#d75f00;padding:4px 12px;">HIGH</td><td style="color:#fff;padding:4px 12px;">$highTotal</td></tr> <tr><td style="color:#ff8700;padding:4px 12px;">MEDIUM</td><td style="color:#fff;padding:4px 12px;">$mediumTotal</td></tr> <tr><td style="color:#d7af5f;padding:4px 12px;">LOW</td><td style="color:#fff;padding:4px 12px;">$lowTotal</td></tr> <tr style="border-top:1px solid #585858;"><td style="color:#afaf5f;padding:4px 12px;font-weight:bold;">TOTAL</td><td style="color:#fff;padding:4px 12px;font-weight:bold;">$totalThreats ($trend)</td></tr> </table> <p style="color:#585858;font-size:0.85em;">Generated $([datetime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss')) UTC | PSGuerrilla v2.1.0</p> </body></html> "@ # Determine providers $digestProviders = if ($Providers) { $Providers } elseif ($config.alerting.digest.providers) { @($config.alerting.digest.providers) } else { # Fall back to all enabled providers $ep = @() if ($config.alerting.providers.sendgrid.enabled) { $ep += 'SendGrid' } if ($config.alerting.providers.teams.enabled) { $ep += 'Teams' } if ($config.alerting.providers.slack.enabled) { $ep += 'Slack' } if ($config.alerting.providers.webhook.enabled) { $ep += 'Webhook' } $ep } if ($digestProviders.Count -eq 0) { Write-Warning 'No digest providers configured.' return [PSCustomObject]@{ Provider = 'Digest' Success = $false Message = 'No digest providers configured' Error = $null } } $allResults = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($prov in $digestProviders) { 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 $htmlBody -TextBody $textBody ` -FromName ($sg.fromName ?? 'PSGuerrilla Digest') $allResults.Add($r) } } 'Teams' { $tm = $config.alerting.providers.teams if ($tm.webhookUrl) { # Build a minimal threat-like object for Teams formatting $digestThreat = [PSCustomObject]@{ Email = 'Digest Summary' ThreatLevel = if ($criticalTotal -gt 0) { 'CRITICAL' } elseif ($highTotal -gt 0) { 'HIGH' } else { 'MEDIUM' } ThreatScore = $totalThreats Indicators = @("$criticalTotal critical, $highTotal high, $mediumTotal medium, $lowTotal low ($trend)") } $r = Send-SignalTeams -WebhookUrl $tm.webhookUrl -Subject $subject -Threats @($digestThreat) $allResults.Add($r) } } 'Slack' { $sl = $config.alerting.providers.slack if ($sl.webhookUrl) { $digestThreat = [PSCustomObject]@{ Email = 'Digest Summary' ThreatLevel = if ($criticalTotal -gt 0) { 'CRITICAL' } elseif ($highTotal -gt 0) { 'HIGH' } else { 'MEDIUM' } ThreatScore = $totalThreats Indicators = @("$criticalTotal critical, $highTotal high, $mediumTotal medium, $lowTotal low ($trend)") } $r = Send-SignalSlack -WebhookUrl $sl.webhookUrl -Subject $subject -Threats @($digestThreat) -TextBody $textBody $allResults.Add($r) } } 'Webhook' { $wh = $config.alerting.providers.webhook if ($wh.url) { $whHeaders = @{} if ($wh.headers) { foreach ($key in $wh.headers.Keys) { $whHeaders[$key] = $wh.headers[$key] } } $digestPayload = [PSCustomObject]@{ Email = 'Digest Summary' ThreatLevel = if ($criticalTotal -gt 0) { 'CRITICAL' } elseif ($highTotal -gt 0) { 'HIGH' } else { 'MEDIUM' } ThreatScore = $totalThreats Indicators = @("$criticalTotal critical, $highTotal high, $mediumTotal medium, $lowTotal low ($trend)") } $digestScanResult = [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.DigestResult' ScanId = "digest-$([datetime]::UtcNow.ToString('yyyyMMddHHmmss'))" Timestamp = [datetime]::UtcNow CriticalCount = $criticalTotal HighCount = $highTotal MediumCount = $mediumTotal LowCount = $lowTotal FlaggedUsers = @($digestPayload) } $r = Send-SignalWebhook -WebhookUrl $wh.url -Threats @($digestPayload) -ScanResult $digestScanResult -Headers $whHeaders -AuthToken ($wh.authToken ?? '') $allResults.Add($r) } } } } # Save digest history $digestState = @{ lastSent = [datetime]::UtcNow.ToString('o') period = $Period totalThreats = $totalThreats criticalCount = $criticalTotal highCount = $highTotal mediumCount = $mediumTotal lowCount = $lowTotal scansIncluded = $totalScans } if (-not (Test-Path $dataDir)) { New-Item -Path $dataDir -ItemType Directory -Force | Out-Null } $digestState | ConvertTo-Json -Depth 5 | Set-Content -Path $digestHistoryPath -Encoding UTF8 $anySuccess = @($allResults | Where-Object Success).Count -gt 0 return [PSCustomObject]@{ Provider = 'Digest' Success = $anySuccess Message = "Digest ($Period): $(@($allResults | Where-Object Success).Count)/$($allResults.Count) providers dispatched - $totalThreats threats ($trend)" Error = if (-not $anySuccess -and $allResults.Count -gt 0) { ($allResults | Where-Object { -not $_.Success } | Select-Object -First 1).Error } else { $null } Details = @($allResults) } } |