diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1 index ab9e5e6..e829bca 100644 --- a/Login_Monitor.ps1 +++ b/Login_Monitor.ps1 @@ -71,7 +71,7 @@ $script:MonitorSingletonLockStream = $null # строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). # Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только # исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). -$ScriptVersion = "1.5.2" +$ScriptVersion = "1.5.3" # Логи (все под InstallRoot) $LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" @@ -82,8 +82,9 @@ $MaxBackupDays = 31 $LogRotationHour = 0 $LogRotationMinute = 0 -# Heartbeat (только файл) +# Heartbeat (файл; при отсутствии обновления > HeartbeatStaleAlertMultiplier × интервал — оповещение) $HeartbeatInterval = 3600 +$HeartbeatStaleAlertMultiplier = 2 $HeartbeatFile = Join-Path $script:InstallRoot "Logs\last_heartbeat.txt" $DeployUpdateMarkerFile = Join-Path $script:InstallRoot "deploy_last_update.txt" # Построчные правила подавления уведомлений Security 4624/4625 (см. ignore.lst.example в репозитории). @@ -157,6 +158,8 @@ $NetBiosDomainName = "" $ExchangeIisLogPath = "" $ExchangeServerHostForIisExclude = "" $ExchangeIisLogTailLines = 5000 +# Окно поиска IP в IIS: только строки за N минут до события 4740 (локальное время сервера IIS). +$ExchangeIisLogMinutesBeforeLockout = 30 # Очередь оповещений: telegram, email (или tg, mail). Пусто = авто: настроенные каналы, порядок telegram → email. $NotifyOrder = "" @@ -796,6 +799,8 @@ try { $script:IsWorkstation = $false $script:OsInstallKindLabel = "" +$script:MonitorStartedAt = $null +$script:HeartbeatStaleAlertActive = $false function Enable-SecurityAudit { Write-Log "Checking security audit (auditpol) settings..." @@ -1136,7 +1141,7 @@ function Send-Heartbeat { } $ignoreEntries = @(Get-RdpMonitorIgnoreListEntries) if ($ignoreEntries.Count -gt 0) { - $message += "`r`n🚫 Игнорируются: Security 4624/4625 по правилам ignore.lst`r`n" + $message += "`r`n🚫 Игнорируются: по правилам ignore.lst (4624/4625 и/или 4740)`r`n" foreach ($e in $ignoreEntries) { $v = ConvertTo-TelegramHtml ([string]$e.Value) $kindLabel = switch ($e.Kind) { @@ -1153,6 +1158,7 @@ function Send-Heartbeat { } $notifyChain = Get-NotifyChainHuman $message += "`r`n📢 Каналы уведомлений: $(ConvertTo-TelegramHtml $notifyChain)" + $message += "`r`n💓 Heartbeat: файл каждые $HeartbeatInterval с; оповещение, если нет обновления > $($HeartbeatStaleAlertMultiplier)× интервал." if (Test-RDSDeploymentPresent) { $message += "`r`n🔐 RDS (хост сессий): обнаружены компоненты RDS помимо чистого шлюза — в мониторинг входят входы по RDP/RDS на этом узле (Security 4624/4625, типы входа по настройке скрипта)." } @@ -1176,6 +1182,55 @@ function Send-Heartbeat { Write-Log "Отправлено уведомление о запуске скрипта (каналы: $notifyChain)" } else { Write-TextFileUtf8Bom -Path $HeartbeatFile -Text $timestamp + $script:HeartbeatStaleAlertActive = $false + } +} + +function Get-LastHeartbeatTimestamp { + if (-not (Test-Path -LiteralPath $HeartbeatFile)) { return $null } + try { + $txt = (Get-Content -LiteralPath $HeartbeatFile -ErrorAction Stop | Select-Object -First 1) + if ([string]::IsNullOrWhiteSpace($txt)) { return $null } + return [datetime]::ParseExact($txt.Trim(), 'dd.MM.yyyy HH:mm:ss', $null) + } catch { + return $null + } +} + +function Test-AndSendHeartbeatStaleAlert { + if ($null -eq $script:MonitorStartedAt) { return } + $thresholdSec = [double]($HeartbeatInterval * $HeartbeatStaleAlertMultiplier) + if (((Get-Date) - $script:MonitorStartedAt).TotalSeconds -lt $thresholdSec) { return } + + $lastHb = Get-LastHeartbeatTimestamp + $isStale = $false + if ($null -eq $lastHb) { + $isStale = $true + } elseif (((Get-Date) - $lastHb).TotalSeconds -gt $thresholdSec) { + $isStale = $true + } + + if (-not $isStale) { + if ($script:HeartbeatStaleAlertActive) { + $script:HeartbeatStaleAlertActive = $false + } + return + } + if ($script:HeartbeatStaleAlertActive) { return } + + $hHost = ConvertTo-TelegramHtml $env:COMPUTERNAME + $hThreshold = ConvertTo-TelegramHtml ([int]$thresholdSec) + $lastTxt = if ($null -eq $lastHb) { 'нет данных' } else { $lastHb.ToString('dd.MM.yyyy HH:mm:ss') } + $hLast = ConvertTo-TelegramHtml $lastTxt + $msg = "⚠️ Heartbeat монитора не обновлялся`r`n" + $msg += "🖥️ Сервер: $hHost`r`n" + $msg += "⏱️ Порог: $hThreshold с ($HeartbeatStaleAlertMultiplier × интервал $HeartbeatInterval с)`r`n" + $msg += "📄 Последний heartbeat: $hLast`r`n" + $msg += "Проверьте процесс Login_Monitor.ps1 и задачи планировщика RDP-Login-Monitor / Watchdog." + + if (Send-MonitorNotification -Message $msg -EmailSubject 'RDP Login Monitor: нет heartbeat') { + $script:HeartbeatStaleAlertActive = $true + Write-Log "Отправлено оповещение: heartbeat не обновлялся дольше $thresholdSec с" } } @@ -1347,8 +1402,18 @@ function Parse-RdpMonitorIgnoreListLine { if ($line[0] -eq '#' -or $line[0] -eq ';') { return $null } if ($line.StartsWith([char]0xFEFF)) { $line = $line.TrimStart([char]0xFEFF) } + $scopes = @('4624', '4625') + if ($line -match '^(?i)(4740|lockout|блокир)\s*:\s*(.+)$') { + $scopes = @('4740') + $line = $Matches[2].Trim() + } elseif ($line -match '^(?i)(all|\*)\s*:\s*(.+)$') { + $scopes = @('4624', '4625', '4740') + $line = $Matches[2].Trim() + } + if ([string]::IsNullOrWhiteSpace($line)) { return $null } + if ($line -notmatch ':') { - return [pscustomobject]@{ Kind = 'Any'; Value = $line } + return [pscustomobject]@{ Kind = 'Any'; Value = $line; Scopes = $scopes } } $idx = $line.IndexOf(':') @@ -1357,16 +1422,16 @@ function Parse-RdpMonitorIgnoreListLine { if ([string]::IsNullOrWhiteSpace($right)) { return $null } if ($left -match '(?i)(рабоч|workstation|wks)') { - return [pscustomobject]@{ Kind = 'Workstation'; Value = $right } + return [pscustomobject]@{ Kind = 'Workstation'; Value = $right; Scopes = $scopes } } if ($left -match '(?i)(польз|username|subject|account|target\s*user|\buser\b)') { - return [pscustomobject]@{ Kind = 'User'; Value = $right } + return [pscustomobject]@{ Kind = 'User'; Value = $right; Scopes = $scopes } } if ($left -match '(?i)(\bip\b|ip\s*адрес|ipaddress|адрес\s*ip)') { - return [pscustomobject]@{ Kind = 'Ip'; Value = $right } + return [pscustomobject]@{ Kind = 'Ip'; Value = $right; Scopes = $scopes } } - return [pscustomobject]@{ Kind = 'Any'; Value = $right } + return [pscustomobject]@{ Kind = 'Any'; Value = $right; Scopes = $scopes } } function Get-RdpMonitorIgnoreListEntries { @@ -1393,11 +1458,12 @@ function Get-RdpMonitorIgnoreListEntries { $nUser = @($arr | Where-Object { $_.Kind -eq 'User' }).Count $nWks = @($arr | Where-Object { $_.Kind -eq 'Workstation' }).Count $nAny = @($arr | Where-Object { $_.Kind -eq 'Any' }).Count + $n4740 = @($arr | Where-Object { $_.Scopes -contains '4740' }).Count $nTotal = $arr.Count if ($nTotal -eq 0) { - Write-Log "ignore.lst обновлён: список правил пуст, игнорирование по файлу для Security 4624/4625 не задаётся." + Write-Log "ignore.lst обновлён: список правил пуст." } else { - Write-Log ("ignore.lst обновлён: добавлено игнорирование событий 4624/4625 по IP ({0}), пользователю ({1}), рабочей станции ({2}); универсальных правил ({3}). Всего записей: {4}." -f $nIp, $nUser, $nWks, $nAny, $nTotal) + Write-Log ("ignore.lst обновлён: записей {0} (IP {1}, user {2}, wks {3}, any {4}; затрагивают 4740: {5})." -f $nTotal, $nIp, $nUser, $nWks, $nAny, $n4740) } return $script:IgnoreListCache } catch { @@ -1410,13 +1476,25 @@ function Get-RdpMonitorIgnoreListEntries { function Test-RdpMonitorIgnoreListMatch { param( + [Parameter(Mandatory = $true)][string]$EventId, [string]$Username, [string]$ComputerName, - [string]$SourceIP + [string]$SourceIP, + [string[]]$AdditionalIps = @() ) - $entries = @(Get-RdpMonitorIgnoreListEntries) + $entries = @(Get-RdpMonitorIgnoreListEntries | Where-Object { $_.Scopes -contains $EventId }) if ($entries.Count -eq 0) { return $false } + $ipsToCheck = [System.Collections.Generic.List[string]]::new() + if (-not [string]::IsNullOrWhiteSpace($SourceIP) -and $SourceIP -ne '-') { + $ipsToCheck.Add($SourceIP.Trim()) | Out-Null + } + foreach ($ip in $AdditionalIps) { + if ([string]::IsNullOrWhiteSpace($ip)) { continue } + $t = $ip.Trim() + if (-not $ipsToCheck.Contains($t)) { $ipsToCheck.Add($t) | Out-Null } + } + foreach ($e in $entries) { $v = [string]$e.Value if ([string]::IsNullOrWhiteSpace($v)) { continue } @@ -1426,19 +1504,20 @@ function Test-RdpMonitorIgnoreListMatch { if (Test-RdpMonitorUsernameMatchesToken -Username $Username -Token $v) { return $true } } 'Workstation' { + if ($EventId -eq '4740') { continue } if (-not [string]::IsNullOrWhiteSpace($ComputerName) -and $ComputerName -ne '-' -and ($ComputerName -ieq $v)) { return $true } } 'Ip' { - if (-not [string]::IsNullOrWhiteSpace($SourceIP) -and $SourceIP -ne '-' -and ($SourceIP -ieq $v)) { - return $true + foreach ($checkIp in $ipsToCheck) { + if ($checkIp -ieq $v) { return $true } } } 'Any' { if (Test-RdpMonitorStringLooksLikeIPv4 $v) { - if (-not [string]::IsNullOrWhiteSpace($SourceIP) -and $SourceIP -ne '-' -and ($SourceIP -ieq $v)) { - return $true + foreach ($checkIp in $ipsToCheck) { + if ($checkIp -ieq $v) { return $true } } continue } @@ -1446,8 +1525,10 @@ function Test-RdpMonitorIgnoreListMatch { if (Test-RdpMonitorUsernameMatchesToken -Username $Username -Token $v) { return $true } continue } - if (-not [string]::IsNullOrWhiteSpace($ComputerName) -and $ComputerName -ne '-' -and ($ComputerName -ieq $v)) { - return $true + if ($EventId -ne '4740') { + if (-not [string]::IsNullOrWhiteSpace($ComputerName) -and $ComputerName -ne '-' -and ($ComputerName -ieq $v)) { + return $true + } } if (Test-RdpMonitorUsernameMatchesToken -Username $Username -Token $v) { return $true } } @@ -1456,6 +1537,14 @@ function Test-RdpMonitorIgnoreListMatch { return $false } +function Should-IgnoreLockout4740Event { + param( + [string]$Username, + [string[]]$IisClientIps = @() + ) + return Test-RdpMonitorIgnoreListMatch -EventId '4740' -Username $Username -AdditionalIps $IisClientIps +} + function Should-IgnoreEvent { param( [string]$Username, @@ -1514,7 +1603,8 @@ function Should-IgnoreEvent { } if ($EventID -in 4624, 4625) { - if (Test-RdpMonitorIgnoreListMatch -Username $Username -ComputerName $ComputerName -SourceIP $SourceIP) { + if (Test-RdpMonitorIgnoreListMatch -EventId ([string]$EventID) -Username $Username ` + -ComputerName $ComputerName -SourceIP $SourceIP) { return $true } } @@ -1877,11 +1967,17 @@ function Get-Lockout4740EventInfo { function Get-ExchangeActiveSyncIpsFromIisLog { param( [Parameter(Mandatory = $true)][string]$SamAccountName, - [string]$DomainNetBios = "" + [string]$DomainNetBios = "", + [Parameter(Mandatory = $true)][datetime]$ReferenceTime ) if ([string]::IsNullOrWhiteSpace($ExchangeIisLogPath)) { return @() } + $minutes = [int]$ExchangeIisLogMinutesBeforeLockout + if ($minutes -lt 1) { $minutes = 1 } + $windowStart = $ReferenceTime.AddMinutes(-$minutes) + $windowEnd = $ReferenceTime.AddMinutes(2) + $logDir = $ExchangeIisLogPath.TrimEnd('\') - $logFile = Join-Path $logDir ("u_ex" + (Get-Date).ToUniversalTime().ToString("yyMMdd") + ".log") + $logFile = Join-Path $logDir ("u_ex" + $ReferenceTime.ToUniversalTime().ToString("yyMMdd") + ".log") if (-not (Test-Path -LiteralPath $logFile)) { Write-Log "IIS: файл лога не найден: $logFile" return @() @@ -1905,8 +2001,24 @@ function Get-ExchangeActiveSyncIpsFromIisLog { try { $lines = Get-Content -LiteralPath $logFile -Tail $ExchangeIisLogTailLines -ErrorAction Stop foreach ($line in $lines) { + if ($line.StartsWith('#')) { continue } if ($line -notlike '*401 *' -or $line -notlike '*ActiveSync*') { continue } if ($line -notlike "*$userPattern1*" -and $line -notlike "*$userPattern2*") { continue } + + $lineTime = $null + if ($line -match '^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})') { + try { + $lineTime = [datetime]::ParseExact( + "$($Matches[1]) $($Matches[2])", + 'yyyy-MM-dd HH:mm:ss', + $null + ) + } catch { } + } + if ($null -ne $lineTime -and ($lineTime -lt $windowStart -or $lineTime -gt $windowEnd)) { + continue + } + if ($line -notmatch '(?:\d{1,3}\.){3}\d{1,3}') { continue } $ip = $Matches[0] if ($excludeHosts -contains $ip) { continue } @@ -1949,7 +2061,7 @@ function Format-Lockout4740TelegramMessage { $message += ('• {0} ({1})' -f (ConvertTo-TelegramHtml $ip), $netType) + "`r`n" } } elseif (-not [string]::IsNullOrWhiteSpace($ExchangeIisLogPath)) { - $message += "`r`nIP в логах IIS ActiveSync для этого пользователя не найдены.`r`n" + $message += "`r`nIP в IIS ActiveSync не найдены (окно $ExchangeIisLogMinutesBeforeLockout мин до блокировки, 401).`r`n" } return $message @@ -1981,12 +2093,15 @@ function Start-LoginMonitor { if ($lockout4740Enabled) { Write-Log "Мониторинг блокировок AD (4740) включён на этом КД ($LockoutMonitorDomainController)." if (-not [string]::IsNullOrWhiteSpace($ExchangeIisLogPath)) { - Write-Log "Обогащение: IIS ActiveSync — $ExchangeIisLogPath" + Write-Log "Обогащение: IIS ActiveSync — $ExchangeIisLogPath (окно ${ExchangeIisLogMinutesBeforeLockout} мин до 4740)" } } elseif (-not [string]::IsNullOrWhiteSpace($LockoutMonitorDomainController)) { Write-Log "Мониторинг 4740 задан для КД '$LockoutMonitorDomainController', но этот узел — $env:COMPUTERNAME (блокировки не отслеживаются)." } + $script:MonitorStartedAt = Get-Date + $script:HeartbeatStaleAlertActive = $false + Cleanup-OldLogs Send-Heartbeat -IsStartup Enable-SecurityAudit @@ -2010,8 +2125,9 @@ function Start-LoginMonitor { while ($true) { try { - # ignore.lst: сверка mtime и лог при изменении файла (не только при событиях 4624/4625). + # ignore.lst: сверка mtime и лог при изменении файла. [void](Get-RdpMonitorIgnoreListEntries) + Test-AndSendHeartbeatStaleAlert $events = Get-WinEvent -FilterHashtable @{ LogName = 'Security' @@ -2138,7 +2254,13 @@ function Start-LoginMonitor { if ([string]::IsNullOrWhiteSpace($lo.Username)) { continue } $domainForIis = if ([string]::IsNullOrWhiteSpace($lo.Domain)) { $NetBiosDomainName } else { $lo.Domain } - $iisIps = @(Get-ExchangeActiveSyncIpsFromIisLog -SamAccountName $lo.Username -DomainNetBios $domainForIis) + $iisIps = @(Get-ExchangeActiveSyncIpsFromIisLog -SamAccountName $lo.Username ` + -DomainNetBios $domainForIis -ReferenceTime $lo.TimeCreated) + + if (Should-IgnoreLockout4740Event -Username $lo.Username -IisClientIps $iisIps) { + Write-Log "Skip 4740 (ignore.lst): User=$($lo.Username)" + continue + } $msg = Format-Lockout4740TelegramMessage -Username $lo.Username -Domain $lo.Domain ` -TimeCreated $lo.TimeCreated -IisClientIps $iisIps diff --git a/README.md b/README.md index 571ddbc..80a6c59 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ PowerShell-набор для мониторинга входов в Windows с 5. Логи и служебные файлы будут в: - `C:\ProgramData\RDP-login-monitor\Logs\` 6. (Опционально) Подавление части алертов по списку — см. раздел **«7) ignore.lst»** ниже. -7. (Опционально) Мониторинг блокировок AD на КД — **`$LockoutMonitorDomainController`**, **`$NetBiosDomainName`**, **`$ExchangeIisLogPath`** (UNC к логам IIS ActiveSync), **`$ExchangeServerHostForIisExclude`** (IP сервера Exchange, не считать клиентским). В оповещении: пользователь из 4740 и IP из IIS (401 + ActiveSync). На других узлах блок 4740 не активен. +7. (Опционально) Мониторинг блокировок AD на КД — **`$LockoutMonitorDomainController`**, **`$NetBiosDomainName`**, **`$ExchangeIisLogPath`**, **`$ExchangeIisLogMinutesBeforeLockout`** (по умолчанию 30), **`$ExchangeServerHostForIisExclude`**. В оповещении: пользователь из 4740 и IP из IIS за окно до блокировки. В **`ignore.lst`** префикс **`4740:`** или **`all:`** — см. **`ignore.lst.example`**. +8. Heartbeat: при отсутствии обновления **`Logs\last_heartbeat.txt`** дольше **`$HeartbeatStaleAlertMultiplier` × `$HeartbeatInterval`** (по умолчанию 2×1 ч) — оповещение в Telegram/Email. ## 2) Ручной запуск @@ -89,9 +90,9 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-logi ## 7) Подавление уведомлений Security: `ignore.lst` -В каталоге установки можно положить файл **`C:\ProgramData\RDP-login-monitor\ignore.lst`** (рядом с **`Login_Monitor.ps1`**). Правила из списка проверяются **только** для Telegram-уведомлений по событиям **`4624`/`4625`** журнала Security (успех/неудача входа). Жёстко заданные в скрипте исключения (`ExcludedUsers`, локальный IP, сервисные учётные записи и т.д.) по-прежнему действуют для всех типов событий; **`ignore.lst`** добавляет к ним **дополнительные** совпадения именно для **4624/4625**. +В каталоге установки можно положить файл **`C:\ProgramData\RDP-login-monitor\ignore.lst`** (рядом с **`Login_Monitor.ps1`**). По умолчанию правила относятся к **`4624`/`4625`**; префикс **`4740:`** (или **`lockout:`**, **`блокир:`**) — только к блокировкам учётной записи; **`all:`** — и входы, и **4740**. Для **4740** тип **`ip:`** сравнивается с IP из IIS ActiveSync. Жёсткие исключения в скрипте по-прежнему для всех типов событий, кроме **4740** (там только `ignore.lst` и встроенные проверки пользователя). -События **RD Gateway (`302`/`303`)**, **RCM `1149`**, ежедневный отчёт и heartbeat **этим файлом не настраиваются** (для `1149` список не используется, даже если формально вызывается общая функция фильтрации). +События **RD Gateway (`302`/`303`)**, **RCM `1149`**, ежедневный отчёт и heartbeat **этим файлом не настраиваются**. ### Как читается файл @@ -101,7 +102,17 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\RDP-logi - Строка с **`:`**: берётся **первая** двоеточие — всё слева (после обрезки пробелов) определяет тип правила, всё справа — значение. Если справа пусто, строка игнорируется. - Строка **без** **`:`**: целиком трактуется как правило типа «любое совпадение» (см. ниже). -### Типы правил (левая часть до первого `:`) +### Префикс области (в самом начале строки, до типа правила) + +| Префикс | События | +| --- | --- | +| *(нет)* | **4624**, **4625** | +| `4740:`, `lockout:`, `блокир:` | **4740** | +| `all:`, `*:` | **4624**, **4625**, **4740** | + +Пример: `4740:user:svc_sync` — не слать оповещение о блокировке этой УЗ. + +### Типы правил (левая часть до первого `:` после префикса области) | Левая часть (фрагменты совпадают как regex, без учёта регистра) | Поле события | | --- | --- | diff --git a/ignore.lst.example b/ignore.lst.example index 5ea7f28..83132d6 100644 --- a/ignore.lst.example +++ b/ignore.lst.example @@ -2,23 +2,29 @@ # C:\ProgramData\RDP-login-monitor\ignore.lst # # Каждая непустая строка — одно правило. Строки с # или ; в начале — комментарии. -# Подавляет только уведомления Security 4624/4625 (не RD Gateway, не RCM 1149). # -# Форматы: +# Область действия (префикс в начале строки, необязателен): +# (по умолчанию) — только Security 4624/4625 +# 4740: — только блокировка учётной записи (4740); для IP — любой IP из IIS +# all: — и 4624/4625, и 4740 +# +# Форматы правила (после префикса области): # user:domain\user # user:user -# workstation:IVANOV +# workstation:IVANOV (не для 4740) # ip:111.222.333.444 -# Можно вставить «как в Telegram» (берётся значение после первого «:»): -# 👤 Пользователь: user -# 🖥️ Рабочая станция (клиент из события): IVANOV -# 🌐 IP адрес: 111.222.333.444 # -# Строка без префикса: -# IVANOV — совпадение с именем рабочей станции ИЛИ с пользователем (sam) ИЛИ с IP (если строка — IPv4) -# 111.222.333.444 — только IP (в реальной конфигурации укажите действительный IPv4 клиента) -# domain\user — пользователь целиком +# Строка без префикса типа: +# IVANOV — рабочая станция / пользователь / IP (IPv4) +# domain\user — пользователь -# user:domain\user -# workstation:IVANOV -# ip:111.222.333.444 +# --- только входы 4624/4625 --- +# user:domain\service_account +# ip:192.168.1.100 + +# --- только блокировки 4740 --- +# 4740:user:test.user +# 4740:ip:203.0.113.50 + +# --- все перечисленные события --- +# all:user:domain\noise_account diff --git a/version.txt b/version.txt index 4cda8f1..8af85be 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.5.2 +1.5.3