diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1
index af4c489..10fcadd 100644
--- a/Login_Monitor.ps1
+++ b/Login_Monitor.ps1
@@ -2,7 +2,8 @@
.SYNOPSIS
Мониторинг логинов/попыток входа с уведомлениями в Telegram
.DESCRIPTION
- Отслеживает события входа в систему (Security 4624/4625) и события RD Gateway (302/303),
+ Отслеживает события входа в систему (Security 4624/4625), агрегированные оповещения при всплеске 4625,
+ события RD Gateway (302/303),
на заданном КД — блокировки учётных записей (Security 4740) с IP из логов IIS ActiveSync,
отправляет уведомления в Telegram, делает ротацию логов, heartbeat в файл и ежедневный отчет.
.NOTES
@@ -71,7 +72,7 @@ $script:MonitorSingletonLockStream = $null
# строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах).
# Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только
# исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1).
-$ScriptVersion = "1.5.9"
+$ScriptVersion = "1.5.10"
# Логи (все под InstallRoot)
$LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log"
@@ -162,6 +163,18 @@ $ExchangeIisLogTailLines = 5000
# Окно поиска IP в IIS: только строки за N минут до события 4740 (локальное время сервера IIS).
$ExchangeIisLogMinutesBeforeLockout = 30
+# Агрегация неудачных входов Security 4625 (вариант C: два порога). Автоблокировка IP не выполняется.
+$FailedLogonRateLimitEnabled = $true
+$FailedLogonRateLimitSuppressIndividualWhileBurst = $true
+# Уровень 1: подбор одной учётной записи с одного источника (IP + пользователь).
+$FailedLogonRateLimitUserIpWindowSeconds = 60
+$FailedLogonRateLimitUserIpThreshold = 5
+$FailedLogonRateLimitUserIpCooldownSeconds = 300
+# Уровень 2: много неудачных попыток с одного IP (password spraying / перебор логинов).
+$FailedLogonRateLimitIpWindowSeconds = 60
+$FailedLogonRateLimitIpThreshold = 12
+$FailedLogonRateLimitIpCooldownSeconds = 300
+
# Очередь оповещений: telegram, email (или tg, mail). Пусто = авто: настроенные каналы, порядок telegram → email.
$NotifyOrder = "tg"
$MailSmtpHost = ""
@@ -1184,6 +1197,9 @@ function Send-Heartbeat {
}
} catch { }
}
+ if ($FailedLogonRateLimitEnabled) {
+ $message += "`r`n🛡️ Агрегация 4625: уровень 1 — $FailedLogonRateLimitUserIpThreshold за $FailedLogonRateLimitUserIpWindowSeconds с (IP+пользователь); уровень 2 — $FailedLogonRateLimitIpThreshold за $FailedLogonRateLimitIpWindowSeconds с (только IP). Cooldown ${FailedLogonRateLimitUserIpCooldownSeconds}/${FailedLogonRateLimitIpCooldownSeconds} с. Автобан: нет."
+ }
if (Test-Lockout4740MonitoringActive) {
$message += "`r`n🔒 Блокировки AD: на этом КД отслеживается Security 4740 (блокировка учётной записи)."
if (-not [string]::IsNullOrWhiteSpace($ExchangeIisLogPath)) {
@@ -1707,6 +1723,250 @@ function Format-LoginEvent {
return $message
}
+$script:FailedLogonBuckets = @{}
+
+function Get-FailedLogonSourceKeyPart {
+ param(
+ [string]$SourceIP,
+ [string]$ComputerName
+ )
+
+ $ip = if ($null -ne $SourceIP) { $SourceIP.Trim() } else { '' }
+ if (-not [string]::IsNullOrWhiteSpace($ip) -and $ip -ne '-' -and $ip -ne '::1' -and $ip -ne '127.0.0.1' -and $ip -notlike 'fe80:*') {
+ return "ip:$ip"
+ }
+
+ $wks = if ($null -ne $ComputerName) { $ComputerName.Trim() } else { '' }
+ if (-not [string]::IsNullOrWhiteSpace($wks) -and $wks -ne '-') {
+ return "wks:$wks"
+ }
+
+ return 'unknown'
+}
+
+function Get-FailedLogonNormalizedUsername {
+ param([string]$Username)
+
+ $u = if ($null -ne $Username) { $Username.Trim() } else { '' }
+ if ([string]::IsNullOrWhiteSpace($u) -or $u -eq '-') { return '(не указан)' }
+ return $u
+}
+
+function Get-FailedLogonSourceDisplayLabel {
+ param([string]$SourceKeyPart)
+
+ if ($SourceKeyPart -like 'ip:*') { return $SourceKeyPart.Substring(3) }
+ if ($SourceKeyPart -like 'wks:*') { return ('рабочая станция ' + $SourceKeyPart.Substring(4)) }
+ return 'неизвестный источник'
+}
+
+function Update-FailedLogonRateLimitBucket {
+ param(
+ [string]$BucketKey,
+ [int]$WindowSeconds,
+ [string]$Username,
+ [int]$LogonType,
+ [datetime]$TimeCreated
+ )
+
+ $cutoff = (Get-Date).AddSeconds(-$WindowSeconds)
+
+ if (-not $script:FailedLogonBuckets.ContainsKey($BucketKey)) {
+ $script:FailedLogonBuckets[$BucketKey] = [pscustomobject]@{
+ Attempts = [System.Collections.ArrayList]@()
+ LastBurstAlertUtc = $null
+ }
+ }
+
+ $bucket = $script:FailedLogonBuckets[$BucketKey]
+ $null = $bucket.Attempts.Add([pscustomobject]@{
+ Time = $TimeCreated
+ Username = (Get-FailedLogonNormalizedUsername -Username $Username)
+ LogonType = $LogonType
+ })
+
+ $fresh = [System.Collections.ArrayList]@()
+ foreach ($a in $bucket.Attempts) {
+ if ($a.Time -ge $cutoff) { $null = $fresh.Add($a) }
+ }
+ $bucket.Attempts = $fresh
+
+ if ($fresh.Count -eq 0) {
+ $bucket.LastBurstAlertUtc = $null
+ }
+
+ return $bucket
+}
+
+function Format-FailedLogonBurstMessage {
+ param(
+ [ValidateSet('UserIp', 'Ip')]
+ [string]$TierKind,
+ [string]$SourceKeyPart,
+ [string]$FocusUsername,
+ [string]$SecurityLogComputerName,
+ [System.Collections.ArrayList]$Attempts,
+ [int]$Threshold,
+ [int]$WindowSeconds
+ )
+
+ $sourceLabel = Get-FailedLogonSourceDisplayLabel -SourceKeyPart $SourceKeyPart
+ $hLog = ConvertTo-TelegramHtml $(if ([string]::IsNullOrWhiteSpace($SecurityLogComputerName)) { $env:COMPUTERNAME } else { $SecurityLogComputerName })
+ $hSource = ConvertTo-TelegramHtml $sourceLabel
+ $count = $Attempts.Count
+ $times = @($Attempts | ForEach-Object { $_.Time } | Sort-Object)
+ $first = $times[0]
+ $last = $times[-1]
+
+ $byUser = @{}
+ foreach ($a in $Attempts) {
+ if (-not $byUser.ContainsKey($a.Username)) { $byUser[$a.Username] = 0 }
+ $byUser[$a.Username]++
+ }
+ $userLines = @($byUser.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object {
+ '{0} ({1})' -f (ConvertTo-TelegramHtml $_.Key), $_.Value
+ })
+
+ $ltSet = @($Attempts | ForEach-Object { $_.LogonType } | Sort-Object -Unique)
+ $ltNames = @($ltSet | ForEach-Object { '{0} ({1})' -f (ConvertTo-TelegramHtml (Get-LogonTypeName -LogonType $_)), $_ })
+ $ltText = if ($ltNames.Count -gt 0) { ($ltNames -join ', ') } else { '-' }
+
+ if ($TierKind -eq 'UserIp') {
+ $title = '🚨 МАССОВЫЕ НЕУДАЧНЫЕ ВХОДЫ (4625) — учётная запись'
+ $tierLine = 'Уровень: IP + пользователь (подбор одного логина)'
+ $hUser = ConvertTo-TelegramHtml $FocusUsername
+ $focusLine = "👤 Учётная запись: $hUser`r`n"
+ } else {
+ $title = '🚨 МАССОВЫЕ НЕУДАЧНЫЕ ВХОДЫ (4625) — источник IP'
+ $tierLine = 'Уровень: только IP (несколько учётных записей / spraying)'
+ $focusLine = ''
+ }
+
+ $message = "$title`r`n"
+ $message += "$tierLine`r`n"
+ $message += "🏢 Сервер (журнал Security): $hLog`r`n"
+ $message += "🎯 Источник: $hSource`r`n"
+ $message += $focusLine
+ $message += "📊 За последние $WindowSeconds с: $count попыток (порог $Threshold)`r`n"
+ $message += "🕐 В окне: $(ConvertTo-TelegramHtml ($first.ToString('dd.MM.yyyy HH:mm:ss'))) — $(ConvertTo-TelegramHtml ($last.ToString('dd.MM.yyyy HH:mm:ss')))`r`n"
+ $message += "👤 В попытках: $($userLines -join '; ')`r`n"
+ $message += "🔑 Типы входа: $ltText`r`n"
+ $message += "⚠️ Возможный брутфорс. Одиночные 4625 в окне подавлены. Автоблокировка IP не выполняется.`r`n"
+ $message += "🔢 Event ID: 4625 (агрегат)"
+
+ return $message
+}
+
+function Get-FailedLogonBurstAlertIfNeeded {
+ param(
+ [ValidateSet('UserIp', 'Ip')]
+ [string]$TierKind,
+ [string]$BucketKey,
+ [string]$SourceKeyPart,
+ [string]$FocusUsername,
+ [string]$SecurityLogComputerName,
+ [int]$WindowSeconds,
+ [int]$Threshold,
+ [int]$CooldownSeconds
+ )
+
+ $bucket = $script:FailedLogonBuckets[$BucketKey]
+ if ($null -eq $bucket) {
+ return $null
+ }
+
+ $count = $bucket.Attempts.Count
+ if ($count -lt $Threshold) {
+ return $null
+ }
+
+ $cooldownOk = $true
+ if ($null -ne $bucket.LastBurstAlertUtc) {
+ $elapsed = ((Get-Date).ToUniversalTime() - $bucket.LastBurstAlertUtc.ToUniversalTime()).TotalSeconds
+ $cooldownOk = ($elapsed -ge $CooldownSeconds)
+ }
+
+ if (-not $cooldownOk) {
+ return $null
+ }
+
+ $bucket.LastBurstAlertUtc = (Get-Date).ToUniversalTime()
+ $msg = Format-FailedLogonBurstMessage -TierKind $TierKind -SourceKeyPart $SourceKeyPart `
+ -FocusUsername $FocusUsername -SecurityLogComputerName $SecurityLogComputerName `
+ -Attempts $bucket.Attempts -Threshold $Threshold -WindowSeconds $WindowSeconds
+
+ return [pscustomobject]@{
+ Tier = $TierKind
+ Message = $msg
+ Count = $count
+ BucketKey = $BucketKey
+ }
+}
+
+function Get-FailedLogonRateLimitDecision4625 {
+ param(
+ [string]$SourceIP,
+ [string]$ComputerName,
+ [string]$Username,
+ [int]$LogonType,
+ [datetime]$TimeCreated,
+ [string]$SecurityLogComputerName
+ )
+
+ if (-not $FailedLogonRateLimitEnabled) {
+ return [pscustomobject]@{
+ SendIndividual = $true
+ BurstAlerts = @()
+ UserIpCount = 0
+ IpCount = 0
+ }
+ }
+
+ $sourcePart = Get-FailedLogonSourceKeyPart -SourceIP $SourceIP -ComputerName $ComputerName
+ $normUser = Get-FailedLogonNormalizedUsername -Username $Username
+ $userIpKey = "tier1:$sourcePart|$normUser"
+ $ipKey = "tier2:$sourcePart"
+
+ $userBucket = Update-FailedLogonRateLimitBucket -BucketKey $userIpKey `
+ -WindowSeconds $FailedLogonRateLimitUserIpWindowSeconds `
+ -Username $Username -LogonType $LogonType -TimeCreated $TimeCreated
+
+ $ipBucket = Update-FailedLogonRateLimitBucket -BucketKey $ipKey `
+ -WindowSeconds $FailedLogonRateLimitIpWindowSeconds `
+ -Username $Username -LogonType $LogonType -TimeCreated $TimeCreated
+
+ $burstAlerts = [System.Collections.ArrayList]@()
+
+ $burstUser = Get-FailedLogonBurstAlertIfNeeded -TierKind 'UserIp' -BucketKey $userIpKey `
+ -SourceKeyPart $sourcePart -FocusUsername $normUser -SecurityLogComputerName $SecurityLogComputerName `
+ -WindowSeconds $FailedLogonRateLimitUserIpWindowSeconds `
+ -Threshold $FailedLogonRateLimitUserIpThreshold `
+ -CooldownSeconds $FailedLogonRateLimitUserIpCooldownSeconds
+ if ($null -ne $burstUser) { $null = $burstAlerts.Add($burstUser) }
+
+ $burstIp = Get-FailedLogonBurstAlertIfNeeded -TierKind 'Ip' -BucketKey $ipKey `
+ -SourceKeyPart $sourcePart -FocusUsername $normUser -SecurityLogComputerName $SecurityLogComputerName `
+ -WindowSeconds $FailedLogonRateLimitIpWindowSeconds `
+ -Threshold $FailedLogonRateLimitIpThreshold `
+ -CooldownSeconds $FailedLogonRateLimitIpCooldownSeconds
+ if ($null -ne $burstIp) { $null = $burstAlerts.Add($burstIp) }
+
+ $inBurst = ($userBucket.Attempts.Count -ge $FailedLogonRateLimitUserIpThreshold) `
+ -or ($ipBucket.Attempts.Count -ge $FailedLogonRateLimitIpThreshold)
+
+ $sendIndividual = $true
+ if ($FailedLogonRateLimitSuppressIndividualWhileBurst -and $inBurst) {
+ $sendIndividual = $false
+ }
+
+ return [pscustomobject]@{
+ SendIndividual = $sendIndividual
+ BurstAlerts = @($burstAlerts)
+ UserIpCount = $userBucket.Attempts.Count
+ IpCount = $ipBucket.Attempts.Count
+ }
+}
+
function Test-RDGatewayLog {
try {
$logExists = Get-WinEvent -ListLog $RDGatewayLogName -ErrorAction SilentlyContinue
@@ -2108,6 +2368,9 @@ function Start-LoginMonitor {
}
Write-Log "========================================"
Write-Log "Каналы уведомлений: $(Get-NotifyChainHuman)"
+ if ($FailedLogonRateLimitEnabled) {
+ Write-Log "Агрегация 4625: tier1 $FailedLogonRateLimitUserIpThreshold/$FailedLogonRateLimitUserIpWindowSeconds с (IP+user), tier2 $FailedLogonRateLimitIpThreshold/$FailedLogonRateLimitIpWindowSeconds с (IP); suppressIndividual=$FailedLogonRateLimitSuppressIndividualWhileBurst"
+ }
$lockout4740Enabled = Test-Lockout4740MonitoringActive
if ($lockout4740Enabled) {
@@ -2190,19 +2453,51 @@ function Start-LoginMonitor {
}
if (-not $shouldIgnore) {
- $formattedMessage = Format-LoginEvent -EventID $event.Id `
- -Username $eventInfo.Username `
- -ComputerName $eventInfo.ComputerName `
- -SourceIP $eventInfo.SourceIP `
- -ProcessName $eventInfo.ProcessName `
- -TimeCreated $eventInfo.TimeCreated `
- -LogonType $eventInfo.LogonType `
- -LogonTypeName $logonTypeName `
- -SecurityLogComputerName $event.MachineName
+ if ($event.Id -eq 4625 -and $FailedLogonRateLimitEnabled) {
+ $rl = Get-FailedLogonRateLimitDecision4625 -SourceIP $eventInfo.SourceIP `
+ -ComputerName $eventInfo.ComputerName -Username $eventInfo.Username `
+ -LogonType $eventInfo.LogonType -TimeCreated $eventInfo.TimeCreated `
+ -SecurityLogComputerName $event.MachineName
- Write-Log "Notify: ID=$($event.Id) User=$($eventInfo.Username) LT=$($eventInfo.LogonType) IP=$($eventInfo.SourceIP)"
- Send-MonitorNotification -Message $formattedMessage `
- -EmailSubject "RDP Login Monitor: вход (ID $($event.Id))" | Out-Null
+ if ($rl.SendIndividual) {
+ $formattedMessage = Format-LoginEvent -EventID $event.Id `
+ -Username $eventInfo.Username `
+ -ComputerName $eventInfo.ComputerName `
+ -SourceIP $eventInfo.SourceIP `
+ -ProcessName $eventInfo.ProcessName `
+ -TimeCreated $eventInfo.TimeCreated `
+ -LogonType $eventInfo.LogonType `
+ -LogonTypeName $logonTypeName `
+ -SecurityLogComputerName $event.MachineName
+
+ 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
+ } else {
+ Write-Log "Notify suppressed 4625: User=$($eventInfo.Username) IP=$($eventInfo.SourceIP) tier1=$($rl.UserIpCount)/$FailedLogonRateLimitUserIpThreshold tier2=$($rl.IpCount)/$FailedLogonRateLimitIpThreshold"
+ }
+
+ foreach ($burst in $rl.BurstAlerts) {
+ $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
+ }
+ } else {
+ $formattedMessage = Format-LoginEvent -EventID $event.Id `
+ -Username $eventInfo.Username `
+ -ComputerName $eventInfo.ComputerName `
+ -SourceIP $eventInfo.SourceIP `
+ -ProcessName $eventInfo.ProcessName `
+ -TimeCreated $eventInfo.TimeCreated `
+ -LogonType $eventInfo.LogonType `
+ -LogonTypeName $logonTypeName `
+ -SecurityLogComputerName $event.MachineName
+
+ Write-Log "Notify: ID=$($event.Id) User=$($eventInfo.Username) LT=$($eventInfo.LogonType) IP=$($eventInfo.SourceIP)"
+ Send-MonitorNotification -Message $formattedMessage `
+ -EmailSubject "RDP Login Monitor: вход (ID $($event.Id))" | Out-Null
+ }
}
}
$lastCheckTime = ($events | Measure-Object -Property TimeCreated -Maximum | Select-Object -ExpandProperty Maximum).AddSeconds(1)
diff --git a/README.md b/README.md
index e6d379d..838efa0 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ PowerShell-набор для мониторинга входов в Windows с
## Актуальная схема (рекомендуется)
- Базовый путь установки: **`C:\ProgramData\RDP-login-monitor\`**.
-- Основной скрипт: **`Login_Monitor.ps1`** — журнал Security **`4624`/`4625`** (логика зависит от типа ОС: рабочая станция или сервер/КД), при наличии журнала — **Remote Connection Manager `1149`** (часто актуально для РС с RDP), при роли **RD Gateway** — **`302`/`303`**, на **КД, где запущен монитор** (имя совпадает с **`$LockoutMonitorDomainController`**) — **`4740`** (блокировка УЗ + IP из IIS ActiveSync), **ежедневный отчёт** (активные сессии через `quser`), **heartbeat**, **ротация логов**, уведомления в Telegram и/или Email.
+- Основной скрипт: **`Login_Monitor.ps1`** — журнал Security **`4624`/`4625`** (логика зависит от типа ОС: рабочая станция или сервер/КД), при всплеске **`4625`** — **агрегированные оповещения** (два порога: IP+пользователь и только IP), при наличии журнала — **Remote Connection Manager `1149`** (часто актуально для РС с RDP), при роли **RD Gateway** — **`302`/`303`**, на **КД, где запущен монитор** (имя совпадает с **`$LockoutMonitorDomainController`**) — **`4740`** (блокировка УЗ + IP из IIS ActiveSync), **ежедневный отчёт** (активные сессии через `quser`), **heartbeat**, **ротация логов**, уведомления в Telegram и/или Email.
- Установка задач: запуск **`Login_Monitor.ps1 -InstallTasks`** создаёт:
- `RDP-Login-Monitor` (основной монитор),
- `RDP-Login-Monitor-Watchdog` (контроль процесса каждые 5 минут).
@@ -19,6 +19,7 @@ PowerShell-набор для мониторинга входов в Windows с
- **Кодировка логов**: `login_monitor.log` / `watchdog.log` пишутся как **UTF-8 с BOM** (и при необходимости BOM добавляется к уже существующему файлу), чтобы в **FAR/старых просмотрщиках** не было ситуации “в консоли нормально, а в файле РЈРІРµ…” из‑за неверной авто-кодировки.
- **`auditpol` на русской Windows**: настройка/проверка аудита опирается на категорию **`Вход/выход`** и подкатегории **`Вход в систему` / `Выход из системы`** (ожидается строка **`Успех и сбой`**). Это устраняет ошибки вида `0x00000057` из‑за несуществующего на RU ОС имени `Logon`.
- **Стабильность**: `auditpol` вызывается по полному пути `%SystemRoot%\System32\auditpol.exe` (без зависимости от PATH), stdout+stderr объединяются через `ProcessStartInfo`.
+- **Агрегация 4625 (брутфорс)**: при включённом `$FailedLogonRateLimitEnabled` — уровень 1: **5** неудачных попыток за **60** с с одного источника для **одной** учётной записи (IP+user); уровень 2: **12** попыток за **60** с с одного IP (несколько логинов). Пока порог не достигнут — поштучные 4625; при всплеске — сводные алерты, одиночные подавляются. Параметры в начале `Login_Monitor.ps1`. Автоблокировка IP не выполняется.
## 1) Подготовка
diff --git a/README_eng.md b/README_eng.md
index fb7a23f..6f21136 100644
--- a/README_eng.md
+++ b/README_eng.md
@@ -5,7 +5,7 @@ PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMT
## Recommended layout
- Installation root: **`C:\ProgramData\RDP-login-monitor\`**.
-- Main script: **`Login_Monitor.ps1`** — Security log **`4624`/`4625`** (behavior depends on OS type: workstation vs server/domain controller), optional **Remote Connection Manager `1149`** when the log is available (often useful for RDP-enabled workstations), **RD Gateway** events **`302`/`303`** when the gateway role/log is present, on the **DC where the monitor runs** (hostname matches **`$LockoutMonitorDomainController`**) — **`4740`** (account lockout + IPs from IIS ActiveSync), **daily report** (active sessions via `quser`), **heartbeat**, **log rotation**, alerts via Telegram and/or Email.
+- Main script: **`Login_Monitor.ps1`** — Security log **`4624`/`4625`** (behavior depends on OS type: workstation vs server/domain controller), **aggregated `4625` burst alerts** (two tiers: IP+user and IP-only), optional **Remote Connection Manager `1149`** when the log is available (often useful for RDP-enabled workstations), **RD Gateway** events **`302`/`303`** when the gateway role/log is present, on the **DC where the monitor runs** (hostname matches **`$LockoutMonitorDomainController`**) — **`4740`** (account lockout + IPs from IIS ActiveSync), **daily report** (active sessions via `quser`), **heartbeat**, **log rotation**, alerts via Telegram and/or Email.
- Scheduled tasks: run **`Login_Monitor.ps1 -InstallTasks`** to register:
- `RDP-Login-Monitor` (main monitor),
- `RDP-Login-Monitor-Watchdog` (process health check every 5 minutes).
@@ -19,6 +19,7 @@ PowerShell toolkit for monitoring Windows logons with Telegram and/or Email (SMT
- **Log encoding**: `login_monitor.log` / `watchdog.log` are written as **UTF-8 with BOM** (BOM is applied to existing files if missing) so viewers like **FAR Manager** do not mis-detect encoding.
- **`auditpol` on Russian Windows**: auditing checks use the **`Вход/выход`** category and **`Вход в систему` / `Выход из системы`** subcategories (expect **`Успех и сбой`**), avoiding errors such as `0x00000057` when English names like `Logon` are absent on a localized OS.
- **Stability**: `auditpol` is invoked via full path `%SystemRoot%\System32\auditpol.exe` (no PATH dependency); stdout and stderr are merged via `ProcessStartInfo`.
+- **`4625` burst alerts**: when `$FailedLogonRateLimitEnabled` is true — tier 1: **5** failures in **60** s per **IP+user**; tier 2: **12** in **60** s per **IP** (spray). Below thresholds, individual `4625` alerts are sent; during a burst, aggregated alerts replace per-event noise. No automatic IP blocking. Tune at the top of `Login_Monitor.ps1`.
## 1) Preparation
diff --git a/version.txt b/version.txt
index 2b26b8d..f0ed379 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-1.5.9
+1.5.10