feat: persistent Security poll cursor with lookback replay (2.0.36-SAC)

Save last_security_poll.txt between runs and replay Security events up to SecurityEventsLookbackMinutes on startup so slow boot or late agent start does not miss RDP/WinRM-related logons.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-16 09:39:43 +10:00
parent d5db38e2ae
commit f48aee1d93
4 changed files with 102 additions and 3 deletions
+51 -2
View File
@@ -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'
+1
View File
@@ -15,6 +15,7 @@ $suites = @(
'Test-ScriptSyntaxAll.ps1',
'Test-TaskQueryModule.ps1',
'Test-DeployTaskLimit.ps1',
'Test-SecurityPollCursor.ps1',
'Test-SendDeploySacNotice.ps1'
)
+49
View File
@@ -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'
}
+1 -1
View File
@@ -1 +1 @@
2.0.35-SAC
2.0.36-SAC