diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1 index a26679c..0420a6a 100644 --- a/Login_Monitor.ps1 +++ b/Login_Monitor.ps1 @@ -90,7 +90,7 @@ $script:SkipLogDetailLimit = 15 # строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). # Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только # исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). -$ScriptVersion = "2.0.35-SAC" +$ScriptVersion = "2.0.36-SAC" # Логи (все под InstallRoot) $LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" @@ -111,6 +111,7 @@ $HeartbeatStaleAlertMultiplier = 2 $LoginSuccessNotifyDedupSeconds = 90 $HeartbeatFile = Join-Path $script:InstallRoot "Logs\last_heartbeat.txt" $GatewayPollCursorFile = Join-Path $script:InstallRoot "Logs\last_rdgateway_poll.txt" +$SecurityPollCursorFile = Join-Path $script:InstallRoot "Logs\last_security_poll.txt" $DeployUpdateMarkerFile = Join-Path $script:InstallRoot "deploy_last_update.txt" # Построчные правила подавления уведомлений Security 4624/4625 (см. ignore.lst.example в репозитории). $script:IgnoreListPath = Join-Path $script:InstallRoot "ignore.lst" @@ -132,6 +133,8 @@ $RDGatewayLogName = "Microsoft-Windows-TerminalServices-Gateway/Operational" $RDGatewayEvents = @(302, 303) # Макс. возраст сохранённого курсора RD Gateway (мин): если файл старше — replay не делаем. $GatewayEventsLookbackMinutes = 60 +# Security 4624/4625/4648: персистентный cursor + replay не старше N мин (медленный boot / поздний старт агента). +$SecurityEventsLookbackMinutes = 60 # RDP Remote Connection Manager (workstations): User authentication succeeded — событие 1149 $RcmLogName = "Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational" @@ -3563,6 +3566,51 @@ function Set-RdpGatewayPollCursor { } } +function Get-RdpSecurityPollCursor { + $now = Get-Date + $fresh = $now.AddSeconds(-10) + $maxAgeMin = [math]::Max(1, [int]$SecurityEventsLookbackMinutes) + $lookbackFloor = $now.AddMinutes(-1 * $maxAgeMin) + + if (-not (Test-Path -LiteralPath $SecurityPollCursorFile)) { + Write-Log "Security: cursor отсутствует — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss')) (lookback ${maxAgeMin} мин)." + return $lookbackFloor + } + try { + $raw = (Get-Content -LiteralPath $SecurityPollCursorFile -TotalCount 1 -ErrorAction Stop).Trim() + if ([string]::IsNullOrWhiteSpace($raw)) { + Write-Log "Security: cursor пуст — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss')) (lookback ${maxAgeMin} мин)." + return $lookbackFloor + } + $parsed = [datetime]::Parse($raw, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind) + if ($parsed.Kind -eq [DateTimeKind]::Utc) { + $parsed = $parsed.ToLocalTime() + } + if ($parsed -lt $lookbackFloor) { + Write-Log "Security: cursor старше $maxAgeMin мин — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss'))." + return $lookbackFloor + } + Write-Log "Security: cursor=$($parsed.ToString('dd.MM.yyyy HH:mm:ss')) (lookback cap ${maxAgeMin} мин)." + return $parsed + } catch { + Write-Log "Security: не удалось прочитать cursor ($SecurityPollCursorFile) — replay с $($lookbackFloor.ToString('dd.MM.yyyy HH:mm:ss'))." + return $lookbackFloor + } +} + +function Set-RdpSecurityPollCursor { + param([Parameter(Mandatory = $true)][datetime]$Cursor) + try { + $dir = Split-Path -Parent $SecurityPollCursorFile + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + Write-TextFileUtf8Bom -Path $SecurityPollCursorFile -Text ($Cursor.ToString('o')) + } catch { + Write-Log "WARN: Security cursor save failed: $($_.Exception.Message)" + } +} + function Test-RdgGatewayBenignErrorCode { param([string]$ErrorCode) if ([string]::IsNullOrWhiteSpace($ErrorCode)) { return $true } @@ -4215,7 +4263,7 @@ function Start-LoginMonitor { $nextInventoryTime = (Get-Date).AddSeconds($InventoryIntervalSec) $nextRotationCheck = Check-AndRotateLog $nextReportCheck = Check-AndSendDailyReport - $lastCheckTime = (Get-Date).AddSeconds(-10) + $lastCheckTime = Get-RdpSecurityPollCursor if ($rdGatewayAvailable) { $lastGatewayCheckTime = Get-RdpGatewayPollCursor Write-Log "RD Gateway: опрос 302/303 включён, cursor=$($lastGatewayCheckTime.ToString('dd.MM.yyyy HH:mm:ss'))" @@ -4425,6 +4473,7 @@ function Start-LoginMonitor { } $lastCheckTime = Update-MonitorPollCursor -CurrentCursor $lastCheckTime -Events @($events) } + Set-RdpSecurityPollCursor -Cursor $lastCheckTime if ($rdGatewayAvailable) { Set-MonitorLoopPhase -Phase 'poll_rdgateway' diff --git a/tools/Run-RdpMonitorTests.ps1 b/tools/Run-RdpMonitorTests.ps1 index bf60066..827264a 100644 --- a/tools/Run-RdpMonitorTests.ps1 +++ b/tools/Run-RdpMonitorTests.ps1 @@ -15,6 +15,7 @@ $suites = @( 'Test-ScriptSyntaxAll.ps1', 'Test-TaskQueryModule.ps1', 'Test-DeployTaskLimit.ps1', + 'Test-SecurityPollCursor.ps1', 'Test-SendDeploySacNotice.ps1' ) diff --git a/tools/tests/Test-SecurityPollCursor.ps1 b/tools/tests/Test-SecurityPollCursor.ps1 new file mode 100644 index 0000000..9d291ba --- /dev/null +++ b/tools/tests/Test-SecurityPollCursor.ps1 @@ -0,0 +1,49 @@ +. (Join-Path $PSScriptRoot '_TestLib.ps1') + +function Test-RdpSecurityPollCursorResolve { + param( + [datetime]$Now, + [int]$MaxAgeMinutes, + [Nullable[datetime]]$SavedCursor + ) + + $maxAgeMin = [math]::Max(1, $MaxAgeMinutes) + $lookbackFloor = $Now.AddMinutes(-1 * $maxAgeMin) + if ($null -eq $SavedCursor) { + return $lookbackFloor + } + if ($SavedCursor -lt $lookbackFloor) { + return $lookbackFloor + } + return $SavedCursor +} + +Invoke-RdpMonitorTestCase -Name 'Security cursor: missing file uses lookback floor' -Script { + $now = Get-Date '2026-06-15T12:00:00' + $resolved = Test-RdpSecurityPollCursorResolve -Now $now -MaxAgeMinutes 60 -SavedCursor $null + $expected = $now.AddMinutes(-60) + Assert-True -Condition ($resolved -eq $expected) -Message 'Expected lookback floor when cursor missing' +} + +Invoke-RdpMonitorTestCase -Name 'Security cursor: stale saved cursor capped to lookback floor' -Script { + $now = Get-Date '2026-06-15T12:00:00' + $stale = $now.AddMinutes(-120) + $resolved = Test-RdpSecurityPollCursorResolve -Now $now -MaxAgeMinutes 60 -SavedCursor $stale + $expected = $now.AddMinutes(-60) + Assert-True -Condition ($resolved -eq $expected) -Message 'Expected cap at lookback floor for stale cursor' +} + +Invoke-RdpMonitorTestCase -Name 'Security cursor: recent saved cursor preserved' -Script { + $now = Get-Date '2026-06-15T12:00:00' + $recent = $now.AddMinutes(-5) + $resolved = Test-RdpSecurityPollCursorResolve -Now $now -MaxAgeMinutes 60 -SavedCursor $recent + Assert-True -Condition ($resolved -eq $recent) -Message 'Expected recent cursor unchanged' +} + +Invoke-RdpMonitorTestCase -Name 'Login_Monitor defines Security poll cursor helpers' -Script { + $repo = Get-RdpMonitorRepoRoot + $text = Get-Content -LiteralPath (Join-Path $repo 'Login_Monitor.ps1') -Raw + Assert-True -Condition ($text -match 'Get-RdpSecurityPollCursor') -Message 'Missing Get-RdpSecurityPollCursor' + Assert-True -Condition ($text -match 'Set-RdpSecurityPollCursor') -Message 'Missing Set-RdpSecurityPollCursor' + Assert-True -Condition ($text -match '\$SecurityPollCursorFile') -Message 'Missing SecurityPollCursorFile' +} diff --git a/version.txt b/version.txt index 7d0946d..e17fcc9 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.0.35-SAC +2.0.36-SAC