diff --git a/Deploy-LoginMonitor.ps1 b/Deploy-LoginMonitor.ps1 index d274c98..1ba3424 100644 --- a/Deploy-LoginMonitor.ps1 +++ b/Deploy-LoginMonitor.ps1 @@ -238,6 +238,15 @@ try { Copy-Item -LiteralPath $sourceScript -Destination $LocalScript -Force Write-DeployLog "Файл скопирован: $LocalScript" + $sourceSac = Join-Path $shareRoot 'Sac-Client.ps1' + $localSac = Join-Path $InstallRoot 'Sac-Client.ps1' + if (Test-Path -LiteralPath $sourceSac) { + Copy-Item -LiteralPath $sourceSac -Destination $localSac -Force + Write-DeployLog "Файл скопирован: $localSac" + } else { + Write-DeployLog "Предупреждение: на шаре нет Sac-Client.ps1 — SAC будет недоступен до следующего деплоя." + } + $installArgs = @( '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $LocalScript, '-InstallTasks' ) diff --git a/Docs/deploy-netlogon-publish.md b/Docs/deploy-netlogon-publish.md index 73e386e..e4deba4 100644 --- a/Docs/deploy-netlogon-publish.md +++ b/Docs/deploy-netlogon-publish.md @@ -15,6 +15,7 @@ ## Копируемые файлы - `Login_Monitor.ps1` +- `Sac-Client.ps1` - `version.txt` - `Deploy-LoginMonitor.ps1` - `Exchange-MailSecurity.ps1` diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1 index 62a4df4..0b83ae2 100644 --- a/Login_Monitor.ps1 +++ b/Login_Monitor.ps1 @@ -35,7 +35,8 @@ param( [string]$MailSmtpPasswordProtectedB64 = '', [switch]$Watchdog, [switch]$InstallTasks, - [switch]$SkipScheduledTaskMaintenance + [switch]$SkipScheduledTaskMaintenance, + [switch]$CheckSac ) Set-StrictMode -Version Latest @@ -74,7 +75,7 @@ $script:MonitorSingletonLockStream = $null # строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). # Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только # исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). -$ScriptVersion = "1.5.11" +$ScriptVersion = "1.2.0-SAC" # Логи (все под InstallRoot) $LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" @@ -185,6 +186,16 @@ $MailTo = '' $MailSmtpStartTls = $true $MailSmtpSsl = $false +# Security Alert Center (см. security-alert-center/docs/agent-integration.md) +$UseSAC = 'off' +$SacUrl = '' +$SacApiKey = '' +$SacSpoolDir = '' +$SacAgentIdFile = '' +$SacTimeoutSec = 12 +$SacTlsSkipVerify = $false +$SacFallbackFailures = 5 + $script:LoginMonitorSettingsFile = Join-Path $script:InstallRoot 'login_monitor.settings.ps1' $script:LoginMonitorSettingsLoaded = $false if (Test-Path -LiteralPath $script:LoginMonitorSettingsFile) { @@ -764,12 +775,52 @@ function Test-MailSmtpConnection { return $true } +$script:SacClientLoaded = $false +foreach ($sacPath in @( + (Join-Path $PSScriptRoot 'Sac-Client.ps1'), + (Join-Path $script:InstallRoot 'Sac-Client.ps1') + )) { + if (Test-Path -LiteralPath $sacPath) { + . $sacPath + $script:SacClientLoaded = $true + break + } +} + +function Get-PlainSummaryFromTelegramHtml { + param([string]$Message) + $plain = ($Message -replace '<[^>]+>', ' ' -replace '\s+', ' ').Trim() + if ($plain.Length -gt 500) { $plain = $plain.Substring(0, 500) } + return $plain +} + function Send-MonitorNotification { param( [string]$Message, - [string]$EmailSubject = "RDP Login Monitor" + [string]$EmailSubject = "RDP Login Monitor", + [string]$SacEventType = '', + [string]$SacSeverity = 'info', + [string]$SacTitle = '', + [string]$SacSummary = '', + [hashtable]$SacDetails = $null ) + $title = if (-not [string]::IsNullOrWhiteSpace($SacTitle)) { $SacTitle } else { $EmailSubject } + $summary = if (-not [string]::IsNullOrWhiteSpace($SacSummary)) { + $SacSummary + } else { + Get-PlainSummaryFromTelegramHtml -Message $Message + } + $etype = if (-not [string]::IsNullOrWhiteSpace($SacEventType)) { $SacEventType } else { 'agent.notification' } + + if ($script:SacClientLoaded -and (Get-Command Send-NotifyOrSac -ErrorAction SilentlyContinue)) { + $sacMode = Get-SacNormalizedMode + if ($sacMode -ne 'off') { + return Send-NotifyOrSac -EventType $etype -Severity $SacSeverity -Title $title -Summary $summary ` + -TelegramMessage $Message -EmailSubject $EmailSubject -Details $SacDetails + } + } + $channels = @(Get-NotifyOrderChannels) if ($channels.Count -eq 0) { Write-Log "Оповещение не отправлено: нет настроенных каналов (Telegram и/или SMTP)" @@ -805,6 +856,15 @@ if ($script:LoginMonitorSettingsLoaded) { Write-Log "Предупреждение: login_monitor.settings.ps1 не найден — Telegram/SMTP/4740 не настроены (скопируйте login_monitor.settings.example.ps1)." } +if ($CheckSac) { + if (-not $script:SacClientLoaded) { + Write-Log "ОШИБКА: Sac-Client.ps1 не найден рядом с Login_Monitor.ps1" + exit 1 + } + $code = Test-SacConnection + exit $code +} + Invoke-RdpMonitorProcessMigrationAndRelaunch Lock-RdpMonitorSingleInstance Ensure-RdpMonitorScheduledTasks @@ -1219,11 +1279,22 @@ function Send-Heartbeat { $message += " IP из IIS не заданы (ExchangeIisLogPath пуст)." } } - Send-MonitorNotification -Message $message -EmailSubject "RDP Login Monitor: запуск" | Out-Null + Send-MonitorNotification -Message $message -EmailSubject "RDP Login Monitor: запуск" ` + -SacEventType 'agent.lifecycle' -SacSeverity 'info' ` + -SacTitle 'RDP login monitor started' ` + -SacSummary "Мониторинг запущен на $env:COMPUTERNAME, версия $ScriptVersion" ` + -SacDetails @{ lifecycle = 'started' } | Out-Null Write-Log "Отправлено уведомление о запуске скрипта (каналы: $notifyChain)" } else { Write-TextFileUtf8Bom -Path $HeartbeatFile -Text $timestamp $script:HeartbeatStaleAlertActive = $false + if ($script:SacClientLoaded -and (Get-SacNormalizedMode) -ne 'off') { + Send-MonitorNotification -Message '' -EmailSubject 'RDP Login Monitor: heartbeat' ` + -SacEventType 'agent.heartbeat' -SacSeverity 'info' ` + -SacTitle 'RDP monitor heartbeat' ` + -SacSummary "Heartbeat $env:COMPUTERNAME $timestamp" ` + -SacDetails @{ host = $env:COMPUTERNAME } | Out-Null + } } } @@ -1269,7 +1340,10 @@ function Test-AndSendHeartbeatStaleAlert { $msg += "📄 Последний heartbeat: $hLast`r`n" $msg += "Проверьте процесс Login_Monitor.ps1 и задачи планировщика RDP-Login-Monitor / Watchdog." - if (Send-MonitorNotification -Message $msg -EmailSubject 'RDP Login Monitor: нет heartbeat') { + if (Send-MonitorNotification -Message $msg -EmailSubject 'RDP Login Monitor: нет heartbeat' ` + -SacEventType 'agent.health' -SacSeverity 'warning' ` + -SacTitle 'RDP monitor heartbeat stale' ` + -SacSummary "Heartbeat не обновлялся на $env:COMPUTERNAME") { $script:HeartbeatStaleAlertActive = $true Write-Log "Отправлено оповещение: heartbeat не обновлялся дольше $thresholdSec с" } @@ -1286,7 +1360,11 @@ function Send-StopNotification { $message += "🕐 Время остановки: $timestamp`r`n" $message += "📋 Причина: $hReason" - Send-MonitorNotification -Message $message -EmailSubject "RDP Login Monitor: остановка" | Out-Null + Send-MonitorNotification -Message $message -EmailSubject "RDP Login Monitor: остановка" ` + -SacEventType 'agent.lifecycle' -SacSeverity 'info' ` + -SacTitle 'RDP login monitor stopped' ` + -SacSummary "Мониторинг остановлен: $Reason" ` + -SacDetails @{ lifecycle = 'stopped'; reason = $Reason } | Out-Null Write-Log "Уведомление об остановке отправлено: $Reason" } @@ -2181,7 +2259,20 @@ function Send-DailyReport { } else { $message += "`r`nСписок пользователей недоступен (quser пуст или недостаточно прав)." } - Send-MonitorNotification -Message $message -EmailSubject "RDP Login Monitor: ежедневный отчёт" | Out-Null + $rdpReportDetails = @{ + stats = @{ + active_sessions_rdp = $count + unique_users = @($uniqueUsers) + } + report_body = (Get-PlainSummaryFromTelegramHtml -Message $message) + report_html = "
$message
" + report_format = 'telegram_html' + } + Send-MonitorNotification -Message $message -EmailSubject "RDP Login Monitor: ежедневный отчёт" ` + -SacEventType 'report.daily.rdp' -SacSeverity 'info' ` + -SacTitle 'RDP daily report' ` + -SacSummary "RDP 24ч: сессий $count, логинов $($uniqueUsers.Count)" ` + -SacDetails $rdpReportDetails | Out-Null Write-TextFileUtf8Bom -Path $LastReportFile -Text ((Get-Date).ToString("yyyy-MM-dd HH:mm:ss")) Write-Log "Ежедневный отчет отправлен" return $true @@ -2483,7 +2574,17 @@ function Start-LoginMonitor { Write-Log "Notify: ID=4625 User=$($eventInfo.Username) LT=$($eventInfo.LogonType) IP=$($eventInfo.SourceIP) (tier1=$($rl.UserIpCount) tier2=$($rl.IpCount))" Send-MonitorNotification -Message $formattedMessage ` - -EmailSubject "RDP Login Monitor: неудачный вход (4625)" | Out-Null + -EmailSubject "RDP Login Monitor: неудачный вход (4625)" ` + -SacEventType 'rdp.login.failed' -SacSeverity 'warning' ` + -SacTitle 'RDP login failed' ` + -SacSummary "4625 $($eventInfo.Username) from $($eventInfo.SourceIP)" ` + -SacDetails @{ + user = $eventInfo.Username + ip_address = $eventInfo.SourceIP + logon_type = $eventInfo.LogonType + event_id_windows = 4625 + workstation_name = $eventInfo.ComputerName + } | Out-Null } else { Write-Log "Notify suppressed 4625: User=$($eventInfo.Username) IP=$($eventInfo.SourceIP) tier1=$($rl.UserIpCount)/$FailedLogonRateLimitUserIpThreshold tier2=$($rl.IpCount)/$FailedLogonRateLimitIpThreshold" } @@ -2492,7 +2593,11 @@ function Start-LoginMonitor { $tierLabel = if ($burst.Tier -eq 'UserIp') { 'IP+user' } else { 'IP' } Write-Log "Notify burst 4625 ($tierLabel): count=$($burst.Count) key=$($burst.BucketKey)" Send-MonitorNotification -Message $burst.Message ` - -EmailSubject "RDP Login Monitor: брутфорс 4625 ($tierLabel)" | Out-Null + -EmailSubject "RDP Login Monitor: брутфорс 4625 ($tierLabel)" ` + -SacEventType 'rdp.bruteforce.burst' -SacSeverity 'high' ` + -SacTitle "RDP bruteforce burst ($tierLabel)" ` + -SacSummary "4625 burst tier=$tierLabel count=$($burst.Count)" ` + -SacDetails @{ tier = $tierLabel; count = $burst.Count; bucket_key = $burst.BucketKey } | Out-Null } } else { $formattedMessage = Format-LoginEvent -EventID $event.Id ` @@ -2506,8 +2611,19 @@ function Start-LoginMonitor { -SecurityLogComputerName $event.MachineName Write-Log "Notify: ID=$($event.Id) User=$($eventInfo.Username) LT=$($eventInfo.LogonType) IP=$($eventInfo.SourceIP)" + $sacType = if ($event.Id -eq 4624) { 'rdp.login.success' } else { 'agent.notification' } Send-MonitorNotification -Message $formattedMessage ` - -EmailSubject "RDP Login Monitor: вход (ID $($event.Id))" | Out-Null + -EmailSubject "RDP Login Monitor: вход (ID $($event.Id))" ` + -SacEventType $sacType -SacSeverity 'info' ` + -SacTitle "RDP login event $($event.Id)" ` + -SacSummary "Event $($event.Id) $($eventInfo.Username) from $($eventInfo.SourceIP)" ` + -SacDetails @{ + user = $eventInfo.Username + ip_address = $eventInfo.SourceIP + logon_type = $eventInfo.LogonType + event_id_windows = [int]$event.Id + workstation_name = $eventInfo.ComputerName + } | Out-Null } } } @@ -2534,8 +2650,19 @@ function Start-LoginMonitor { -ErrorCode $ei.ErrorCode ` -TimeCreated $ei.TimeCreated Write-Log "Notify RDG: ID=$($event.Id) User=$($ei.Username)" + $rdgType = if ($event.Id -eq 302) { 'rdg.connection.success' } else { 'rdg.connection.failed' } + $rdgSev = if ($event.Id -eq 302) { 'info' } else { 'warning' } Send-MonitorNotification -Message $msg ` - -EmailSubject "RDP Login Monitor: RD Gateway ($($event.Id))" | Out-Null + -EmailSubject "RDP Login Monitor: RD Gateway ($($event.Id))" ` + -SacEventType $rdgType -SacSeverity $rdgSev ` + -SacTitle "RD Gateway event $($event.Id)" ` + -SacSummary "RDG $($event.Id) $($ei.Username)" ` + -SacDetails @{ + user = $ei.Username + external_ip = $ei.ExternalIP + internal_ip = $ei.InternalIP + event_id_windows = [int]$event.Id + } | Out-Null } $lastGatewayCheckTime = ($gatewayEvents | Measure-Object -Property TimeCreated -Maximum | Select-Object -ExpandProperty Maximum).AddSeconds(1) } @@ -2560,7 +2687,15 @@ function Start-LoginMonitor { -TimeCreated $rcmInfo.TimeCreated -SecurityLogComputerName $event.MachineName Write-Log "Notify RCM 1149: User=$($rcmInfo.Username) IP=$($rcmInfo.ClientIP)" Send-MonitorNotification -Message $msg ` - -EmailSubject "RDP Login Monitor: RDP 1149" | Out-Null + -EmailSubject "RDP Login Monitor: RDP 1149" ` + -SacEventType 'rdp.login.success' -SacSeverity 'info' ` + -SacTitle 'RDP connection (RCM 1149)' ` + -SacSummary "RCM 1149 $($rcmInfo.Username) $($rcmInfo.ClientIP)" ` + -SacDetails @{ + user = $rcmInfo.Username + ip_address = $rcmInfo.ClientIP + event_id_windows = 1149 + } | Out-Null } $lastRcmCheckTime = ($rcmEvents | Measure-Object -Property TimeCreated -Maximum | Select-Object -ExpandProperty Maximum).AddSeconds(1) } @@ -2592,7 +2727,16 @@ function Start-LoginMonitor { -TimeCreated $lo.TimeCreated -IisClientIps $iisIps Write-Log "Notify 4740: User=$($lo.Username) IIS_IPs=$($iisIps -join ', ')" Send-MonitorNotification -Message $msg ` - -EmailSubject "RDP Login Monitor: блокировка УЗ $($lo.Username)" | Out-Null + -EmailSubject "RDP Login Monitor: блокировка УЗ $($lo.Username)" ` + -SacEventType 'auth.account.locked' -SacSeverity 'high' ` + -SacTitle "Account locked $($lo.Username)" ` + -SacSummary "4740 $($lo.Username)" ` + -SacDetails @{ + user = $lo.Username + domain = $lo.Domain + event_id_windows = 4740 + iis_client_ips = @($iisIps) + } | Out-Null } $lastLockout4740CheckTime = ($lockoutEvents | Measure-Object -Property TimeCreated -Maximum | Select-Object -ExpandProperty Maximum).AddSeconds(1) } @@ -2609,6 +2753,9 @@ function Start-LoginMonitor { if ($now -ge $nextReportCheck) { $nextReportCheck = Check-AndSendDailyReport } + if ($script:SacClientLoaded) { + Invoke-SacFlushSpool -MaxFiles 5 | Out-Null + } } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } Write-Log "Ошибка цикла мониторинга: $($_.Exception.Message)" @@ -2619,10 +2766,24 @@ function Start-LoginMonitor { $script:StopNotificationSent = $false try { + $sacMode = 'off' + if ($script:SacClientLoaded) { $sacMode = Get-SacNormalizedMode } + if ($sacMode -ne 'off') { + if (-not (Test-SacConfigured)) { + Write-Log "ОШИБКА: UseSAC=$sacMode, но SacUrl/SacApiKey не заданы в login_monitor.settings.ps1" + exit 1 + } + if (-not (Test-SacHealth)) { + Write-Log "ОШИБКА: SAC /health недоступен (SacUrl=$SacUrl)" + exit 1 + } + Write-Log "SAC: режим $sacMode, health OK, ingest=$(Get-SacIngestUrl)" + } + $notifyChannels = @(Get-NotifyOrderChannels) - if ($notifyChannels.Count -eq 0) { + if ($notifyChannels.Count -eq 0 -and $sacMode -eq 'off') { Write-Log "ВНИМАНИЕ: не настроен ни один канал оповещений (Telegram и/или SMTP в конфигурации скрипта)." - } else { + } elseif ($notifyChannels.Count -gt 0) { foreach ($notifyCh in $notifyChannels) { switch ($notifyCh) { 'telegram' { Test-TelegramConnection | Out-Null } diff --git a/README.md b/README.md index 6339484..12b92a6 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ PowerShell-набор для мониторинга входов в Windows с - Документация по развёртыванию: **[Docs/README.md](Docs/README.md)** (RDP-монитор, Exchange, NETLOGON). - **`Encrypt-DpapiForRdpMonitor.ps1`** — опционально для подготовки DPAPI-строк токена/chat id и пароля SMTP (`$MailSmtpPasswordProtectedB64` в файле настроек). - **Локальные настройки RDP-монитора:** **`login_monitor.settings.ps1`** в каталоге установки (образец **`login_monitor.settings.example.ps1`**). При автообновлении **`Login_Monitor.ps1`** с шары файл настроек **не перезаписывается** (как **`exchange_monitor.settings.ps1`** для Exchange). +- **Security Alert Center (SAC):** модуль **`Sac-Client.ps1`** (копируется вместе с `Login_Monitor.ps1`). Режимы **`$UseSAC`**: `off` | `exclusive` | `dual` | `fallback` — контракт в репозитории **security-alert-center** (`docs/agent-integration.md`). Версия релиза агента: **`1.2.0-SAC`** (`$ScriptVersion` и `version.txt`). ## Что изменилось (важное) @@ -30,12 +31,14 @@ PowerShell-набор для мониторинга входов в Windows с - `C:\ProgramData\RDP-login-monitor\` 2. Скопируйте в неё как минимум: - `Login_Monitor.ps1` + - `Sac-Client.ps1` - `login_monitor.settings.example.ps1` → переименуйте в **`login_monitor.settings.ps1`** и задайте параметры (см. п. 3) - (для доменного развёртывания отдельно на шаре) `Deploy-LoginMonitor.ps1`, `version.txt` и `login_monitor.settings.example.ps1` 3. Настройте **`C:\ProgramData\RDP-login-monitor\login_monitor.settings.ps1`** (не редактируйте секреты в `Login_Monitor.ps1` — они перезапишутся при деплое): - **Telegram:** `$TelegramBotToken` / `$TelegramChatID` или `...ProtectedB64` - **Email (SMTP):** `$MailSmtpHost`, `$MailFrom`, `$MailTo`, `$MailSmtpPort` (по умолчанию 587), при необходимости `$MailSmtpUser` / `$MailSmtpPassword` (или `$MailSmtpPasswordProtectedB64` через DPAPI), `$MailSmtpStartTls` / `$MailSmtpSsl` - **Порядок:** `$NotifyOrder` — пусто = авто (Telegram → Email, только настроенные); иначе `telegram,email` или `email` и т.п. (допускаются `tg`, `mail`) + - **SAC (опционально):** `$UseSAC`, `$SacUrl`, `$SacApiKey` — см. блок в **`login_monitor.settings.example.ps1`**. Проверка: `Login_Monitor.ps1 -CheckSac` 4. Запускайте с правами администратора (чтение `Security` журнала и регистрация задач). 5. Логи и служебные файлы будут в: - `C:\ProgramData\RDP-login-monitor\Logs\` diff --git a/Sac-Client.ps1 b/Sac-Client.ps1 new file mode 100644 index 0000000..5ad26d0 --- /dev/null +++ b/Sac-Client.ps1 @@ -0,0 +1,387 @@ +<# +.SYNOPSIS + Клиент Security Alert Center для RDP-login-monitor. +.DESCRIPTION + Dot-source после login_monitor.settings.ps1 и функции Write-Log. + Ожидает: $UseSAC, $SacUrl, $SacApiKey, $ScriptVersion, $script:InstallRoot. +#> + +function Write-SacLog { + param([string]$Message) + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + Write-Log $Message + } else { + Write-Host $Message + } +} + +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-SacCategoryForType { + param([string]$EventType) + 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 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 + ) + + $payload = [ordered]@{ + schema_version = '1.0' + event_id = [guid]::NewGuid().ToString() + occurred_at = (Get-Date).ToString('o') + 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 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 } + + $obj = $JsonBody | ConvertFrom-Json + $eventId = [string]$obj.event_id + $eventType = [string]$obj.type + $timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 } + + try { + Invoke-SacTlsPrep + $headers = @{ + Authorization = "Bearer $SacApiKey" + 'Content-Type' = 'application/json' + 'Idempotency-Key' = $eventId + } + $resp = Invoke-WebRequest -Uri $ingest -Method Post -Headers $headers -Body $JsonBody -UseBasicParsing -TimeoutSec $timeout + if ($resp.StatusCode -eq 202) { + Remove-SacSpoolFile -EventId $eventId + Reset-SacFailCount + Write-SacLog "SAC: accepted event_id=$eventId type=$eventType" + return $true + } + $body = if ($resp.Content) { $resp.Content } else { '' } + Write-SacLog "WARN: SAC POST HTTP $($resp.StatusCode): $($body.Substring(0, [Math]::Min(200, $body.Length)))" + } catch { + $code = 0 + if ($_.Exception.Response) { + $code = [int]$_.Exception.Response.StatusCode + } + $err = $_.Exception.Message + Write-SacLog "WARN: SAC POST HTTP ${code}: $err" + } + + Write-SacSpoolFile -EventId $eventId -JsonBody $JsonBody + $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 + $json = $payload | ConvertTo-Json -Depth 8 -Compress + 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 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 + 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 { + $json = [System.IO.File]::ReadAllText($f.FullName) + Invoke-SacPostPayload -JsonBody $json | Out-Null + } catch { } + } +} + +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 202)' + return 0 + } + Write-Error 'SAC ingest agent.test: FAIL' + return 1 +} diff --git a/login_monitor.settings.example.ps1 b/login_monitor.settings.example.ps1 index c21384e..ad26713 100644 --- a/login_monitor.settings.example.ps1 +++ b/login_monitor.settings.example.ps1 @@ -9,8 +9,8 @@ #> # --- Telegram (или DPAPI Base64 через Encrypt-DpapiForRdpMonitor.ps1) --- -$TelegramBotToken = '8239219522:AAEyOZX3cwNfgGOMDkf-mgjTIuoaOh5gF7I' -$TelegramChatID = '2843230' +$TelegramBotToken = '' +$TelegramChatID = '' # $TelegramBotTokenProtectedB64 = '' # $TelegramChatIDProtectedB64 = '' @@ -26,6 +26,17 @@ $NotifyOrder = 'tg' # $MailSmtpSsl = $false # $MailSmtpPasswordProtectedB64 = '' +# --- Security Alert Center (SAC) --- +# off | exclusive | dual | fallback — см. security-alert-center/docs/agent-integration.md +# $UseSAC = 'exclusive' +# $SacUrl = 'https://sac.kalinamall.ru' +# $SacApiKey = 'sac_xxxxxxxx' +# $SacSpoolDir = 'C:\ProgramData\RDP-login-monitor\sac-spool' +# $SacTimeoutSec = 12 +# $SacTlsSkipVerify = $false +# $SacFallbackFailures = 5 +# Проверка: powershell -File Login_Monitor.ps1 -CheckSac + # --- Узкое исключение шумовых сетевых логонов (LogonType=3, Advapi) --- $IgnoreAdvapiNetworkLogonSourceIps = @( '192.168.160.57' diff --git a/update-rdp-monitor.ps1 b/update-rdp-monitor.ps1 index 23c9afe..9b9f5e5 100644 --- a/update-rdp-monitor.ps1 +++ b/update-rdp-monitor.ps1 @@ -22,6 +22,7 @@ param( $ErrorActionPreference = 'Stop' $DistFiles = @( 'Login_Monitor.ps1', + 'Sac-Client.ps1', 'version.txt', 'Deploy-LoginMonitor.ps1', 'Exchange-MailSecurity.ps1', diff --git a/version.txt b/version.txt index d8c5e72..682dea4 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.6.8 +1.2.0-SAC