Files
user-system/scripts/ops/drill-alertmanager-live-delivery.ps1

437 lines
13 KiB
PowerShell

param(
[string]$EvidenceDate = (Get-Date -Format 'yyyy-MM-dd'),
[string]$EnvFilePath = '',
[int]$TimeoutSeconds = 20,
[switch]$DisableSsl
)
$ErrorActionPreference = 'Stop'
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$evidenceRoot = Join-Path $projectRoot "docs\evidence\ops\$EvidenceDate\alerting"
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$drillRoot = Join-Path $evidenceRoot $timestamp
$sanitizedConfigPath = Join-Path $drillRoot 'alertmanager.live.redacted.yaml'
$reportPath = Join-Path $drillRoot 'ALERTMANAGER_LIVE_DELIVERY_DRILL.md'
$tempRenderedPath = Join-Path ([System.IO.Path]::GetTempPath()) ("alertmanager-live-" + [System.Guid]::NewGuid().ToString('N') + '.yaml')
$requiredVariables = @(
'ALERTMANAGER_DEFAULT_TO',
'ALERTMANAGER_CRITICAL_TO',
'ALERTMANAGER_WARNING_TO',
'ALERTMANAGER_FROM',
'ALERTMANAGER_SMARTHOST',
'ALERTMANAGER_AUTH_USERNAME',
'ALERTMANAGER_AUTH_PASSWORD'
)
New-Item -ItemType Directory -Force $evidenceRoot, $drillRoot | Out-Null
function Import-EnvFileToProcess {
param(
[Parameter(Mandatory = $true)][string]$Path
)
$saved = @()
foreach ($rawLine in Get-Content $Path -Encoding UTF8) {
$line = $rawLine.Trim()
if ($line -eq '' -or $line.StartsWith('#')) {
continue
}
$parts = $line -split '=', 2
if ($parts.Count -ne 2) {
throw "invalid env line: $line"
}
$name = $parts[0].Trim()
$value = $parts[1].Trim()
$existing = [Environment]::GetEnvironmentVariable($name, 'Process')
$saved += [pscustomobject]@{
Name = $name
HadValue = -not [string]::IsNullOrEmpty($existing)
Value = $existing
}
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
}
return $saved
}
function Restore-ProcessEnv {
param(
[Parameter(Mandatory = $true)][object[]]$SavedState
)
foreach ($entry in $SavedState) {
if ($entry.HadValue) {
[Environment]::SetEnvironmentVariable($entry.Name, $entry.Value, 'Process')
continue
}
Remove-Item ("Env:" + $entry.Name) -ErrorAction SilentlyContinue
}
}
function Get-ConfiguredValues {
param(
[Parameter(Mandatory = $true)][string[]]$Names
)
$values = @{}
foreach ($name in $Names) {
$values[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
}
return $values
}
function Get-PlaceholderFindings {
param(
[Parameter(Mandatory = $true)][hashtable]$Values
)
$findings = @()
foreach ($entry in $Values.GetEnumerator()) {
$name = $entry.Key
$value = [string]$entry.Value
if ([string]::IsNullOrWhiteSpace($value)) {
continue
}
if ($value -match '\$\{[A-Z0-9_]+\}') {
$findings += "$name contains unresolved placeholder syntax"
}
if ($value -match '(?i)\bexample\.(com|org)\b') {
$findings += "$name still uses example domain"
}
if ($name -like '*PASSWORD' -and $value -match '(?i)^(replace-with-secret|synthetic-secret-for-render-drill|password)$') {
$findings += "$name still uses placeholder secret"
}
}
return $findings
}
function Parse-Smarthost {
param(
[Parameter(Mandatory = $true)][string]$Value
)
$match = [regex]::Match($Value, '^(?<host>\[[^\]]+\]|[^:]+)(:(?<port>\d+))?$')
if (-not $match.Success) {
throw "invalid ALERTMANAGER_SMARTHOST value: $Value"
}
$host = $match.Groups['host'].Value.Trim('[', ']')
$port = if ($match.Groups['port'].Success) { [int]$match.Groups['port'].Value } else { 25 }
return [pscustomobject]@{
Host = $host
Port = $port
Raw = $Value
}
}
function Split-Recipients {
param(
[Parameter(Mandatory = $true)][string]$Value
)
return @(
$Value -split '[,;]' |
ForEach-Object { $_.Trim() } |
Where-Object { $_ -ne '' }
)
}
function Mask-EmailList {
param(
[Parameter(Mandatory = $true)][string]$Value
)
$masked = @()
foreach ($recipient in Split-Recipients -Value $Value) {
if ($recipient -notmatch '^(?<local>[^@]+)@(?<domain>.+)$') {
$masked += '***REDACTED***'
continue
}
$local = $Matches['local']
$domain = $Matches['domain']
$prefix = if ($local.Length -gt 0) { $local.Substring(0, 1) } else { '*' }
$masked += ($prefix + '***@' + $domain)
}
return $masked -join ', '
}
function Mask-Host {
param(
[Parameter(Mandatory = $true)][string]$Value
)
if ([string]::IsNullOrWhiteSpace($Value)) {
return '***REDACTED_HOST***'
}
if ($Value.Length -le 3) {
return ($Value.Substring(0, 1) + '**')
}
return ($Value.Substring(0, 1) + '***' + $Value.Substring($Value.Length - 2))
}
function Test-TcpConnectivity {
param(
[Parameter(Mandatory = $true)][string]$Host,
[Parameter(Mandatory = $true)][int]$Port,
[Parameter(Mandatory = $true)][int]$TimeoutSeconds
)
$client = New-Object System.Net.Sockets.TcpClient
try {
$asyncResult = $client.BeginConnect($Host, $Port, $null, $null)
if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutSeconds * 1000, $false)) {
throw "tcp connect timeout after ${TimeoutSeconds}s"
}
$client.EndConnect($asyncResult)
return [pscustomobject]@{
Succeeded = $true
Error = ''
}
} catch {
return [pscustomobject]@{
Succeeded = $false
Error = $_.Exception.Message
}
} finally {
$client.Dispose()
}
}
function Send-SmtpMessage {
param(
[Parameter(Mandatory = $true)][pscustomobject]$Smarthost,
[Parameter(Mandatory = $true)][string]$From,
[Parameter(Mandatory = $true)][string]$To,
[Parameter(Mandatory = $true)][string]$Username,
[Parameter(Mandatory = $true)][string]$Password,
[Parameter(Mandatory = $true)][string]$RouteName,
[Parameter(Mandatory = $true)][int]$TimeoutSeconds,
[Parameter(Mandatory = $true)][bool]$EnableSsl
)
$message = [System.Net.Mail.MailMessage]::new()
$smtp = [System.Net.Mail.SmtpClient]::new($Smarthost.Host, $Smarthost.Port)
try {
$message.From = [System.Net.Mail.MailAddress]::new($From)
foreach ($recipient in Split-Recipients -Value $To) {
$message.To.Add($recipient)
}
$message.Subject = "[ALERTING-LIVE-DRILL][$RouteName] $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
$message.Body = @"
This is a live alert delivery drill.
Route: $RouteName
Project: $projectRoot
GeneratedAt: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')
"@
$smtp.EnableSsl = $EnableSsl
$smtp.Timeout = $TimeoutSeconds * 1000
$smtp.DeliveryMethod = [System.Net.Mail.SmtpDeliveryMethod]::Network
$smtp.UseDefaultCredentials = $false
$smtp.Credentials = [System.Net.NetworkCredential]::new($Username, $Password)
$smtp.Send($message)
return [pscustomobject]@{
Route = $RouteName
RecipientMask = Mask-EmailList -Value $To
Accepted = $true
Error = ''
}
} catch {
return [pscustomobject]@{
Route = $RouteName
RecipientMask = Mask-EmailList -Value $To
Accepted = $false
Error = $_.Exception.Message
}
} finally {
$message.Dispose()
$smtp.Dispose()
}
}
function Get-RedactedRenderedConfig {
param(
[Parameter(Mandatory = $true)][string]$RenderedContent,
[Parameter(Mandatory = $true)][hashtable]$Values
)
$redacted = $RenderedContent
$replacementMap = @{
'ALERTMANAGER_DEFAULT_TO' = '***REDACTED_DEFAULT_TO***'
'ALERTMANAGER_CRITICAL_TO' = '***REDACTED_CRITICAL_TO***'
'ALERTMANAGER_WARNING_TO' = '***REDACTED_WARNING_TO***'
'ALERTMANAGER_FROM' = '***REDACTED_FROM***'
'ALERTMANAGER_SMARTHOST' = '***REDACTED_SMARTHOST***'
'ALERTMANAGER_AUTH_USERNAME' = '***REDACTED_AUTH_USERNAME***'
'ALERTMANAGER_AUTH_PASSWORD' = '***REDACTED_AUTH_PASSWORD***'
}
foreach ($entry in $replacementMap.GetEnumerator()) {
$value = [string]$Values[$entry.Key]
if ([string]::IsNullOrWhiteSpace($value)) {
continue
}
$redacted = [regex]::Replace($redacted, [regex]::Escape($value), [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $entry.Value })
}
return $redacted
}
$savedEnvState = @()
$values = @{}
$missingVariables = @()
$placeholderFindings = @()
$renderSucceeded = $false
$tcpResult = [pscustomobject]@{ Succeeded = $false; Error = 'not-run' }
$sendResults = @()
$success = $false
$failureReason = ''
$smarthost = $null
$envSource = if ([string]::IsNullOrWhiteSpace($EnvFilePath)) { 'process environment' } else { $EnvFilePath }
try {
if (-not [string]::IsNullOrWhiteSpace($EnvFilePath)) {
if (-not (Test-Path $EnvFilePath)) {
throw "env file not found: $EnvFilePath"
}
$savedEnvState = Import-EnvFileToProcess -Path $EnvFilePath
}
$values = Get-ConfiguredValues -Names $requiredVariables
$missingVariables = @(
$requiredVariables |
Where-Object { [string]::IsNullOrWhiteSpace([string]$values[$_]) }
)
$placeholderFindings = Get-PlaceholderFindings -Values $values
if ($missingVariables.Count -gt 0) {
throw "missing required alertmanager variables: $($missingVariables -join ', ')"
}
if ($placeholderFindings.Count -gt 0) {
throw "placeholder or example values detected"
}
$smarthost = Parse-Smarthost -Value ([string]$values['ALERTMANAGER_SMARTHOST'])
& (Join-Path $PSScriptRoot 'render-alertmanager-config.ps1') `
-TemplatePath (Join-Path $projectRoot 'deployment\alertmanager\alertmanager.yml') `
-OutputPath $tempRenderedPath `
-EnvFilePath $EnvFilePath | Out-Null
$renderedContent = Get-Content $tempRenderedPath -Raw -Encoding UTF8
$redactedContent = Get-RedactedRenderedConfig -RenderedContent $renderedContent -Values $values
Set-Content -Path $sanitizedConfigPath -Value $redactedContent -Encoding UTF8
$renderSucceeded = $true
$tcpResult = Test-TcpConnectivity -Host $smarthost.Host -Port $smarthost.Port -TimeoutSeconds $TimeoutSeconds
if (-not $tcpResult.Succeeded) {
throw "smtp tcp connectivity failed: $($tcpResult.Error)"
}
$routes = @(
[pscustomobject]@{ Name = 'default'; To = [string]$values['ALERTMANAGER_DEFAULT_TO'] }
[pscustomobject]@{ Name = 'critical-alerts'; To = [string]$values['ALERTMANAGER_CRITICAL_TO'] }
[pscustomobject]@{ Name = 'warning-alerts'; To = [string]$values['ALERTMANAGER_WARNING_TO'] }
)
foreach ($route in $routes) {
$sendResults += Send-SmtpMessage `
-Smarthost $smarthost `
-From ([string]$values['ALERTMANAGER_FROM']) `
-To $route.To `
-Username ([string]$values['ALERTMANAGER_AUTH_USERNAME']) `
-Password ([string]$values['ALERTMANAGER_AUTH_PASSWORD']) `
-RouteName $route.Name `
-TimeoutSeconds $TimeoutSeconds `
-EnableSsl (-not $DisableSsl.IsPresent)
}
$failedRoutes = @($sendResults | Where-Object { -not $_.Accepted })
if ($failedRoutes.Count -gt 0) {
throw "smtp send failed for route(s): $($failedRoutes.Route -join ', ')"
}
$success = $true
} catch {
$failureReason = $_.Exception.Message
} finally {
if (Test-Path $tempRenderedPath) {
Remove-Item $tempRenderedPath -Force -ErrorAction SilentlyContinue
}
if ($savedEnvState.Count -gt 0) {
Restore-ProcessEnv -SavedState $savedEnvState
}
$reportLines = @(
'# Alertmanager Live Delivery Drill',
'',
"- Generated at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')",
"- Template file: $(Join-Path $projectRoot 'deployment\alertmanager\alertmanager.yml')",
"- Env source: $envSource",
"- Redacted rendered config: $(if (Test-Path $sanitizedConfigPath) { $sanitizedConfigPath } else { 'not-generated' })",
'',
'## Strict Preconditions',
'',
"- Required variables present: $($missingVariables.Count -eq 0)",
"- Placeholder/example-value findings: $(if ($placeholderFindings.Count -gt 0) { $placeholderFindings -join '; ' } else { 'none' })",
"- Render path succeeded: $renderSucceeded",
'',
'## Delivery Attempt',
'',
"- SMTP host: $(if ($smarthost) { (Mask-Host -Value $smarthost.Host) } else { 'unparsed' })",
"- SMTP port: $(if ($smarthost) { $smarthost.Port } else { 'unparsed' })",
"- TLS enabled: $(-not $DisableSsl.IsPresent)",
"- TCP connectivity succeeded: $($tcpResult.Succeeded)",
"- TCP connectivity error: $(if ($tcpResult.Error) { $tcpResult.Error } else { 'none' })",
''
)
if ($sendResults.Count -gt 0) {
$reportLines += '## Route Results'
$reportLines += ''
foreach ($result in $sendResults) {
$reportLines += "- Route $($result.Route): accepted=$($result.Accepted), recipients=$($result.RecipientMask), error=$(if ([string]::IsNullOrWhiteSpace($result.Error)) { 'none' } else { $result.Error })"
}
$reportLines += ''
}
$reportLines += '## Conclusion'
$reportLines += ''
$reportLines += "- Live external delivery closed: $success"
$reportLines += "- Failure reason: $(if ([string]::IsNullOrWhiteSpace($failureReason)) { 'none' } else { $failureReason })"
$reportLines += '- This drill fails closed on unresolved placeholders, example domains, and placeholder secrets.'
$reportLines += '- The evidence intentionally stores only redacted config output and masked recipient information.'
$reportLines += '- A successful run proves real secret injection plus SMTP server acceptance for the configured on-call routes; it does not by itself prove downstream human acknowledgment.'
$reportLines += ''
Set-Content -Path $reportPath -Value ($reportLines -join [Environment]::NewLine) -Encoding UTF8
Get-Content $reportPath
}
if (-not $success) {
throw "alertmanager live delivery drill failed: $failureReason"
}