fix: quarantine corrupt SAC spool files, flush newest first (2.1.6-SAC)

Reject null-padded spool payloads to rejected/, read UTF-16 spool, process recent events first, and raise flush batch to 20 so daily reports are not blocked by legacy junk.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 09:09:54 +10:00
parent ccd50a085d
commit 8a937b8f17
3 changed files with 106 additions and 11 deletions
+2 -2
View File
@@ -90,7 +90,7 @@ $script:SkipLogDetailLimit = 15
# строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). # строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах).
# Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только # Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только
# исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). # исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1).
$ScriptVersion = "2.1.5-SAC" $ScriptVersion = "2.1.6-SAC"
# Логи (все под InstallRoot) # Логи (все под InstallRoot)
$LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" $LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log"
@@ -5016,7 +5016,7 @@ function Start-LoginMonitor {
$nextReportCheck = Check-AndSendDailyReport $nextReportCheck = Check-AndSendDailyReport
} }
if ($script:SacClientLoaded) { if ($script:SacClientLoaded) {
Invoke-SacFlushSpool -MaxFiles 5 | Out-Null Invoke-SacFlushSpool -MaxFiles 20 | Out-Null
if ((Get-SacNormalizedMode) -ne 'off' -and $now -ge $script:NextSacCommandPollTime) { if ((Get-SacNormalizedMode) -ne 'off' -and $now -ge $script:NextSacCommandPollTime) {
Invoke-SacProcessPendingCommands | Out-Null Invoke-SacProcessPendingCommands | Out-Null
$script:NextSacCommandPollTime = $now.AddSeconds($SacCommandPollIntervalSec) $script:NextSacCommandPollTime = $now.AddSeconds($SacCommandPollIntervalSec)
+102 -7
View File
@@ -474,6 +474,84 @@ function Move-SacSpoolToRejected {
Move-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue Move-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
} }
function Test-SacGuidString {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
return ($Value.Trim() -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
}
function Get-SacEventIdFromSpoolFileName {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
$base = [System.IO.Path]::GetFileNameWithoutExtension($Path)
if (Test-SacGuidString -Value $base) {
return $base.ToLowerInvariant()
}
return $null
}
function Get-SacEventIdFromJsonText {
param([string]$JsonText)
if ([string]::IsNullOrWhiteSpace($JsonText)) { return $null }
if ($JsonText -match '"event_id"\s*:\s*"([0-9a-fA-F-]{36})"') {
return $Matches[1].ToLowerInvariant()
}
return $null
}
function Test-SacSpoolBytesCorrupt {
param([byte[]]$Bytes)
if ($null -eq $Bytes -or $Bytes.Length -lt 2) { return $true }
# UTF-16/UTF-32 с нулевым стартом или «пустой» spool — не JSON (см. hex 00 00 00 00).
if ($Bytes[0] -eq 0 -and $Bytes[1] -eq 0) { return $true }
return $false
}
function Read-SacSpoolFileBytes {
param([Parameter(Mandatory = $true)][string]$Path)
return [System.IO.File]::ReadAllBytes($Path)
}
function Read-SacSpoolFileText {
param([Parameter(Mandatory = $true)][string]$Path)
$bytes = Read-SacSpoolFileBytes -Path $Path
if (Test-SacSpoolBytesCorrupt -Bytes $bytes) { return $null }
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
return [System.Text.Encoding]::Unicode.GetString($bytes)
}
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0x7B -and $Bytes[1] -eq 0) {
return [System.Text.Encoding]::Unicode.GetString($bytes)
}
$utf8 = New-Object System.Text.UTF8Encoding $false
return $utf8.GetString($bytes)
}
function Move-SacSpoolFileToRejected {
param(
[Parameter(Mandatory = $true)][string]$SpoolFilePath,
[string]$Reason = ''
)
$eventId = Get-SacEventIdFromSpoolFileName -Path $SpoolFilePath
if ($Reason) {
$label = if ($eventId) { "$eventId.json" } else { [System.IO.Path]::GetFileName($SpoolFilePath) }
Write-SacLog "WARN: SAC spool → rejected ($Reason): $label"
}
if ($eventId) {
Move-SacSpoolToRejected -EventId $eventId
return
}
$dir = Get-SacSpoolDirResolved
$name = [System.IO.Path]::GetFileName($SpoolFilePath)
$src = Join-Path $dir $name
if (-not (Test-Path -LiteralPath $src)) { return }
$rejDir = Join-Path $dir 'rejected'
if (-not (Test-Path -LiteralPath $rejDir)) {
New-Item -ItemType Directory -Path $rejDir -Force | Out-Null
}
$dst = Join-Path $rejDir $name
Move-Item -LiteralPath $src -Destination $dst -Force -ErrorAction SilentlyContinue
}
function Get-SacPostBodyBytes { function Get-SacPostBodyBytes {
param([string]$JsonText) param([string]$JsonText)
$bytes = Get-SacUtf8Bytes -Text $JsonText $bytes = Get-SacUtf8Bytes -Text $JsonText
@@ -588,7 +666,10 @@ function Write-SacPostBodyDiagnostic {
} }
function Invoke-SacPostPayload { function Invoke-SacPostPayload {
param([string]$JsonBody) param(
[string]$JsonBody,
[string]$SpoolFilePath = ''
)
if (-not (Test-SacConfigured)) { return $false } if (-not (Test-SacConfigured)) { return $false }
if (-not (Test-SacShouldAttemptSend)) { return $false } if (-not (Test-SacShouldAttemptSend)) { return $false }
@@ -597,11 +678,15 @@ function Invoke-SacPostPayload {
if ([string]::IsNullOrWhiteSpace($ingest)) { return $false } if ([string]::IsNullOrWhiteSpace($ingest)) { return $false }
$jsonText = Repair-SacJsonText -Text (Get-SacSingleString -Value $JsonBody -Label 'spool payload') $jsonText = Repair-SacJsonText -Text (Get-SacSingleString -Value $JsonBody -Label 'spool payload')
if ($jsonText -notmatch '"event_id"\s*:\s*"([0-9a-fA-F-]{36})"') { $eventId = Get-SacEventIdFromJsonText -JsonText $jsonText
if (-not $eventId) {
if (-not [string]::IsNullOrWhiteSpace($SpoolFilePath)) {
Move-SacSpoolFileToRejected -SpoolFilePath $SpoolFilePath -Reason 'no event_id in JSON body'
} else {
Write-SacLog 'WARN: SAC JSON has no event_id (uuid); skip POST' Write-SacLog 'WARN: SAC JSON has no event_id (uuid); skip POST'
}
return $false return $false
} }
$eventId = $Matches[1]
$eventType = 'unknown' $eventType = 'unknown'
if ($jsonText -match '"type"\s*:\s*"([^"]+)"') { if ($jsonText -match '"type"\s*:\s*"([^"]+)"') {
$eventType = $Matches[1] $eventType = $Matches[1]
@@ -838,15 +923,25 @@ function Invoke-SacFlushSpool {
$dir = Get-SacSpoolDirResolved $dir = Get-SacSpoolDirResolved
if (-not (Test-Path -LiteralPath $dir)) { return } if (-not (Test-Path -LiteralPath $dir)) { return }
$files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime) # Сначала свежие события (daily report); битые записи уходят в rejected и не блокируют очередь.
$files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending)
$count = 0 $count = 0
foreach ($f in $files) { foreach ($f in $files) {
$count++ $count++
if ($count -gt $MaxFiles) { break } if ($count -gt $MaxFiles) { break }
try { try {
$utf8 = New-Object System.Text.UTF8Encoding $false $bytes = Read-SacSpoolFileBytes -Path $f.FullName
$json = [System.IO.File]::ReadAllText($f.FullName, $utf8) if (Test-SacSpoolBytesCorrupt -Bytes $bytes) {
Invoke-SacPostPayload -JsonBody $json | Out-Null Move-SacSpoolFileToRejected -SpoolFilePath $f.FullName -Reason 'null or empty payload'
continue
}
$json = Read-SacSpoolFileText -Path $f.FullName
if ([string]::IsNullOrWhiteSpace($json)) {
Move-SacSpoolFileToRejected -SpoolFilePath $f.FullName -Reason 'unreadable payload'
continue
}
Invoke-SacPostPayload -JsonBody $json -SpoolFilePath $f.FullName | Out-Null
} catch { } catch {
Write-SacLog "WARN: SAC spool flush failed for $($f.Name): $($_.Exception.Message)" Write-SacLog "WARN: SAC spool flush failed for $($f.Name): $($_.Exception.Message)"
} }
+1 -1
View File
@@ -1 +1 @@
2.1.5-SAC 2.1.6-SAC