Files
RDP-login-monitor/Sac-Client.ps1
T

587 lines
20 KiB
PowerShell

<#
.SYNOPSIS
Клиент Security Alert Center для RDP-login-monitor.
.DESCRIPTION
Dot-source после login_monitor.settings.ps1 и функции Write-Log.
Ожидает: $UseSAC, $SacUrl, $SacApiKey, $ScriptVersion, $script:InstallRoot.
Release: 1.2.12-SAC (fix null+JSON body from pipeline; no ConvertFrom-Json on ingest).
#>
function Test-SacIngestAcceptedStatus {
param([int]$StatusCode)
return ($StatusCode -in 201, 409, 202)
}
function Complete-SacIngestSuccess {
param(
[string]$EventId,
[string]$EventType,
[int]$StatusCode
)
Remove-SacSpoolFile -EventId $EventId
Reset-SacFailCount
if ($StatusCode -eq 409) {
Write-SacLog "SAC: duplicate OK (HTTP 409) event_id=$EventId type=$EventType"
} else {
Write-SacLog "SAC: accepted event_id=$EventId type=$EventType (HTTP $StatusCode)"
}
}
function Get-SacUtf8Bytes {
param([Parameter(Mandatory = $true)][string]$Text)
$t = Get-SacSingleString -Value $Text -Label 'request body'
if ($t.StartsWith([char]0xFEFF)) { $t = $t.Substring(1) }
# PowerShell иногда склеивает $null и JSON в "null{...}" — ломает FastAPI json parser
if ($t.Length -gt 5 -and $t.StartsWith('null', [System.StringComparison]::Ordinal) -and $t[4] -eq '{') {
Write-SacLog 'WARN: SAC stripped accidental null prefix before JSON body'
$t = $t.Substring(4)
}
return (New-Object System.Text.UTF8Encoding $false).GetBytes($t)
}
function Write-SacLog {
param([string]$Message)
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
[void](Write-Log $Message)
} else {
Write-Host $Message
}
}
function Get-SacSingleString {
param($Value, [string]$Label)
if ($null -eq $Value) { return '' }
if ($Value -is [string]) { return $Value }
if ($Value -is [System.Array]) {
$parts = @($Value | Where-Object { $null -ne $_ -and $_ -is [string] -and $_.Length -gt 0 })
if ($parts.Count -gt 0) {
if ($parts.Count -gt 1) {
Write-SacLog "WARN: SAC $Label had $($parts.Count) string parts; using last"
}
return [string]$parts[-1]
}
}
return [string]$Value
}
function Get-SacNormalizedMode {
$m = if ($null -ne $UseSAC) { [string]$UseSAC } else { 'off' }
return $m.Trim().ToLowerInvariant()
}
function Test-SacConfigured {
return (-not [string]::IsNullOrWhiteSpace($SacUrl)) -and (-not [string]::IsNullOrWhiteSpace($SacApiKey))
}
function Get-SacBaseUrl {
if ([string]::IsNullOrWhiteSpace($SacUrl)) { return $null }
$url = $SacUrl.Trim().TrimEnd('/')
if ($url -match '/api/v1/events$') {
$url = $url -replace '/api/v1/events$', ''
}
return $url.TrimEnd('/')
}
function Get-SacIngestUrl {
$base = Get-SacBaseUrl
if ([string]::IsNullOrWhiteSpace($base)) { return $null }
return "$base/api/v1/events"
}
function Get-SacSpoolDirResolved {
if (-not [string]::IsNullOrWhiteSpace($SacSpoolDir)) {
return $SacSpoolDir.Trim()
}
return (Join-Path $script:InstallRoot 'sac-spool')
}
function Get-SacAgentIdFileResolved {
if (-not [string]::IsNullOrWhiteSpace($SacAgentIdFile)) {
return $SacAgentIdFile.Trim()
}
return (Join-Path $script:InstallRoot 'agent_instance_id')
}
function Get-SacFailCountFileResolved {
return (Join-Path $script:InstallRoot 'sac-fail.count')
}
function Get-SacAgentInstanceId {
$idFile = Get-SacAgentIdFileResolved
$dir = Split-Path -Parent $idFile
if (-not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
if (Test-Path -LiteralPath $idFile) {
$existing = (Get-Content -LiteralPath $idFile -TotalCount 1 -ErrorAction SilentlyContinue)
if (-not [string]::IsNullOrWhiteSpace($existing)) {
return $existing.Trim()
}
}
$newId = [guid]::NewGuid().ToString()
try {
Set-Content -LiteralPath $idFile -Value $newId -Encoding UTF8 -NoNewline
} catch { }
return $newId
}
function Get-SacOccurredAtIso {
return [DateTimeOffset]::Now.ToString('yyyy-MM-ddTHH:mm:ss.fffK')
}
function Convert-AnyToJsonSerializable {
param($Value)
if ($null -eq $Value) { return $null }
if ($Value -is [string] -or $Value -is [bool] -or $Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) {
return $Value
}
if ($Value -is [hashtable] -or $Value -is [System.Collections.IDictionary]) {
$out = @{}
foreach ($key in $Value.Keys) {
$out[[string]$key] = Convert-AnyToJsonSerializable $Value[$key]
}
return $out
}
if ($Value -is [System.Collections.IEnumerable]) {
$list = New-Object System.Collections.Generic.List[object]
foreach ($item in $Value) {
$list.Add((Convert-AnyToJsonSerializable $item)) | Out-Null
}
return $list
}
return [string]$Value
}
function ConvertTo-SacJsonText {
param([Parameter(Mandatory = $true)]$Payload)
$serializable = Convert-AnyToJsonSerializable $Payload
try {
Add-Type -AssemblyName System.Web.Extensions -ErrorAction Stop
$ser = New-Object System.Web.Script.Serialization.JavaScriptSerializer
$ser.MaxJsonLength = 16777216
$ser.RecursionLimit = 32
return $ser.Serialize($serializable)
} catch {
Write-SacLog "WARN: JavaScriptSerializer unavailable, fallback ConvertTo-Json ($($_.Exception.Message))"
return ($serializable | ConvertTo-Json -Depth 12 -Compress)
}
}
function Get-SacCategoryForType {
param([string]$EventType)
if ($EventType -match '^agent\.') { return 'agent' }
if ($EventType -match '^(ssh\.|auth\.|rdp\.)') { return 'auth' }
if ($EventType -match '^privilege\.') { return 'privilege' }
if ($EventType -match '^session\.') { return 'session' }
if ($EventType -match '^report\.') { return 'report' }
if ($EventType -match '^rdg\.') { return 'network' }
return 'agent'
}
function Limit-SacString {
param(
[string]$Text,
[int]$MaxLen,
[string]$Label
)
if ([string]::IsNullOrEmpty($Text)) { return '' }
if ($Text.Length -le $MaxLen) { return $Text }
Write-SacLog "WARN: SAC truncate $Label $($Text.Length) -> $MaxLen chars (event schema limit)"
return $Text.Substring(0, $MaxLen)
}
function Get-SacHttpErrorBody {
param($ErrorRecord)
if ($null -ne $ErrorRecord -and $null -ne $ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) {
return [string]$ErrorRecord.ErrorDetails.Message
}
$ex = if ($null -ne $ErrorRecord) { $ErrorRecord.Exception } else { $null }
try {
if ($null -eq $ex -or $null -eq $ex.Response) { return '' }
$stream = $ex.Response.GetResponseStream()
if ($null -eq $stream) { return '' }
$reader = New-Object System.IO.StreamReader($stream)
$body = $reader.ReadToEnd()
$reader.Close()
return $body
} catch {
return ''
}
}
function New-SacEventPayload {
param(
[Parameter(Mandatory = $true)][string]$EventType,
[Parameter(Mandatory = $true)][string]$Severity,
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Summary,
[hashtable]$Details = $null
)
$Title = Limit-SacString -Text $Title -MaxLen 256 -Label 'title'
$Summary = Limit-SacString -Text $Summary -MaxLen 8192 -Label 'summary'
$payload = [ordered]@{
schema_version = '1.0'
event_id = [guid]::NewGuid().ToString()
occurred_at = (Get-SacOccurredAtIso)
source = [ordered]@{
product = 'rdp-login-monitor'
product_version = if ($ScriptVersion) { [string]$ScriptVersion } else { 'unknown' }
agent_instance_id = Get-SacAgentInstanceId
}
host = [ordered]@{
hostname = $env:COMPUTERNAME
os_family = 'windows'
}
category = (Get-SacCategoryForType -EventType $EventType)
type = $EventType
severity = $Severity
title = $Title
summary = $Summary
}
if ($null -ne $Details -and $Details.Count -gt 0) {
$payload.details = $Details
}
return $payload
}
function Get-SacFailCount {
$f = Get-SacFailCountFileResolved
if (-not (Test-Path -LiteralPath $f)) { return 0 }
$raw = (Get-Content -LiteralPath $f -TotalCount 1 -ErrorAction SilentlyContinue) -replace '\D', ''
if ([string]::IsNullOrWhiteSpace($raw)) { return 0 }
return [int]$raw
}
function Set-SacFailCount {
param([int]$Count)
$f = Get-SacFailCountFileResolved
$dir = Split-Path -Parent $f
if (-not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Set-Content -LiteralPath $f -Value ([string]$Count) -Encoding UTF8 -NoNewline
}
function Reset-SacFailCount {
Set-SacFailCount -Count 0
}
function Test-SacShouldAttemptSend {
$mode = Get-SacNormalizedMode
if ($mode -ne 'fallback') { return $true }
$max = if ($SacFallbackFailures) { [int]$SacFallbackFailures } else { 5 }
$n = Get-SacFailCount
if ($n -lt $max) { return $true }
if (Test-SacHealth) {
Reset-SacFailCount
Write-SacLog 'SAC: /health OK, resuming POST (fallback)'
return $true
}
Write-SacLog "WARN: SAC fallback: skip POST ($n>=$max failures), local channels only"
return $false
}
function Invoke-SacTlsPrep {
if (-not $SacTlsSkipVerify) { return }
if (-not $script:SacTlsCallbackRegistered) {
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
$script:SacTlsCallbackRegistered = $true
}
}
function Test-SacHealth {
if (-not (Test-SacConfigured)) { return $false }
$base = Get-SacBaseUrl
if ([string]::IsNullOrWhiteSpace($base)) { return $false }
$timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 }
try {
Invoke-SacTlsPrep
$resp = Invoke-WebRequest -Uri "$base/health" -Method Get -UseBasicParsing -TimeoutSec $timeout
return ($resp.StatusCode -eq 200)
} catch {
return $false
}
}
function Write-SacSpoolFile {
param(
[string]$EventId,
[string]$JsonBody
)
$dir = Get-SacSpoolDirResolved
if (-not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
$path = Join-Path $dir "$EventId.json"
[System.IO.File]::WriteAllText($path, $JsonBody, (New-Object System.Text.UTF8Encoding $false))
}
function Remove-SacSpoolFile {
param([string]$EventId)
$path = Join-Path (Get-SacSpoolDirResolved) "$EventId.json"
if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
}
function Move-SacSpoolToRejected {
param([string]$EventId)
$dir = Get-SacSpoolDirResolved
$src = Join-Path $dir "$EventId.json"
if (-not (Test-Path -LiteralPath $src)) { return }
$rejDir = Join-Path $dir 'rejected'
if (-not (Test-Path -LiteralPath $rejDir)) {
New-Item -ItemType Directory -Path $rejDir -Force | Out-Null
}
$dst = Join-Path $rejDir "$EventId.json"
Move-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
}
function Invoke-SacPostPayload {
param([string]$JsonBody)
if (-not (Test-SacConfigured)) { return $false }
if (-not (Test-SacShouldAttemptSend)) { return $false }
$ingest = Get-SacIngestUrl
if ([string]::IsNullOrWhiteSpace($ingest)) { return $false }
$jsonText = Get-SacSingleString -Value $JsonBody -Label 'spool payload'
if ($jsonText -notmatch '"event_id"\s*:\s*"([0-9a-fA-F-]{36})"') {
Write-SacLog 'WARN: SAC JSON has no event_id (uuid); skip POST'
return $false
}
$eventId = $Matches[1]
$eventType = 'unknown'
if ($jsonText -match '"type"\s*:\s*"([^"]+)"') {
$eventType = $Matches[1]
}
$timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 }
$spoolOnFailure = $true
try {
Invoke-SacTlsPrep
$headers = @{
Authorization = "Bearer $SacApiKey"
'Content-Type' = 'application/json; charset=utf-8'
'Idempotency-Key' = $eventId
}
$bodyBytes = Get-SacUtf8Bytes -Text $jsonText
$resp = Invoke-WebRequest -Uri $ingest -Method Post -Headers $headers -Body $bodyBytes -UseBasicParsing -TimeoutSec $timeout
if (Test-SacIngestAcceptedStatus -StatusCode $resp.StatusCode) {
Complete-SacIngestSuccess -EventId $eventId -EventType $eventType -StatusCode $resp.StatusCode
return $true
}
$body = if ($resp.Content) { $resp.Content } else { '' }
if ($resp.StatusCode -eq 422) {
$spoolOnFailure = $false
Write-SacLog "WARN: SAC POST HTTP 422 validation (not spooled) type=$eventType event_id=$eventId"
}
if ($body.Length -gt 0) {
$snippet = $body.Substring(0, [Math]::Min(800, $body.Length))
Write-SacLog "WARN: SAC POST HTTP $($resp.StatusCode): $snippet"
} else {
Write-SacLog "WARN: SAC POST HTTP $($resp.StatusCode) (empty body)"
}
} catch {
$code = 0
$body = Get-SacHttpErrorBody -ErrorRecord $_
if ($_.Exception.Response) {
$code = [int]$_.Exception.Response.StatusCode
}
if (Test-SacIngestAcceptedStatus -StatusCode $code) {
Complete-SacIngestSuccess -EventId $eventId -EventType $eventType -StatusCode $code
return $true
}
if ($code -eq 422) {
$spoolOnFailure = $false
Write-SacLog "WARN: SAC POST HTTP 422 validation (not spooled) type=$eventType event_id=$eventId"
}
$err = $_.Exception.Message
if ($body.Length -gt 0) {
$snippet = $body.Substring(0, [Math]::Min(800, $body.Length))
Write-SacLog "WARN: SAC POST HTTP ${code}: $err | $snippet"
} else {
Write-SacLog "WARN: SAC POST HTTP ${code}: $err"
}
}
if (-not $spoolOnFailure) {
Move-SacSpoolToRejected -EventId $eventId
return $false
}
Write-SacSpoolFile -EventId $eventId -JsonBody $jsonText
$max = if ($SacFallbackFailures) { [int]$SacFallbackFailures } else { 5 }
$n = Get-SacFailCount + 1
Set-SacFailCount -Count $n
if ($n -ge $max) {
Write-SacLog "WARN: SAC fallback: SAC_FALLBACK_FAILURES threshold ($max)"
}
return $false
}
function Send-SacEvent {
param(
[Parameter(Mandatory = $true)][string]$EventType,
[Parameter(Mandatory = $true)][string]$Severity,
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Summary,
[hashtable]$Details = $null
)
if (-not (Test-SacConfigured)) {
Write-SacLog 'WARN: SAC not configured (SacUrl / SacApiKey)'
return $false
}
$payload = New-SacEventPayload -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details
if ($payload -is [System.Array]) {
$payload = $payload[-1]
}
$json = ConvertTo-SacJsonText -Payload $payload
$json = Get-SacSingleString -Value $json -Label 'event json'
if ([string]::IsNullOrWhiteSpace($json) -or $json[0] -ne '{') {
Write-SacLog "WARN: SAC invalid JSON for type=$EventType (empty or does not start with {{)"
return $false
}
return (Invoke-SacPostPayload -JsonBody $json)
}
function Send-SacLocalChannels {
param(
[string]$TelegramMessage,
[string]$EmailSubject
)
if ([string]::IsNullOrWhiteSpace($TelegramMessage)) { return $false }
$channels = @(Get-NotifyOrderChannels)
if ($channels.Count -eq 0) { return $false }
$anyOk = $false
foreach ($ch in $channels) {
$ok = switch ($ch) {
'telegram' { Send-TelegramMessage -Message $TelegramMessage }
'email' { Send-EmailNotification -Message $TelegramMessage -Subject $EmailSubject }
default { $false }
}
if ($ok) { $anyOk = $true }
}
return $anyOk
}
function Test-SacHeartbeatOnlyEventType {
param([string]$EventType)
return ($EventType -eq 'agent.heartbeat')
}
function Send-NotifyOrSac {
param(
[Parameter(Mandatory = $true)][string]$EventType,
[Parameter(Mandatory = $true)][string]$Severity,
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Summary,
[string]$TelegramMessage = '',
[string]$EmailSubject = 'RDP Login Monitor',
[hashtable]$Details = $null
)
if ([string]::IsNullOrWhiteSpace($TelegramMessage)) {
$TelegramMessage = $Summary
}
$mode = Get-SacNormalizedMode
# Периодический heartbeat — только SAC (UI), без Telegram/email в любом режиме.
if (Test-SacHeartbeatOnlyEventType -EventType $EventType) {
if ($mode -eq 'off') {
return $false
}
return (Send-SacEvent -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details)
}
switch ($mode) {
'off' {
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
}
'exclusive' {
return (Send-SacEvent -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details)
}
'dual' {
Send-SacEvent -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details | Out-Null
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
}
'fallback' {
if (Send-SacEvent -EventType $EventType -Severity $Severity -Title $Title -Summary $Summary -Details $Details) {
return $true
}
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
}
default {
Write-SacLog "WARN: unknown UseSAC=$mode, local channels only"
return (Send-SacLocalChannels -TelegramMessage $TelegramMessage -EmailSubject $EmailSubject)
}
}
}
function Invoke-SacFlushSpool {
param([int]$MaxFiles = 20)
$mode = Get-SacNormalizedMode
if ($mode -eq 'off') { return }
if (-not (Test-SacConfigured)) { return }
$dir = Get-SacSpoolDirResolved
if (-not (Test-Path -LiteralPath $dir)) { return }
$files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime)
$count = 0
foreach ($f in $files) {
$count++
if ($count -gt $MaxFiles) { break }
try {
$utf8 = New-Object System.Text.UTF8Encoding $false
$json = [System.IO.File]::ReadAllText($f.FullName, $utf8)
Invoke-SacPostPayload -JsonBody $json | Out-Null
} catch {
Write-SacLog "WARN: SAC spool flush failed for $($f.Name): $($_.Exception.Message)"
}
}
}
function Test-SacConnection {
Write-Host 'SAC check (rdp-login-monitor)'
Write-Host "UseSAC=$(Get-SacNormalizedMode)"
switch (Get-SacNormalizedMode) {
'exclusive' { Write-Host 'Mode exclusive: SAC only' }
'dual' { Write-Host 'Mode dual: SAC + local channels' }
'fallback' { Write-Host 'Mode fallback: SAC, then local on failure' }
}
Write-Host "SacUrl=$SacUrl"
if (Test-SacConfigured) {
Write-Host "SAC ingest URL=$(Get-SacIngestUrl)"
}
if (-not (Test-SacConfigured)) {
Write-Error 'SAC: SacUrl or SacApiKey missing'
return 1
}
if (Test-SacHealth) {
Write-Host 'SAC health: OK'
} else {
Write-Error 'SAC health: FAIL'
return 1
}
if (Send-SacEvent -EventType 'agent.test' -Severity 'info' -Title 'SAC test' -Summary 'rdp-login-monitor CheckSac') {
Write-Host 'SAC ingest agent.test: OK (expected HTTP 201)'
return 0
}
Write-Error 'SAC ingest agent.test: FAIL'
return 1
}