diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1 index 829270d..eb80900 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.38-SAC" +$ScriptVersion = "2.0.39-SAC" # Логи (все под InstallRoot) $LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" @@ -108,7 +108,7 @@ $StartupRebootDetectMinutes = 5 # Инвентаризация железа/ПО для SAC (agent.inventory); интервал опроса, сек (12 ч). $InventoryIntervalSec = 43200 $GetInventory = 1 -$HeartbeatStaleAlertMultiplier = 2 +$SacCommandPollIntervalSec = 60 # Один RDP-вход часто даёт 2+ события 4624 с одним временем — не слать дубли в Telegram/SAC. $LoginSuccessNotifyDedupSeconds = 90 $HeartbeatFile = Join-Path $script:InstallRoot "Logs\last_heartbeat.txt" @@ -4404,6 +4404,7 @@ function Start-LoginMonitor { $adminShareMonitoringEnabled = Test-MonitorFeatureEnabled -Value $EnableAdminShareMonitoring $nextHeartbeatTime = (Get-Date).AddSeconds($HeartbeatInterval) + $script:NextSacCommandPollTime = Get-Date $nextInventoryTime = (Get-Date).AddSeconds($InventoryIntervalSec) $nextRotationCheck = Check-AndRotateLog $nextReportCheck = Check-AndSendDailyReport @@ -4934,6 +4935,10 @@ function Start-LoginMonitor { } if ($script:SacClientLoaded) { Invoke-SacFlushSpool -MaxFiles 5 | Out-Null + if ((Get-SacNormalizedMode) -ne 'off' -and $now -ge $script:NextSacCommandPollTime) { + Invoke-SacProcessPendingCommands | Out-Null + $script:NextSacCommandPollTime = $now.AddSeconds($SacCommandPollIntervalSec) + } } } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } diff --git a/Sac-Client.ps1 b/Sac-Client.ps1 index 708d8a3..ac98b37 100644 --- a/Sac-Client.ps1 +++ b/Sac-Client.ps1 @@ -853,6 +853,232 @@ function Invoke-SacFlushSpool { } } +function Get-SacAgentCommandsUrl { + $base = Get-SacBaseUrl + if ([string]::IsNullOrWhiteSpace($base)) { return $null } + return "$base/api/v1/agent/commands" +} + +function Get-SacAgentCommandResultUrl { + param([Parameter(Mandatory = $true)][string]$CommandId) + $base = Get-SacBaseUrl + if ([string]::IsNullOrWhiteSpace($base)) { return $null } + return "$base/api/v1/agent/commands/$CommandId/result" +} + +function Invoke-SacHttpGet { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [int]$TimeoutSec = 12 + ) + Invoke-SacTlsPrep + try { + $resp = Invoke-WebRequest -Uri $Uri -Method Get -UseBasicParsing -TimeoutSec $TimeoutSec ` + -Headers @{ Authorization = "Bearer $SacApiKey" } + return @{ StatusCode = [int]$resp.StatusCode; Content = [string]$resp.Content } + } catch { + $code = 0 + $content = '' + if ($_.Exception.Response) { + $code = [int]$_.Exception.Response.StatusCode + try { + $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()) + $content = $reader.ReadToEnd() + $reader.Close() + } catch { } + } + return @{ StatusCode = $code; Content = $content; Error = $_.Exception.Message } + } +} + +function Invoke-SacHttpPostJson { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$JsonBody, + [int]$TimeoutSec = 12 + ) + Invoke-SacTlsPrep + $bytes = Get-SacUtf8Bytes -Text $JsonBody + try { + $resp = Invoke-WebRequest -Uri $Uri -Method Post -UseBasicParsing -TimeoutSec $TimeoutSec ` + -Headers @{ Authorization = "Bearer $SacApiKey" } ` + -ContentType 'application/json; charset=utf-8' ` + -Body $bytes + return @{ StatusCode = [int]$resp.StatusCode; Content = [string]$resp.Content } + } catch { + $code = 0 + $content = '' + if ($_.Exception.Response) { + $code = [int]$_.Exception.Response.StatusCode + try { + $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()) + $content = $reader.ReadToEnd() + $reader.Close() + } catch { } + } + return @{ StatusCode = $code; Content = $content; Error = $_.Exception.Message } + } +} + +function Invoke-SacCaptureProcess { + param( + [Parameter(Mandatory = $true)][string]$CommandLine + ) + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = 'cmd.exe' + $psi.Arguments = "/c $CommandLine" + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + $p = New-Object System.Diagnostics.Process + $p.StartInfo = $psi + [void]$p.Start() + $stdout = $p.StandardOutput.ReadToEnd() + $stderr = $p.StandardError.ReadToEnd() + if (-not $p.WaitForExit(60000)) { + try { $p.Kill() } catch { } + return @{ ExitCode = -1; Stdout = $stdout; Stderr = 'timeout' } + } + return @{ ExitCode = $p.ExitCode; Stdout = $stdout; Stderr = $stderr } +} + +function Invoke-SacRunWithRunAs { + param( + [Parameter(Mandatory = $true)][string]$CommandLine, + $RunAs = $null + ) + if ($null -eq $RunAs -or [string]::IsNullOrWhiteSpace($RunAs.user)) { + return (Invoke-SacCaptureProcess -CommandLine $CommandLine) + } + $user = [string]$RunAs.user + $password = [string]$RunAs.password + if ([string]::IsNullOrWhiteSpace($password)) { + return (Invoke-SacCaptureProcess -CommandLine $CommandLine) + } + $taskName = "SacCmd_$([guid]::NewGuid().ToString('N').Substring(0, 12))" + $outFile = Join-Path $env:TEMP "$taskName.out.txt" + $errFile = Join-Path $env:TEMP "$taskName.err.txt" + if (Test-Path -LiteralPath $outFile) { Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $errFile) { Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue } + $wrapped = "/c `"$CommandLine > `"$outFile`" 2> `"$errFile`"`"" + try { + $action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument $wrapped + Register-ScheduledTask -TaskName $taskName -Action $action -User $user -Password $password ` + -RunLevel Highest -Force | Out-Null + Start-ScheduledTask -TaskName $taskName + $deadline = (Get-Date).AddSeconds(45) + do { + Start-Sleep -Milliseconds 300 + $state = (Get-ScheduledTask -TaskName $taskName).State + } while ($state -eq 'Running' -and (Get-Date) -lt $deadline) + $stdout = if (Test-Path -LiteralPath $outFile) { Get-Content -LiteralPath $outFile -Raw -ErrorAction SilentlyContinue } else { '' } + $stderr = if (Test-Path -LiteralPath $errFile) { Get-Content -LiteralPath $errFile -Raw -ErrorAction SilentlyContinue } else { '' } + return @{ ExitCode = 0; Stdout = [string]$stdout; Stderr = [string]$stderr } + } catch { + return @{ ExitCode = 1; Stdout = ''; Stderr = $_.Exception.Message } + } finally { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-SacAgentCommand { + param( + [Parameter(Mandatory = $true)]$Command, + $RunAs = $null + ) + $type = [string]$Command.type + switch ($type) { + 'qwinsta' { + return (Invoke-SacRunWithRunAs -CommandLine 'qwinsta.exe' -RunAs $RunAs) + } + 'logoff' { + $sid = $null + if ($Command.params -and $Command.params.session_id) { + $sid = [int]$Command.params.session_id + } + if (-not $sid) { + return @{ ExitCode = 1; Stdout = ''; Stderr = 'missing session_id' } + } + $line = "logoff.exe $sid /v" + return (Invoke-SacRunWithRunAs -CommandLine $line -RunAs $RunAs) + } + default { + return @{ ExitCode = 1; Stdout = ''; Stderr = "unknown command type: $type" } + } + } +} + +function Submit-SacAgentCommandResult { + param( + [Parameter(Mandatory = $true)][string]$CommandId, + [Parameter(Mandatory = $true)][string]$Status, + [string]$Stdout = '', + [string]$Stderr = '' + ) + $url = Get-SacAgentCommandResultUrl -CommandId $CommandId + if ([string]::IsNullOrWhiteSpace($url)) { return $false } + $body = @{ + status = $Status + stdout = $Stdout + stderr = $Stderr + } | ConvertTo-Json -Compress + $timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 } + $resp = Invoke-SacHttpPostJson -Uri $url -JsonBody $body -TimeoutSec $timeout + return ($resp.StatusCode -in 200, 201) +} + +function Invoke-SacProcessPendingCommands { + if ((Get-SacNormalizedMode) -eq 'off') { return 0 } + if (-not (Test-SacConfigured)) { return 0 } + + $pollUrl = Get-SacAgentCommandsUrl + if ([string]::IsNullOrWhiteSpace($pollUrl)) { return 0 } + + $agentId = Get-SacAgentInstanceId + $timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 } + $uri = "$pollUrl?agent_instance_id=$([uri]::EscapeDataString($agentId))" + $get = Invoke-SacHttpGet -Uri $uri -TimeoutSec $timeout + if ($get.StatusCode -ne 200) { + if ($get.StatusCode -gt 0) { + Write-SacLog "WARN: SAC agent commands poll HTTP $($get.StatusCode)" + } + return 0 + } + $parsed = $null + try { + $parsed = $get.Content | ConvertFrom-Json + } catch { + Write-SacLog "WARN: SAC agent commands poll: invalid JSON" + return 0 + } + $commands = @($parsed.commands) + if ($commands.Count -eq 0) { return 0 } + + $done = 0 + foreach ($cmd in $commands) { + $cmdId = [string]$cmd.id + if ([string]::IsNullOrWhiteSpace($cmdId)) { continue } + Write-SacLog "SAC agent command: $cmdId type=$($cmd.type)" + $runAs = $cmd.run_as + $result = Invoke-SacAgentCommand -Command $cmd -RunAs $runAs + $status = if ([int]$result.ExitCode -eq 0) { 'completed' } else { 'failed' } + $stderr = [string]$result.Stderr + if ([int]$result.ExitCode -ne 0 -and [string]::IsNullOrWhiteSpace($stderr)) { + $stderr = "exit code $($result.ExitCode)" + } + if (Submit-SacAgentCommandResult -CommandId $cmdId -Status $status ` + -Stdout ([string]$result.Stdout) -Stderr $stderr) { + $done++ + Write-SacLog "SAC agent command result submitted: $cmdId status=$status" + } else { + Write-SacLog "WARN: SAC agent command result failed: $cmdId" + } + } + return $done +} + function Test-SacConnection { Write-Host 'SAC check (rdp-login-monitor)' Write-Host "UseSAC=$(Get-SacNormalizedMode)" diff --git a/login_monitor.settings.example.ps1 b/login_monitor.settings.example.ps1 index e348d68..74020fa 100644 --- a/login_monitor.settings.example.ps1 +++ b/login_monitor.settings.example.ps1 @@ -46,6 +46,8 @@ $DailyReportEnabled = 1 # --- Heartbeat SAC (agent.heartbeat): интервал в секундах; 14400 = 4 ч --- $HeartbeatInterval = 14400 +# Poll SAC на команды qwinsta/logoff (сек); см. security-alert-center/docs/agent-control-plane.md +# $SacCommandPollIntervalSec = 60 # Окно (мин): LastBootUpTime + System 41/1074/6005/6008/6009 → «старт после перезагрузки ОС» $StartupRebootDetectMinutes = 5 diff --git a/version.txt b/version.txt index c50554c..66b0415 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.0.38-SAC +2.0.39-SAC