feat(detmir): add rust-first operations tooling

This commit is contained in:
igor04091968
2026-06-02 17:57:58 +03:00
parent 60670d30a8
commit 19e3682bc8
263 changed files with 51678 additions and 718 deletions
+123
View File
@@ -0,0 +1,123 @@
using System;
using System.Diagnostics;
using System.IO;
using System.ServiceProcess;
namespace AWatchRus
{
public sealed class CollectorGuardService : ServiceBase
{
private Process child;
private readonly ServiceOptions options;
public CollectorGuardService(ServiceOptions options)
{
this.options = options;
ServiceName = options.ServiceName;
CanStop = true;
CanShutdown = true;
}
protected override void OnStart(string[] args)
{
Directory.CreateDirectory(Path.GetDirectoryName(options.LogPath));
File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " service starting" + Environment.NewLine);
var psi = new ProcessStartInfo
{
FileName = options.PowerShellPath,
Arguments = string.Format(
"-NoProfile -ExecutionPolicy Bypass -File \"{0}\" -ConfigPath \"{1}\" -Mode {2} -LoopSeconds {3}",
options.ScriptPath,
options.ConfigPath,
options.Mode,
options.LoopSeconds),
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = false,
RedirectStandardError = false,
};
child = Process.Start(psi);
File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " child pid=" + child.Id + Environment.NewLine);
}
protected override void OnStop()
{
StopChild("service stopping");
}
protected override void OnShutdown()
{
StopChild("system shutdown");
}
private void StopChild(string reason)
{
try
{
File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " " + reason + Environment.NewLine);
if (child != null && !child.HasExited)
{
child.Kill();
child.WaitForExit(10000);
}
}
catch (Exception ex)
{
try
{
File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " stop error: " + ex.Message + Environment.NewLine);
}
catch
{
}
}
}
}
public sealed class ServiceOptions
{
public string ServiceName = "AWatchRusCollectorGuard";
public string PowerShellPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32\\WindowsPowerShell\\v1.0\\powershell.exe");
public string ScriptPath = @"C:\Program Files\AWatch-rus\windows\aw-collector-guard.ps1";
public string ConfigPath = @"C:\ProgramData\AWatch-rus\deployment-config.json";
public string Mode = "shadow";
public int LoopSeconds = 60;
public string LogPath = @"C:\ProgramData\AWatch-rus\logs\collector-guard-service.log";
}
internal static class Program
{
private static void Main(string[] args)
{
var options = Parse(args);
ServiceBase.Run(new CollectorGuardService(options));
}
private static ServiceOptions Parse(string[] args)
{
var options = new ServiceOptions();
for (var i = 0; i < args.Length; i++)
{
var key = args[i].ToLowerInvariant();
var value = i + 1 < args.Length ? args[i + 1] : null;
if (value == null || value.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
if (key == "--service-name") options.ServiceName = value;
else if (key == "--script") options.ScriptPath = value;
else if (key == "--config") options.ConfigPath = value;
else if (key == "--mode") options.Mode = value;
else if (key == "--loop")
{
int parsed;
if (int.TryParse(value, out parsed)) options.LoopSeconds = parsed;
}
else if (key == "--log") options.LogPath = value;
i++;
}
return options;
}
}
}
+150 -6
View File
@@ -1,4 +1,4 @@
Set-StrictMode -Version Latest
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$script:ActivityWatchBuiltInAdministratorName = $null
@@ -431,6 +431,22 @@ function Get-ActivityWatchTaskNameToken {
return $buffer.ToString().Trim('_')
}
function Test-ActivityWatchScheduledTaskExistsExact {
param([string]$TaskName)
if ([string]::IsNullOrWhiteSpace($TaskName)) {
return $false
}
try {
& schtasks.exe /Query /TN $TaskName *> $null
return ($LASTEXITCODE -eq 0)
}
catch {
return $false
}
}
function New-ActivityWatchUserTaskDefinitions {
param(
[Parameter(Mandatory = $true)]
@@ -633,6 +649,119 @@ function Test-ActivityWatchUserHasLiveSession {
return $false
}
function Test-ActivityWatchSessionMatchesUserId {
param(
[Parameter(Mandatory = $true)]
[object]$SessionRecord,
[Parameter(Mandatory = $true)]
[string]$UserId
)
if ([string]::IsNullOrWhiteSpace($UserId) -or $null -eq $SessionRecord) {
return $false
}
$sessionUser = [string]$SessionRecord.UserName
if ([string]::IsNullOrWhiteSpace($sessionUser)) {
return $false
}
foreach ($candidate in @(Resolve-ActivityWatchUserCandidates -UserId $UserId)) {
if ($sessionUser -ieq $candidate -or
('{0}\{1}' -f $env:COMPUTERNAME, $sessionUser) -ieq $candidate -or
((-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) -and ('{0}\{1}' -f $env:USERDOMAIN, $sessionUser) -ieq $candidate)) {
return $true
}
}
return $false
}
function Test-ActivityWatchUserHasManagedSession {
param(
[Parameter(Mandatory = $true)]
[string]$UserId,
[object[]]$SessionRecords,
[switch]$IncludeLive,
[switch]$IncludeDisconnected
)
foreach ($session in @($SessionRecords)) {
if ([int]$session.SessionId -le 0) {
continue
}
if ([string]::IsNullOrWhiteSpace([string]$session.UserName)) {
continue
}
if ([bool]$session.IsLive -and -not $IncludeLive.IsPresent) {
continue
}
if (-not [bool]$session.IsLive -and -not $IncludeDisconnected.IsPresent) {
continue
}
if (Test-ActivityWatchSessionMatchesUserId -SessionRecord $session -UserId $UserId) {
return $true
}
}
return $false
}
function Get-ActivityWatchManagedInteractiveSessions {
param(
[pscustomobject[]]$TaskDefinitions,
[object[]]$SessionRecords,
[switch]$IncludeLive,
[switch]$IncludeDisconnected
)
$result = New-Object System.Collections.Generic.List[object]
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($taskDef in @($TaskDefinitions)) {
$userId = [string]$taskDef.userId
$taskName = [string]$taskDef.taskName
if ([string]::IsNullOrWhiteSpace($userId) -or [string]::IsNullOrWhiteSpace($taskName)) {
continue
}
foreach ($session in @($SessionRecords)) {
if ([int]$session.SessionId -le 0) {
continue
}
if ([string]::IsNullOrWhiteSpace([string]$session.UserName)) {
continue
}
if ([bool]$session.IsLive -and -not $IncludeLive.IsPresent) {
continue
}
if (-not [bool]$session.IsLive -and -not $IncludeDisconnected.IsPresent) {
continue
}
if (-not (Test-ActivityWatchSessionMatchesUserId -SessionRecord $session -UserId $userId)) {
continue
}
$key = '{0}|{1}|{2}' -f $taskName, [int]$session.SessionId, $userId
if (-not $seen.Add($key)) {
continue
}
$result.Add([pscustomobject]@{
TaskName = $taskName
UserId = $userId
SessionName = [string]$session.SessionName
SessionId = [int]$session.SessionId
State = [string]$session.State
UserName = [string]$session.UserName
IsLive = [bool]$session.IsLive
}) | Out-Null
}
}
return @($result.ToArray())
}
function Copy-ActivityWatchCollectorAssets {
param(
[Parameter(Mandatory = $true)]
@@ -777,7 +906,7 @@ function New-ActivityWatchDeploymentConfig {
[int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true,
[bool]$ProcessEventsEnabled = $true,
[bool]$ProcessEventsEnabled = $false,
[Parameter(Mandatory = $true)]
[string]$LaunchScriptPath,
[Parameter(Mandatory = $true)]
@@ -1410,6 +1539,10 @@ function Get-ActivityWatchRecoveryTaskDefinitions {
foreach ($task in @($config.userTasks)) {
$taskName = [string]$task.launchTaskName
$userId = Normalize-ActivityWatchUserId -UserId ([string]$task.userId)
$canonicalTaskName = "ActivityWatch Launch [$((Get-ActivityWatchTaskNameToken -UserId $userId))]"
if ($canonicalTaskName -ne $taskName -and (Test-ActivityWatchScheduledTaskExistsExact -TaskName $canonicalTaskName)) {
$taskName = $canonicalTaskName
}
if (-not [string]::IsNullOrWhiteSpace($taskName) -and -not $taskMap.Contains($taskName)) {
$taskMap[$taskName] = [pscustomobject]@{
taskName = $taskName
@@ -1679,13 +1812,24 @@ function Stop-ActivityWatchProcessesInNonLiveSessions {
[Parameter(Mandatory = $true)]
[object[]]$SessionRecords,
[Parameter(Mandatory = $true)]
[pscustomobject]$Config
[pscustomobject]$Config,
[pscustomobject[]]$TaskDefinitions = @(),
[switch]$PreserveManagedSessions
)
$stateRoot = if ($Config.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$Config.paths.stateRoot } else { Join-Path $env:ProgramData 'AWatch-rus' }
$preservedSessionIds = @()
if ($PreserveManagedSessions.IsPresent) {
$preservedSessionIds = @(
Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $TaskDefinitions -SessionRecords $SessionRecords -IncludeDisconnected |
ForEach-Object { [int]$_.SessionId } |
Sort-Object -Unique
)
}
$sessionIds = @(
$SessionRecords |
Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 } |
Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 -and ($preservedSessionIds -notcontains [int]$_.SessionId) } |
ForEach-Object { [int]$_.SessionId } |
Sort-Object -Unique
)
@@ -1704,7 +1848,7 @@ function Stop-ActivityWatchProcessesInNonLiveSessions {
}
}
foreach ($session in @($SessionRecords | Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 })) {
foreach ($session in @($SessionRecords | Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 -and ($preservedSessionIds -notcontains [int]$_.SessionId) })) {
Remove-ActivityWatchLogonMarkersForSession -StateRoot $stateRoot -SessionId ([int]$session.SessionId) -UserName ([string]$session.UserName)
}
@@ -1885,7 +2029,7 @@ function Invoke-ActivityWatchRecoveryLoop {
$config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath
$taskDefs = Get-ActivityWatchRecoveryTaskDefinitions -ConfigPaths $configPaths
$sessionRecords = Get-ActivityWatchSessionRecords
Stop-ActivityWatchProcessesInNonLiveSessions -SessionRecords $sessionRecords -Config $config
Stop-ActivityWatchProcessesInNonLiveSessions -SessionRecords $sessionRecords -Config $config -TaskDefinitions $taskDefs -PreserveManagedSessions
$sessionRecords = Get-ActivityWatchSessionRecords
$stateRoot = [string]$config.paths.stateRoot
$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' }
+681
View File
@@ -0,0 +1,681 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[ValidateSet('shadow', 'enforce')]
[string]$Mode = 'shadow',
[int]$LoopSeconds = 60,
[int]$InteractiveMaxAgeSeconds = 900,
[int]$HeadlessMaxAgeSeconds = 900,
[int]$RestartWindowSeconds = 600,
[int]$MaxRestarts = 3,
[int]$ActionCooldownSeconds = 300,
[int]$InteractiveActionCooldownSeconds = 60,
[switch]$Once,
[switch]$HeadlessEndpointEnabled,
[switch]$HeadlessFileOpsEnabled,
[switch]$SelfTest
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
Import-Module $modulePath -Force
function New-GuardLock {
param([string]$StateRoot)
if (-not (Test-Path -LiteralPath $StateRoot)) {
New-Item -Path $StateRoot -ItemType Directory -Force | Out-Null
}
$lockPath = Join-Path $StateRoot 'collector-guard.lock'
if (Test-Path -LiteralPath $lockPath) {
try {
$lockData = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json
$existingPid = [int]$lockData.pid
if ($existingPid -gt 0 -and (Get-Process -Id $existingPid -ErrorAction SilentlyContinue)) {
return $null
}
}
catch {
}
}
$payload = @{
pid = $PID
createdAt = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json -Compress
Set-Content -LiteralPath $lockPath -Value $payload -Encoding UTF8
return $lockPath
}
function Write-GuardLog {
param(
[string]$LogPath,
[string]$Message
)
try {
$directory = Split-Path -Path $LogPath -Parent
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
New-Item -Path $directory -ItemType Directory -Force | Out-Null
}
Add-Content -LiteralPath $LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
}
catch {
}
}
function Get-AwApiBase {
param([pscustomobject]$Config)
$scheme = if ($Config.server.PSObject.Properties.Name -contains 'scheme') { [string]$Config.server.scheme } else { 'http' }
$hostName = [string]$Config.server.host
$port = [int]$Config.server.port
return ('{0}://{1}:{2}/api/0' -f $scheme, $hostName, $port)
}
function Invoke-AwJson {
param(
[Parameter(Mandatory = $true)]
[string]$Method,
[Parameter(Mandatory = $true)]
[string]$Uri,
[object]$Body
)
$params = @{
Method = $Method
Uri = $Uri
TimeoutSec = 15
ErrorAction = 'Stop'
}
if ($null -ne $Body) {
$params.Body = ($Body | ConvertTo-Json -Depth 16 -Compress)
$params.ContentType = 'application/json'
}
return Invoke-RestMethod @params
}
function Ensure-AwBucket {
param(
[string]$ApiBase,
[string]$BucketId,
[string]$ClientName,
[string]$BucketType,
[string]$Hostname
)
try {
Invoke-AwJson -Method 'GET' -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return $true
}
catch {
}
try {
$body = @{
client = $ClientName
type = $BucketType
hostname = $Hostname
}
Invoke-AwJson -Method 'POST' -Uri "$ApiBase/buckets/$BucketId" -Body $body | Out-Null
return $true
}
catch {
return $false
}
}
function Get-LatestBucketAge {
param(
[string]$ApiBase,
[string]$BucketId
)
try {
$events = Invoke-AwJson -Method 'GET' -Uri "$ApiBase/buckets/$BucketId/events?limit=20"
$latest = @($events | Where-Object { $null -ne $_.timestamp } | Sort-Object timestamp -Descending | Select-Object -First 1)
if (-not $latest) {
return [pscustomobject]@{ bucket = $BucketId; found = $false; timestamp = $null; ageSeconds = $null }
}
$ts = [DateTimeOffset]::Parse([string]$latest.timestamp).UtcDateTime
$age = [Math]::Max(0, [int]((Get-Date).ToUniversalTime() - $ts).TotalSeconds)
return [pscustomobject]@{ bucket = $BucketId; found = $true; timestamp = [string]$latest.timestamp; ageSeconds = $age }
}
catch {
return [pscustomobject]@{ bucket = $BucketId; found = $false; timestamp = $null; ageSeconds = $null; error = $_.Exception.Message }
}
}
function Send-GuardHeartbeat {
param(
[string]$ApiBase,
[string]$Hostname,
[object]$State,
[int]$PulseSeconds
)
$bucketId = "aw-rus-collector-guard_$Hostname"
if (-not (Ensure-AwBucket -ApiBase $ApiBase -BucketId $bucketId -ClientName 'aw-rus-collector-guard' -BucketType 'aw.rus.collector.guard' -Hostname $Hostname)) {
return $false
}
$event = @{
timestamp = (Get-Date).ToUniversalTime().ToString('o')
duration = 0
data = $State
}
try {
Invoke-AwJson -Method 'POST' -Uri "$ApiBase/buckets/$bucketId/heartbeat?pulsetime=$PulseSeconds" -Body $event | Out-Null
return $true
}
catch {
return $false
}
}
function Read-GuardRuntime {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
return [pscustomobject]@{ restartHistory = @{}; lastAction = @{}; quarantine = @{} }
}
try {
$state = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
if ($null -eq $state.restartHistory) { $state | Add-Member -NotePropertyName restartHistory -NotePropertyValue @{} }
if ($null -eq $state.lastAction) { $state | Add-Member -NotePropertyName lastAction -NotePropertyValue @{} }
if ($null -eq $state.quarantine) { $state | Add-Member -NotePropertyName quarantine -NotePropertyValue @{} }
return $state
}
catch {
return [pscustomobject]@{ restartHistory = @{}; lastAction = @{}; quarantine = @{} }
}
}
function Write-GuardRuntime {
param(
[string]$Path,
[object]$Runtime
)
$directory = Split-Path -Path $Path -Parent
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
New-Item -Path $directory -ItemType Directory -Force | Out-Null
}
$Runtime | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $Path -Encoding UTF8
}
function Get-RuntimeMapValue {
param(
[object]$Map,
[string]$Key
)
if ($null -eq $Map) {
return $null
}
if ($Map -is [hashtable] -and $Map.ContainsKey($Key)) {
return $Map[$Key]
}
$propertyNames = @($Map.PSObject.Properties | ForEach-Object { $_.Name })
if ($propertyNames -contains $Key) {
return $Map.$Key
}
return $null
}
function Set-RuntimeMapValue {
param(
[object]$Map,
[string]$Key,
[object]$Value
)
if ($Map -is [hashtable]) {
$Map[$Key] = $Value
return
}
$propertyNames = @($Map.PSObject.Properties | ForEach-Object { $_.Name })
if ($propertyNames -contains $Key) {
$Map.$Key = $Value
}
else {
$Map | Add-Member -NotePropertyName $Key -NotePropertyValue $Value -Force
}
}
function Remove-RuntimeMapValue {
param(
[object]$Map,
[string]$Key
)
if ($null -eq $Map) {
return
}
if ($Map -is [hashtable]) {
if ($Map.ContainsKey($Key)) {
$Map.Remove($Key)
}
return
}
$property = $Map.PSObject.Properties[$Key]
if ($null -ne $property) {
$Map.PSObject.Properties.Remove($Key)
}
}
function Reset-GuardActionBudget {
param(
[object]$Runtime,
[string]$Key
)
Remove-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key
Remove-RuntimeMapValue -Map $Runtime.lastAction -Key $Key
Remove-RuntimeMapValue -Map $Runtime.quarantine -Key $Key
}
function Invoke-GuardSelfTest {
$emptyObject = [pscustomobject]@{}
if ($null -ne (Get-RuntimeMapValue -Map $emptyObject -Key 'missing')) {
throw 'empty PSCustomObject should not return a missing runtime-map value'
}
Set-RuntimeMapValue -Map $emptyObject -Key 'headless:worktime-session' -Value 123
if ((Get-RuntimeMapValue -Map $emptyObject -Key 'headless:worktime-session') -ne 123) {
throw 'failed to set runtime-map value on empty PSCustomObject'
}
$hash = @{}
Set-RuntimeMapValue -Map $hash -Key 'headless:worktime-session' -Value @(1, 2)
$hashValue = @(Get-RuntimeMapValue -Map $hash -Key 'headless:worktime-session')
if ($hashValue.Count -ne 2) {
throw 'failed to round-trip runtime-map value on hashtable'
}
$runtime = [pscustomobject]@{ restartHistory = [pscustomobject]@{}; lastAction = [pscustomobject]@{}; quarantine = [pscustomobject]@{} }
$allowed = Test-ActionAllowed -Runtime $runtime -Key 'headless:worktime-session' -CooldownSeconds 1 -WindowSeconds 60 -MaxCount 3
if (-not $allowed.allowed) {
throw "expected action to be allowed, got $($allowed.reason)"
}
Register-GuardAction -Runtime $runtime -Key 'headless:worktime-session'
$blocked = Test-ActionAllowed -Runtime $runtime -Key 'headless:worktime-session' -CooldownSeconds 300 -WindowSeconds 60 -MaxCount 3
if ($blocked.allowed -or $blocked.reason -ne 'cooldown') {
throw 'expected cooldown after registering guard action'
}
$budgetRuntime = [pscustomobject]@{ restartHistory = [pscustomobject]@{}; lastAction = [pscustomobject]@{}; quarantine = [pscustomobject]@{} }
foreach ($i in 1..3) {
Register-GuardAction -Runtime $budgetRuntime -Key 'task:test'
}
$budgetBlocked = Test-ActionAllowed -Runtime $budgetRuntime -Key 'task:test' -CooldownSeconds 0 -WindowSeconds 600 -MaxCount 3
if ($budgetBlocked.allowed -or $budgetBlocked.reason -ne 'quarantine') {
throw 'expected quarantine when restart budget is exhausted'
}
Reset-GuardActionBudget -Runtime $budgetRuntime -Key 'task:test'
$budgetAllowed = Test-ActionAllowed -Runtime $budgetRuntime -Key 'task:test' -CooldownSeconds 0 -WindowSeconds 600 -MaxCount 3
if (-not $budgetAllowed.allowed) {
throw 'expected reset action budget to clear quarantine'
}
$oldComputerName = $env:COMPUTERNAME
try {
$env:COMPUTERNAME = 'SHARKON2025'
$sessionRecords = @(
[pscustomobject]@{ SessionName = 'USER5'; UserName = 'USER5'; SessionId = 2; State = 'Disc'; IsLive = $false },
[pscustomobject]@{ SessionName = 'console'; UserName = ''; SessionId = 1; State = 'Conn'; IsLive = $true }
)
$taskDefs = @(
[pscustomobject]@{ taskName = 'ActivityWatch Launch [SHARKON2025_user5]'; userId = 'SHARKON2025\user5' }
)
if (-not (Test-ActivityWatchUserHasManagedSession -UserId 'SHARKON2025\user5' -SessionRecords $sessionRecords -IncludeDisconnected)) {
throw 'expected disconnected managed session to match task user'
}
if (Test-ActivityWatchUserHasManagedSession -UserId 'SHARKON2025\user5' -SessionRecords $sessionRecords -IncludeLive) {
throw 'disconnected managed session should not match live-only filter'
}
$managed = @(Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $taskDefs -SessionRecords $sessionRecords -IncludeDisconnected)
if ($managed.Count -ne 1 -or [int]$managed[0].SessionId -ne 2) {
throw 'failed to enumerate disconnected managed session'
}
}
finally {
if ($null -eq $oldComputerName) {
Remove-Item Env:COMPUTERNAME -ErrorAction SilentlyContinue
}
else {
$env:COMPUTERNAME = $oldComputerName
}
}
Write-Output 'collector guard self-test OK'
}
function Test-ActionAllowed {
param(
[object]$Runtime,
[string]$Key,
[int]$CooldownSeconds,
[int]$WindowSeconds,
[int]$MaxCount
)
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$last = Get-RuntimeMapValue -Map $Runtime.lastAction -Key $Key
if ($null -ne $last -and ($now - [int64]$last) -lt $CooldownSeconds) {
return [pscustomobject]@{ allowed = $false; reason = 'cooldown' }
}
$history = @(Get-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key)
$history = @($history | Where-Object { ($now - [int64]$_) -le $WindowSeconds })
Set-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key -Value @($history)
if ($history.Count -ge $MaxCount) {
Set-RuntimeMapValue -Map $Runtime.quarantine -Key $Key -Value @{
since = (Get-Date).ToUniversalTime().ToString('o')
reason = 'restart-budget-exhausted'
count = $history.Count
}
return [pscustomobject]@{ allowed = $false; reason = 'quarantine' }
}
Remove-RuntimeMapValue -Map $Runtime.quarantine -Key $Key
return [pscustomobject]@{ allowed = $true; reason = 'ok' }
}
function Register-GuardAction {
param(
[object]$Runtime,
[string]$Key
)
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$history = @(Get-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key)
$history += $now
Set-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key -Value @($history)
Set-RuntimeMapValue -Map $Runtime.lastAction -Key $Key -Value $now
}
function Get-CollectorProcessSnapshot {
param([pscustomobject]$Config)
$scriptPaths = [ordered]@{}
foreach ($name in @('collectorScript', 'endpointCollectorScript', 'fileCollectorScript', 'emailCollectorScript', 'sessionCollectorScript')) {
if ($Config.paths.PSObject.Properties.Name -contains $name) {
$value = [string]$Config.paths.$name
if (-not [string]::IsNullOrWhiteSpace($value)) {
$scriptPaths[$name] = $value
}
}
}
$powershellCollectors = @()
try {
$processes = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe' })
foreach ($proc in $processes) {
$commandLine = [string]$proc.CommandLine
foreach ($entry in $scriptPaths.GetEnumerator()) {
if ($commandLine -match [Regex]::Escape([string]$entry.Value)) {
$powershellCollectors += [pscustomobject]@{
name = [string]$entry.Key
processId = [int]$proc.ProcessId
sessionId = [int]$proc.SessionId
scriptPath = [string]$entry.Value
}
}
}
}
}
catch {
}
$watchers = @()
try {
$watchers = @(Get-Process -Name 'aw-watcher-afk','aw-watcher-window' -ErrorAction SilentlyContinue |
Select-Object @{Name='name'; Expression={$_.Name}}, @{Name='processId'; Expression={$_.Id}}, @{Name='sessionId'; Expression={$_.SessionId}})
}
catch {
$watchers = @()
}
return [pscustomobject]@{
watchers = @($watchers)
collectors = @($powershellCollectors)
}
}
function Invoke-ExactTaskRun {
param([string]$TaskName)
& schtasks.exe /Run /TN $TaskName | Out-Null
return ($LASTEXITCODE -eq 0)
}
function Invoke-GuardCycle {
param(
[object]$Runtime,
[string]$RuntimePath,
[string]$LogPath
)
$config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath
$stateRoot = if ($config.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$config.paths.stateRoot } else { Split-Path -Path $ConfigPath -Parent }
$hostname = if ($config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$config.awHostname)) { [string]$config.awHostname } else { [string]$env:COMPUTERNAME }
$apiBase = Get-AwApiBase -Config $config
$configPaths = Get-ActivityWatchRecoveryConfigPaths -PrimaryConfigPath $ConfigPath
$taskDefs = @(Get-ActivityWatchRecoveryTaskDefinitions -ConfigPaths $configPaths)
$sessionRecords = @(Get-ActivityWatchSessionRecords)
$liveSessions = @(Get-ActivityWatchLiveInteractiveSessions -SessionRecords $sessionRecords)
$managedInteractiveSessions = @(Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $taskDefs -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected)
$processSnapshot = Get-CollectorProcessSnapshot -Config $config
$liveSessionIds = @($liveSessions | ForEach-Object { [int]$_.SessionId })
$managedSessionIds = @($managedInteractiveSessions | ForEach-Object { [int]$_.SessionId } | Sort-Object -Unique)
$bucketChecks = [ordered]@{}
foreach ($bucket in @(
"aw-worktime-sessions_$hostname",
"aw-watcher-afk_$hostname",
"aw-watcher-window_$hostname",
"aw-dlp-endpoint-signals_$hostname"
)) {
$bucketChecks[$bucket] = Get-LatestBucketAge -ApiBase $apiBase -BucketId $bucket
}
$actions = New-Object System.Collections.Generic.List[object]
$problems = New-Object System.Collections.Generic.List[string]
$worktimeAge = $bucketChecks["aw-worktime-sessions_$hostname"].ageSeconds
$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' }
$sessionCollectorRunning = Test-ActivityWatchCollectorRunningGlobal -ScriptPath $sessionCollectorScript
$headlessKey = 'headless:worktime-session'
$needsHeadlessAction = (-not $sessionCollectorRunning -or $null -eq $worktimeAge -or [int]$worktimeAge -gt $HeadlessMaxAgeSeconds)
if ($needsHeadlessAction) {
$key = $headlessKey
$allowed = Test-ActionAllowed -Runtime $Runtime -Key $key -CooldownSeconds $ActionCooldownSeconds -WindowSeconds $RestartWindowSeconds -MaxCount $MaxRestarts
if ($allowed.allowed) {
if ($Mode -eq 'enforce') {
Start-ActivityWatchCollectorScriptGlobalIfNeeded -ScriptPath $sessionCollectorScript -ConfigPath $ConfigPath
Register-GuardAction -Runtime $Runtime -Key $key
Write-GuardLog -LogPath $LogPath -Message "started $key"
$actions.Add([pscustomobject]@{ action = 'start'; target = $key; applied = $true }) | Out-Null
}
else {
$actions.Add([pscustomobject]@{ action = 'start'; target = $key; applied = $false; mode = 'shadow' }) | Out-Null
}
}
else {
$problems.Add("$key action blocked: $($allowed.reason)") | Out-Null
}
}
else {
Reset-GuardActionBudget -Runtime $Runtime -Key $headlessKey
}
if ($Mode -eq 'enforce') {
Stop-ActivityWatchProcessesInNonLiveSessions -SessionRecords $sessionRecords -Config $config -TaskDefinitions $taskDefs -PreserveManagedSessions
}
$interactiveStale = $false
foreach ($bucket in @("aw-watcher-afk_$hostname", "aw-watcher-window_$hostname", "aw-dlp-endpoint-signals_$hostname")) {
$age = $bucketChecks[$bucket].ageSeconds
if ($null -eq $age -or [int]$age -gt $InteractiveMaxAgeSeconds) {
$interactiveStale = $true
}
}
$watchersInLiveSessions = @($processSnapshot.watchers | Where-Object { $liveSessionIds -contains [int]$_.sessionId })
$watchersInManagedSessions = @($processSnapshot.watchers | Where-Object { $managedSessionIds -contains [int]$_.sessionId })
$liveWatcherMissing = $false
if ($liveSessions.Count -gt 0) {
$hasAfk = @($watchersInLiveSessions | Where-Object { [string]$_.name -ieq 'aw-watcher-afk' }).Count -gt 0
$hasWindow = @($watchersInLiveSessions | Where-Object { [string]$_.name -ieq 'aw-watcher-window' }).Count -gt 0
$liveWatcherMissing = (-not $hasAfk) -or (-not $hasWindow)
}
$managedWatcherMissing = $false
if ($managedInteractiveSessions.Count -gt 0) {
$afkEnabled = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true }
$windowEnabled = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true }
foreach ($managedSession in @($managedInteractiveSessions)) {
$sessionId = [int]$managedSession.SessionId
$sessionWatchers = @($watchersInManagedSessions | Where-Object { [int]$_.sessionId -eq $sessionId })
$hasManagedAfk = @($sessionWatchers | Where-Object { [string]$_.name -ieq 'aw-watcher-afk' }).Count -gt 0
$hasManagedWindow = @($sessionWatchers | Where-Object { [string]$_.name -ieq 'aw-watcher-window' }).Count -gt 0
if (($afkEnabled -and -not $hasManagedAfk) -or ($windowEnabled -and -not $hasManagedWindow)) {
$managedWatcherMissing = $true
break
}
}
}
if ($managedInteractiveSessions.Count -eq 0 -and $liveSessions.Count -gt 0 -and $interactiveStale) {
$problems.Add('interactive buckets stale but no managed interactive sessions found') | Out-Null
}
$needsInteractiveTaskAction = $managedInteractiveSessions.Count -gt 0 -and (
$liveWatcherMissing -or
$managedWatcherMissing -or
($interactiveStale -and $liveSessions.Count -gt 0)
)
if ($needsInteractiveTaskAction) {
foreach ($taskDef in $taskDefs) {
if (-not (Test-ActivityWatchUserHasManagedSession -UserId ([string]$taskDef.userId) -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected)) {
continue
}
$key = "task:$($taskDef.taskName)"
$allowed = Test-ActionAllowed -Runtime $Runtime -Key $key -CooldownSeconds $InteractiveActionCooldownSeconds -WindowSeconds $RestartWindowSeconds -MaxCount $MaxRestarts
if (-not $allowed.allowed) {
$problems.Add("$key action blocked: $($allowed.reason)") | Out-Null
continue
}
if ($Mode -eq 'enforce') {
$ok = Invoke-ExactTaskRun -TaskName ([string]$taskDef.taskName)
if ($ok) {
Register-GuardAction -Runtime $Runtime -Key $key
}
Write-GuardLog -LogPath $LogPath -Message ("run {0} ok={1}" -f $key, $ok)
$actions.Add([pscustomobject]@{ action = 'run-task'; target = [string]$taskDef.taskName; applied = $true; ok = $ok }) | Out-Null
}
else {
$actions.Add([pscustomobject]@{ action = 'run-task'; target = [string]$taskDef.taskName; applied = $false; mode = 'shadow' }) | Out-Null
}
}
}
else {
foreach ($taskDef in $taskDefs) {
if (Test-ActivityWatchUserHasManagedSession -UserId ([string]$taskDef.userId) -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected) {
Reset-GuardActionBudget -Runtime $Runtime -Key "task:$($taskDef.taskName)"
}
}
}
$status = 'ok'
if ($problems.Count -gt 0) {
$status = 'warn'
}
if ($managedInteractiveSessions.Count -gt 0 -and $interactiveStale -and $Mode -eq 'shadow') {
$status = 'warn'
}
$sessionState = @(
foreach ($session in @($sessionRecords)) {
[pscustomobject]@{
SessionName = [string]$session.SessionName
UserName = [string]$session.UserName
SessionId = [int]$session.SessionId
State = [string]$session.State
IsLive = [bool]$session.IsLive
}
}
)
$state = @{}
$state['status'] = $status
$state['mode'] = $Mode
$state['host'] = $hostname
$state['generatedAtUtc'] = (Get-Date).ToUniversalTime().ToString('o')
$state['pid'] = $PID
$state['sessions'] = @($sessionState)
$state['liveSessionCount'] = $liveSessions.Count
$state['managedSessionCount'] = $managedInteractiveSessions.Count
$state['managedSessions'] = @($managedInteractiveSessions)
$bucketState = @{}
foreach ($key in $bucketChecks.Keys) {
$bucketState[$key] = $bucketChecks[$key]
}
$state['processes'] = $processSnapshot
$state['buckets'] = $bucketState
$state['actions'] = @($actions.ToArray())
$state['problems'] = @($problems.ToArray())
$state['quarantine'] = $Runtime.quarantine
$statePath = Join-Path $stateRoot 'collector-guard-state.json'
$state | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $statePath -Encoding UTF8
Write-GuardRuntime -Path $RuntimePath -Runtime $Runtime
[void](Send-GuardHeartbeat -ApiBase $apiBase -Hostname $hostname -State $state -PulseSeconds ([Math]::Max($LoopSeconds * 2, 60)))
return $state
}
if ($SelfTest) {
Invoke-GuardSelfTest
exit 0
}
$initialConfig = Read-ActivityWatchDeploymentConfig -Path $ConfigPath
$initialStateRoot = if ($initialConfig.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$initialConfig.paths.stateRoot } else { Split-Path -Path $ConfigPath -Parent }
$initialLogsRoot = if ($initialConfig.paths.PSObject.Properties.Name -contains 'logsRoot') { [string]$initialConfig.paths.logsRoot } else { Join-Path $initialStateRoot 'logs' }
$logPath = Join-Path $initialLogsRoot 'collector-guard.log'
$runtimePath = Join-Path $initialStateRoot 'collector-guard-runtime.json'
$lockPath = New-GuardLock -StateRoot $initialStateRoot
if (-not $lockPath) {
Write-GuardLog -LogPath $logPath -Message 'another collector guard instance is already running'
exit 0
}
try {
$runtime = Read-GuardRuntime -Path $runtimePath
Write-GuardLog -LogPath $logPath -Message "collector guard started mode=$Mode loop=$LoopSeconds once=$($Once.IsPresent)"
while ($true) {
try {
Invoke-GuardCycle -Runtime $runtime -RuntimePath $runtimePath -LogPath $logPath | Out-Null
}
catch {
Write-GuardLog -LogPath $logPath -Message ("cycle error: {0}; at {1}" -f $_.Exception.Message, $_.ScriptStackTrace)
}
if ($Once) {
break
}
Start-Sleep -Seconds ([Math]::Max($LoopSeconds, 15))
}
}
finally {
if ($lockPath -and (Test-Path -LiteralPath $lockPath)) {
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
}
Write-GuardLog -LogPath $logPath -Message 'collector guard stopped'
}
+1 -1
View File
@@ -27,7 +27,7 @@ param(
[int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true,
[bool]$ProcessEventsEnabled = $true,
[bool]$ProcessEventsEnabled = $false,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
+1 -1
View File
@@ -27,7 +27,7 @@ param(
[int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true,
[bool]$ProcessEventsEnabled = $true,
[bool]$ProcessEventsEnabled = $false,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
+1 -1
View File
@@ -25,7 +25,7 @@ param(
[int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true,
[bool]$ProcessEventsEnabled = $true,
[bool]$ProcessEventsEnabled = $false,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath
+151 -9
View File
@@ -15,7 +15,8 @@ param(
[int]$PolicyRefreshSeconds,
[string]$PolicyCachePath,
[string]$LogPath,
[int]$PollSeconds
[int]$PollSeconds,
[switch]$SelfTestSuppressedBlock
)
Set-StrictMode -Version Latest
@@ -30,6 +31,7 @@ catch {
$script:TransportQueuePath = $null
$script:TransportQueueLockPath = $null
$script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId
$script:TransportMetrics = @{
eventsEnqueued = 0
eventsFlushed = 0
@@ -71,6 +73,21 @@ function Write-EndpointLog {
}
}
function Get-QueueNameToken {
param(
[string]$UserName,
[int]$SessionId
)
$token = ('{0}-s{1}' -f $UserName, $SessionId)
foreach ($ch in [System.IO.Path]::GetInvalidFileNameChars()) {
$token = $token.Replace([string]$ch, '_')
}
if ([string]::IsNullOrWhiteSpace($token)) {
return ('session-{0}' -f $SessionId)
}
return $token
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
@@ -129,11 +146,20 @@ function Invoke-AwJsonPost {
function Initialize-TransportQueue {
param([Parameter(Mandatory = $true)][string]$StateRoot)
$script:TransportQueuePath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.jsonl'
$script:TransportQueueLockPath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.lock'
$queueToken = Get-QueueNameToken -UserName $env:USERNAME -SessionId $script:SessionId
$script:TransportQueuePath = Join-Path $StateRoot ("dlp-endpoint-signals-queue-{0}.jsonl" -f $queueToken)
$script:TransportQueueLockPath = Join-Path $StateRoot ("dlp-endpoint-signals-queue-{0}.lock" -f $queueToken)
if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) {
New-Item -Path $script:TransportQueuePath -ItemType File -Force | Out-Null
}
$legacyQueuePath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.jsonl'
if (Test-Path -LiteralPath $legacyQueuePath) {
$legacyItems = @(Get-Content -LiteralPath $legacyQueuePath -ErrorAction SilentlyContinue | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($legacyItems.Count -gt 0) {
Add-Content -LiteralPath $script:TransportQueuePath -Value $legacyItems -Encoding UTF8
Clear-Content -LiteralPath $legacyQueuePath -ErrorAction SilentlyContinue
}
}
}
function Get-TransportQueueLock {
@@ -617,6 +643,17 @@ function Load-DlpPolicy {
regexPack = $null
ocrEnabled = $false
}
nativeControls = [ordered]@{
mode = 'monitor'
rollout = [ordered]@{
allowGlobalBlock = $false
}
channels = [ordered]@{
clipboard = [ordered]@{ action = 'audit' }
usb = [ordered]@{ action = 'audit' }
print = [ordered]@{ action = 'audit' }
}
}
}
$script:PolicySource = 'defaults'
@@ -656,6 +693,28 @@ function Load-DlpPolicy {
$script:Policy.contentAnalysis.ocrEnabled = [bool]$raw.contentAnalysis.ocrEnabled
}
}
if ($raw.nativeControls) {
$nativeProps = @($raw.nativeControls.PSObject.Properties.Name)
if ($nativeProps -contains 'mode' -and $raw.nativeControls.mode) {
$script:Policy.nativeControls.mode = ([string]$raw.nativeControls.mode).ToLowerInvariant()
}
if ($nativeProps -contains 'rollout' -and $raw.nativeControls.rollout) {
$rolloutProps = @($raw.nativeControls.rollout.PSObject.Properties.Name)
if ($rolloutProps -contains 'allowGlobalBlock') {
$script:Policy.nativeControls.rollout.allowGlobalBlock = [bool]$raw.nativeControls.rollout.allowGlobalBlock
}
}
if ($nativeProps -contains 'channels' -and $raw.nativeControls.channels) {
foreach ($channelName in @('clipboard', 'usb', 'print')) {
if (@($raw.nativeControls.channels.PSObject.Properties.Name) -contains $channelName) {
$channel = $raw.nativeControls.channels.$channelName
if ($channel -and (@($channel.PSObject.Properties.Name) -contains 'action') -and $channel.action) {
$script:Policy.nativeControls.channels[$channelName].action = ([string]$channel.action).ToLowerInvariant()
}
}
}
}
}
$script:PolicySource = 'local'
}
catch {
@@ -663,6 +722,68 @@ function Load-DlpPolicy {
}
}
function Resolve-DlpEffectiveAction {
param(
[Parameter(Mandatory = $true)][string]$RequestedAction,
[Parameter(Mandatory = $true)][ValidateSet('clipboard', 'usb', 'print')][string]$Channel
)
$requested = $RequestedAction.ToLowerInvariant()
$mode = ([string]$script:Policy.nativeControls.mode).ToLowerInvariant()
$allowGlobalBlock = [bool]$script:Policy.nativeControls.rollout.allowGlobalBlock
$channelAction = 'audit'
try {
$channelAction = ([string]$script:Policy.nativeControls.channels[$Channel].action).ToLowerInvariant()
}
catch {
$channelAction = 'audit'
}
$suppressed = $false
$effective = $requested
if ($requested -eq 'block') {
$channelAllowsBlock = $channelAction -in @('block', 'blockwithoverride')
if ($mode -ne 'enforce' -or -not $allowGlobalBlock -or -not $channelAllowsBlock) {
$effective = 'alert'
$suppressed = $true
}
}
return [pscustomobject]@{
requestedAction = $requested
action = $effective
enforcementMode = $mode
nativeChannelAction = $channelAction
enforcementSuppressed = $suppressed
}
}
function Invoke-SuppressedBlockSelfTest {
$decisions = @()
foreach ($channel in @('clipboard', 'usb', 'print')) {
$decisions += (Resolve-DlpEffectiveAction -RequestedAction 'block' -Channel $channel)
}
$failed = @(
$decisions |
Where-Object { $_.action -eq 'block' -or -not [bool]$_.enforcementSuppressed }
)
$result = [ordered]@{
ok = (@($failed).Count -eq 0)
test = 'suppressed-block-in-monitor'
policySource = $script:PolicySource
policyMode = $script:PolicyMode
decisions = @($decisions)
}
$result | ConvertTo-Json -Depth 6
if (-not $result.ok) {
exit 2
}
exit 0
}
function Test-ValidInn {
param([string]$Value)
$digits = ($Value -replace '\D', '')
@@ -925,7 +1046,9 @@ function Evaluate-ClipboardRules {
$fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME"
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$requestedAction = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$actionDecision = Resolve-DlpEffectiveAction -RequestedAction $requestedAction -Channel 'clipboard'
$action = [string]$actionDecision.action
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" }
@@ -939,13 +1062,17 @@ function Evaluate-ClipboardRules {
clipboardHash = $ClipboardHash
clipboardLength = $ClipboardText.Length
enforced = $enforced
requestedAction = [string]$actionDecision.requestedAction
enforcementMode = [string]$actionDecision.enforcementMode
nativeChannelAction = [string]$actionDecision.nativeChannelAction
enforcementSuppressed = [bool]$actionDecision.enforcementSuppressed
dictionaryPack = $dictionaryPack
regexPack = $regexPack
dictionaryMatches = @($advanced.dictionaryMatches)
regexMatches = @($advanced.regexMatches)
ocrRequested = $ocrEnabled
}
Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced)
Write-EndpointLog ("incident clipboard rule={0} requested={1} action={2} severity={3} enforced={4} suppressed={5}" -f $ruleId, $requestedAction, $action, $severity, $enforced, [bool]$actionDecision.enforcementSuppressed)
}
}
@@ -965,7 +1092,9 @@ function Evaluate-UsbRules {
$fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME"
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$requestedAction = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$actionDecision = Resolve-DlpEffectiveAction -RequestedAction $requestedAction -Channel 'usb'
$action = [string]$actionDecision.action
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" }
@@ -979,8 +1108,12 @@ function Evaluate-UsbRules {
driveLetter = $DriveLetter
volumeName = $VolumeName
enforced = $enforced
requestedAction = [string]$actionDecision.requestedAction
enforcementMode = [string]$actionDecision.enforcementMode
nativeChannelAction = [string]$actionDecision.nativeChannelAction
enforcementSuppressed = [bool]$actionDecision.enforcementSuppressed
}
Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced)
Write-EndpointLog ("incident usb rule={0} requested={1} action={2} severity={3} drive={4} enforced={5} suppressed={6}" -f $ruleId, $requestedAction, $action, $severity, $DriveLetter, $enforced, [bool]$actionDecision.enforcementSuppressed)
}
}
@@ -1016,7 +1149,9 @@ function Evaluate-PrintRules {
$fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME"
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$requestedAction = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$actionDecision = Resolve-DlpEffectiveAction -RequestedAction $requestedAction -Channel 'print'
$action = [string]$actionDecision.action
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" }
@@ -1031,13 +1166,17 @@ function Evaluate-PrintRules {
documentName = $DocumentName
owner = $Owner
enforced = $enforced
requestedAction = [string]$actionDecision.requestedAction
enforcementMode = [string]$actionDecision.enforcementMode
nativeChannelAction = [string]$actionDecision.nativeChannelAction
enforcementSuppressed = [bool]$actionDecision.enforcementSuppressed
dictionaryPack = $dictionaryPack
regexPack = $regexPack
dictionaryMatches = @($advanced.dictionaryMatches)
regexMatches = @($advanced.regexMatches)
ocrRequested = $ocrEnabled
}
Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced)
Write-EndpointLog ("incident print rule={0} requested={1} action={2} severity={3} printer={4} enforced={5} suppressed={6}" -f $ruleId, $requestedAction, $action, $severity, $PrinterName, $enforced, [bool]$actionDecision.enforcementSuppressed)
}
}
@@ -1320,6 +1459,9 @@ $script:LastEventTime = $null
Initialize-TransportQueue -StateRoot $resolvedStateRoot
Initialize-DlpPolicy
if ($SelfTestSuppressedBlock) {
Invoke-SuppressedBlockSelfTest
}
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
while ($true) {
+3 -1
View File
@@ -154,4 +154,6 @@ function Save-CachedDlpPolicyBundle {
Set-Content -LiteralPath $CachePath -Value $json -Encoding UTF8
}
Export-ModuleMember -Function Invoke-DlpPolicyGetJson, Get-RemoteDlpPolicyBundle, Get-RemoteDlpPolicyDesired, Send-DlpPolicyAgentHeartbeat, Read-CachedDlpPolicyBundle, Save-CachedDlpPolicyBundle
if ($ExecutionContext.SessionState.Module) {
Export-ModuleMember -Function Invoke-DlpPolicyGetJson, Get-RemoteDlpPolicyBundle, Get-RemoteDlpPolicyDesired, Send-DlpPolicyAgentHeartbeat, Read-CachedDlpPolicyBundle, Save-CachedDlpPolicyBundle
}
+13
View File
@@ -6,6 +6,19 @@
"action": "log",
"severity": "low"
},
"nativeControls": {
"mode": "monitor",
"rollout": {
"baselineDays": 7,
"requireGuardHeartbeat": true,
"allowGlobalBlock": false
},
"channels": {
"clipboard": {"action": "audit"},
"usb": {"action": "audit"},
"print": {"action": "audit"}
}
},
"rules": [
{
"id": "personal-web-during-workhours",
@@ -0,0 +1,145 @@
{
"version": 1,
"defaults": {
"enabled": true,
"cooldownSeconds": 300,
"action": "alert",
"severity": "medium"
},
"nativeControls": {
"mode": "monitor",
"rollout": {
"baselineDays": 7,
"requireGuardHeartbeat": true,
"allowGlobalBlock": false
},
"channels": {
"removableStorage": {
"action": "audit",
"windows": {
"mechanisms": ["gpo-device-restrictions", "set-disk-readonly"],
"target": "write"
},
"linux": {
"mechanisms": ["fanotify", "auditd"],
"target": "mountpoints"
},
"macos": {
"mechanisms": ["mdm-restrictions", "endpoint-security"],
"target": "managed-devices"
},
"chromeos": {
"mechanisms": ["data-controls"],
"target": "removable-storage"
}
},
"print": {
"action": "audit",
"windows": {
"mechanisms": ["printservice-operational-log", "spooler-cancel-job"]
},
"linux": {
"mechanisms": ["cups-logs"],
"enforcement": "monitor-only"
},
"macos": {
"mechanisms": ["mdm-printing-restrictions"],
"enforcement": "managed-only"
},
"chromeos": {
"mechanisms": ["data-controls"]
}
},
"clipboard": {
"action": "audit",
"windows": {
"mechanisms": ["clipboard-monitor", "clear-clipboard"],
"blockScope": "high-confidence-only"
},
"linux": {
"mechanisms": ["desktop-clipboard-monitor"],
"enforcement": "monitor-only"
},
"macos": {
"mechanisms": ["endpoint-monitor", "mdm-restrictions"],
"enforcement": "monitor-first"
},
"chromeos": {
"mechanisms": ["data-controls"]
}
},
"browserUpload": {
"action": "audit",
"windows": {
"mechanisms": ["managed-browser-policy", "browser-extension"]
},
"linux": {
"mechanisms": ["managed-browser-policy", "proxy-logs"]
},
"macos": {
"mechanisms": ["managed-browser-policy", "network-extension"]
},
"chromeos": {
"mechanisms": ["data-controls"]
}
},
"appExecution": {
"action": "audit",
"windows": {
"mechanisms": ["applocker", "wdac"]
},
"linux": {
"mechanisms": ["auditd", "fanotify", "bpf-lsm"],
"enforcement": "fanotify-or-lsm-only"
},
"macos": {
"mechanisms": ["mdm-restrictions", "endpoint-security"]
},
"chromeos": {
"mechanisms": ["admin-console-app-policy"]
}
}
}
},
"endpoint": {
"clipboard": [
{
"id": "clipboard-sensitive-keywords",
"enabled": true,
"cooldownSeconds": 300,
"action": "alert",
"severity": "high",
"message": "В буфере обнаружены чувствительные ключевые слова",
"minLength": 20,
"regexPatterns": [
"(?i)парол(ь|и)",
"(?i)password",
"(?i)secret",
"(?i)cvv",
"(?i)паспорт"
]
}
],
"usb": [
{
"id": "usb-media-connected",
"enabled": true,
"cooldownSeconds": 300,
"action": "alert",
"severity": "medium",
"message": "Подключен съемный носитель"
}
],
"print": [
{
"id": "print-sensitive-docs",
"enabled": true,
"cooldownSeconds": 300,
"action": "alert",
"severity": "high",
"message": "Печать документа с признаками чувствительных данных",
"documentRegex": "(?i)(salary|зарплат|passport|паспорт|договор|contract)"
}
]
}
}
+26 -2
View File
@@ -66,6 +66,21 @@ function Write-StartupTrace {
}
}
function Get-QueueNameToken {
param(
[string]$UserName,
[int]$SessionId
)
$token = ('{0}-s{1}' -f $UserName, $SessionId)
foreach ($ch in [System.IO.Path]::GetInvalidFileNameChars()) {
$token = $token.Replace([string]$ch, '_')
}
if ([string]::IsNullOrWhiteSpace($token)) {
return ('session-{0}' -f $SessionId)
}
return $token
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
@@ -97,11 +112,20 @@ function Initialize-TransportQueue {
param(
[Parameter(Mandatory = $true)][string]$StateRoot
)
$script:TransportQueuePath = Join-Path $StateRoot 'file-operations-queue.jsonl'
$script:TransportQueueLockPath = Join-Path $StateRoot 'file-operations-queue.lock'
$queueToken = Get-QueueNameToken -UserName $env:USERNAME -SessionId $script:SessionId
$script:TransportQueuePath = Join-Path $StateRoot ("file-operations-queue-{0}.jsonl" -f $queueToken)
$script:TransportQueueLockPath = Join-Path $StateRoot ("file-operations-queue-{0}.lock" -f $queueToken)
if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) {
New-Item -Path $script:TransportQueuePath -ItemType File -Force | Out-Null
}
$legacyQueuePath = Join-Path $StateRoot 'file-operations-queue.jsonl'
if (Test-Path -LiteralPath $legacyQueuePath) {
$legacyItems = @(Get-Content -LiteralPath $legacyQueuePath -ErrorAction SilentlyContinue | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($legacyItems.Count -gt 0) {
Add-Content -LiteralPath $script:TransportQueuePath -Value $legacyItems -Encoding UTF8
Clear-Content -LiteralPath $legacyQueuePath -ErrorAction SilentlyContinue
}
}
}
function Get-TransportQueueLock {
+1 -1
View File
@@ -94,7 +94,7 @@ $effectiveEvtxExportRoot = if ($PSBoundParameters.ContainsKey('EvtxExportRoot')
$effectiveEvtxRetentionDays = if ($PSBoundParameters.ContainsKey('EvtxRetentionDays')) { [int]$EvtxRetentionDays } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'retentionDays') { [int]$existingConfig.forensics.retentionDays } else { 14 }
$effectiveEvtxChannels = if ($PSBoundParameters.ContainsKey('EvtxChannels')) { @($EvtxChannels) } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'evtxChannels') { @($existingConfig.forensics.evtxChannels) } else { @() }
$effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true }
$effectiveProcessEventsEnabled = if ($PSBoundParameters.ContainsKey('ProcessEventsEnabled')) { [bool]$ProcessEventsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$existingConfig.sessionEvents.processEventsEnabled } else { $true }
$effectiveProcessEventsEnabled = if ($PSBoundParameters.ContainsKey('ProcessEventsEnabled')) { [bool]$ProcessEventsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$existingConfig.sessionEvents.processEventsEnabled } else { $false }
$effectiveAwHostname = if ($PSBoundParameters.ContainsKey('AwHostname') -and -not [string]::IsNullOrWhiteSpace($AwHostname)) { [string]$AwHostname } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$existingConfig.awHostname)) { [string]$existingConfig.awHostname } else { [string]$env:COMPUTERNAME }
$effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' }
$effectivePolicyMode = if ($PSBoundParameters.ContainsKey('PolicyMode') -and $PolicyMode) { [string]$PolicyMode } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'mode') { [string]$existingConfig.policyEngine.mode } else { 'local' }
@@ -0,0 +1,88 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[ValidateSet('shadow', 'enforce')]
[string]$Mode = 'shadow',
[string]$ServiceName = 'AWatchRusCollectorGuard',
[int]$LoopSeconds = 60,
[switch]$DisableRecoveryTask
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Assert-Admin {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($id)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run as Administrator.'
}
}
Assert-Admin
$guardScriptPath = Join-Path $PSScriptRoot 'aw-collector-guard.ps1'
$serviceSourcePath = Join-Path $PSScriptRoot 'AWatchRusCollectorGuardService.cs'
$serviceExePath = Join-Path $PSScriptRoot 'AWatchRusCollectorGuardService.exe'
if (-not (Test-Path -LiteralPath $guardScriptPath)) {
throw "Collector guard script not found: $guardScriptPath"
}
if (-not (Test-Path -LiteralPath $serviceSourcePath)) {
throw "Collector guard service source not found: $serviceSourcePath"
}
if (-not (Test-Path -LiteralPath $ConfigPath)) {
throw "Config not found: $ConfigPath"
}
$cscCandidates = @(
(Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'),
(Join-Path $env:WINDIR 'Microsoft.NET\Framework\v4.0.30319\csc.exe')
)
$csc = @($cscCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1)
if (-not $csc) {
throw 'C# compiler not found. Install .NET Framework build tools or provide AWatchRusCollectorGuardService.exe.'
}
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
if ($existing.Status -ne 'Stopped') {
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
try {
$existing.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(20))
}
catch {
}
}
sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 2
}
& $csc /nologo /target:exe /optimize+ /out:$serviceExePath /reference:System.ServiceProcess.dll $serviceSourcePath | Out-Null
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $serviceExePath)) {
throw "Failed to compile $serviceExePath"
}
$logsRoot = Join-Path (Split-Path -Path $ConfigPath -Parent) 'logs'
$serviceLogPath = Join-Path $logsRoot 'collector-guard-service.log'
$binPath = "`"$serviceExePath`" --service-name `"$ServiceName`" --script `"$guardScriptPath`" --config `"$ConfigPath`" --mode $Mode --loop $LoopSeconds --log `"$serviceLogPath`""
New-Service -Name $ServiceName -BinaryPathName $binPath -DisplayName 'AWatch-rus Collector Guard' -StartupType Automatic | Out-Null
sc.exe description $ServiceName "Session-aware ActivityWatch collector guard for AWatch-rus" | Out-Null
sc.exe failure $ServiceName reset= 300 actions= restart/5000/restart/15000/restart/60000 | Out-Null
if ($DisableRecoveryTask) {
Write-Warning 'DisableRecoveryTask is deprecated and ignored: ActivityWatch Recovery must remain enabled as collector guard fallback.'
}
else {
try {
Enable-ScheduledTask -TaskName 'ActivityWatch Recovery' -ErrorAction SilentlyContinue | Out-Null
}
catch {
}
}
sc.exe start $ServiceName | Out-Null
Write-Output "Collector guard service installed: $ServiceName"
Write-Output "Mode: $Mode"
Write-Output "Config: $ConfigPath"
@@ -44,6 +44,9 @@ Source: "..\..\aw-standalone-service.ps1"; DestDir: "{app}\windows"; Flags: igno
Source: "..\..\deploy-single-user.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-domain-users.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-ensemble.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\AWatchRusCollectorGuardService.cs"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\aw-collector-guard.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\install-collector-guard-service.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\hardening-recovery.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\validate-deployment.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\migrate-awatch-rus-paths.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
@@ -54,6 +57,7 @@ Source: "..\..\file-operations-collector.ps1"; DestDir: "{app}\windows"; Flags:
Source: "..\..\email-outbound-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-policy.native-cross-os.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
; Offline payload (optional): place ZIP into windows/installkit/innosetup/payload/ before compiling.
Source: "payload\{#AwDefaultZipName}"; DestDir: "{app}\payload"; Flags: ignoreversion skipifsourcedoesntexist
Source: "innosetup-rdp-package-filelist.md"; DestDir: "{app}\windows\installkit\innosetup"; Flags: ignoreversion
Binary file not shown.
@@ -29,6 +29,9 @@
- `windows/deploy-single-user.ps1`
- `windows/deploy-domain-users.ps1`
- `windows/deploy-ensemble.ps1`
- `windows/AWatchRusCollectorGuardService.cs`
- `windows/aw-collector-guard.ps1`
- `windows/install-collector-guard-service.ps1`
- `windows/hardening-recovery.ps1`
- `windows/validate-deployment.ps1`
- `windows/migrate-awatch-rus-paths.ps1`
@@ -41,6 +44,7 @@
### 1.4 Шаблоны конфигурации
- `windows/web-category-rules.example.json`
- `windows/dlp-policy.example.json`
- `windows/dlp-policy.native-cross-os.example.json`
## 2) Бинарный payload ActivityWatch
@@ -83,6 +87,7 @@
- `windows\dlp-endpoint-signals-collector.ps1`
- `windows\web-category-rules.example.json`
- `windows\dlp-policy.example.json`
- `windows\dlp-policy.native-cross-os.example.json`
- `payload\activitywatch-v0.13.2-windows-x86_64.zip` (только для offline-режима)
## 6) Контроль перед сборкой .iss
+105 -12
View File
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json'
)
@@ -38,7 +38,7 @@ $windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -a
$fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true }
$sessionEventsConfig = if ($config.PSObject.Properties.Name -contains 'sessionEvents') { $config.sessionEvents } else { $null }
$sessionLogonEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$sessionEventsConfig.logonEnabled } else { $false }
$sessionProcessEventsEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$sessionEventsConfig.processEventsEnabled } else { $true }
$sessionProcessEventsEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$sessionEventsConfig.processEventsEnabled } else { $false }
$sessionEventsBucketId = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]$sessionEventsConfig.bucketPrefix)) {
('{0}_{1}' -f [string]$sessionEventsConfig.bucketPrefix, $awHostname)
}
@@ -301,6 +301,94 @@ function Get-TransportQueueHealth {
}
}
function Get-TransportQueueGroupHealth {
param(
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[string]$StateRoot,
[Parameter(Mandatory = $true)]
[string]$QueuePattern,
[Parameter(Mandatory = $true)]
[int]$StaleAfterSeconds,
[Parameter(Mandatory = $true)]
[int]$MaxDepth,
[int]$ActiveProcessCount = 0,
[bool]$Required = $true
)
$queues = @(Get-ChildItem -LiteralPath $StateRoot -Filter $QueuePattern -ErrorAction SilentlyContinue | Sort-Object Name)
if ($queues.Count -eq 0) {
return [pscustomobject]@{
name = $Name
required = [bool]$Required
queuePattern = $QueuePattern
queueCount = 0
queues = @()
depth = 0
sizeBytes = 0
activeProcessCount = [int]$ActiveProcessCount
staleAfterSeconds = [int]$StaleAfterSeconds
maxDepth = [int]$MaxDepth
ok = [bool](-not $Required)
}
}
$items = @()
foreach ($queue in $queues) {
$lockPath = [System.IO.Path]::ChangeExtension($queue.FullName, '.lock')
$items += Get-TransportQueueHealth -Name $queue.BaseName -QueuePath $queue.FullName -LockPath $lockPath -StaleAfterSeconds $StaleAfterSeconds -MaxDepth $MaxDepth -ActiveProcessCount $ActiveProcessCount -Required $Required
}
return [pscustomobject]@{
name = $Name
required = [bool]$Required
queuePattern = $QueuePattern
queueCount = [int]$items.Count
queues = @($items)
depth = [int](($items | Measure-Object -Property depth -Sum).Sum)
sizeBytes = [int64](($items | Measure-Object -Property sizeBytes -Sum).Sum)
activeProcessCount = [int]$ActiveProcessCount
staleAfterSeconds = [int]$StaleAfterSeconds
maxDepth = [int]$MaxDepth
ok = [bool](-not ($items | Where-Object { -not $_.ok }))
}
}
function Resolve-ActivityWatchLaunchTaskName {
param(
[Parameter(Mandatory = $true)]
[string]$TaskName
)
if ($TaskName -notmatch '\[[^\]]+_Administrator\]') {
return $TaskName
}
$localizedCandidate = 'ActivityWatch Launch [{0}_Администратор]' -f $awHostname
$localizedTask = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $localizedCandidate } | Select-Object -First 1
if ($localizedTask) {
return $localizedCandidate
}
try {
$builtinAdmin = Get-LocalUser -ErrorAction Stop |
Where-Object { [string]$_.SID -match '-500$' } |
Select-Object -First 1
if ($builtinAdmin -and -not [string]::IsNullOrWhiteSpace([string]$builtinAdmin.Name)) {
$candidate = 'ActivityWatch Launch [{0}_{1}]' -f $awHostname, [string]$builtinAdmin.Name
$existing = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $candidate } | Select-Object -First 1
if ($existing) {
return $candidate
}
}
}
catch {
}
return $TaskName
}
function Get-TaskSnapshot {
param(
[Parameter(Mandatory = $true)]
@@ -337,14 +425,13 @@ function Get-TaskSnapshot {
}
catch {
}
[pscustomobject]@{
taskName = [string]$task.TaskName
present = $true
enabled = [bool]$enabled
state = [string]$task.State
lastResult = if ($taskInfo) { [int64]$taskInfo.LastTaskResult } else { $null }
ok = [bool]($enabled)
ok = [bool]$enabled
}
}
)
@@ -413,10 +500,14 @@ else {
0
}
$sessionScopedCollectorsRequired = ($sessionScopedExpectedCount -gt 0)
$liveSessionScopedCollectorsRequired = ($liveSessionBoundUsers.Count -gt 0)
$collectorGuardService = Get-Service -Name 'AWatchRusCollectorGuard' -ErrorAction SilentlyContinue
$collectorGuardActive = [bool]($collectorGuardService -and $collectorGuardService.Status -eq 'Running')
$taskNames = @()
if ($config.userTasks) {
$taskNames += @($config.userTasks | ForEach-Object { [string]$_.launchTaskName })
$taskNames += @($config.userTasks | ForEach-Object { Resolve-ActivityWatchLaunchTaskName -TaskName ([string]$_.launchTaskName) })
}
$taskNames += [string]$config.recovery.taskName
$tasks = @(Get-TaskSnapshot -TaskNames $taskNames)
@@ -438,24 +529,24 @@ foreach ($watcher in $runningWatchers) {
$bucketChecks = @(
Get-BucketHealth -BucketId ('aw-worktime-sessions_' + $awHostname) -MaxAgeSeconds $sessionFreshnessSeconds -Required $true -RequireFreshEvent $true
)
if ($sessionScopedCollectorsRequired -and $afkExpected) {
if ($liveSessionScopedCollectorsRequired -and $afkExpected) {
$bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-afk_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false
}
if ($sessionScopedCollectorsRequired -and $windowExpected) {
if ($liveSessionScopedCollectorsRequired -and $windowExpected) {
$bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-window_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false
}
if ($sessionScopedCollectorsRequired) {
if ($liveSessionScopedCollectorsRequired) {
$bucketChecks += Get-BucketHealth -BucketId ('aw-dlp-endpoint-signals_' + $awHostname) -MaxAgeSeconds $endpointFreshnessSeconds -Required $true -RequireFreshEvent $true
}
if ($sessionScopedCollectorsRequired -and $fileOpsExpected) {
if ($liveSessionScopedCollectorsRequired -and $fileOpsExpected) {
$bucketChecks += Get-BucketHealth -BucketId ('aw-file-operations_' + $awHostname) -MaxAgeSeconds $transportStaleSeconds -Required $false -RequireFreshEvent $true
}
$queueChecks = @(
Get-TransportQueueHealth -Name 'endpoint' -QueuePath (Join-Path $stateRoot 'dlp-endpoint-signals-queue.jsonl') -LockPath (Join-Path $stateRoot 'dlp-endpoint-signals-queue.lock') -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($endpointCollectorProcesses).Count -Required $sessionScopedCollectorsRequired
Get-TransportQueueGroupHealth -Name 'endpoint' -StateRoot $stateRoot -QueuePattern 'dlp-endpoint-signals-queue*.jsonl' -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($endpointCollectorProcesses).Count -Required $liveSessionScopedCollectorsRequired
)
if ($fileOpsExpected) {
$queueChecks += Get-TransportQueueHealth -Name 'fileops' -QueuePath (Join-Path $stateRoot 'file-operations-queue.jsonl') -LockPath (Join-Path $stateRoot 'file-operations-queue.lock') -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($fileCollectorProcesses).Count -Required $sessionScopedCollectorsRequired
$queueChecks += Get-TransportQueueGroupHealth -Name 'fileops' -StateRoot $stateRoot -QueuePattern 'file-operations-queue*.jsonl' -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($fileCollectorProcesses).Count -Required $liveSessionScopedCollectorsRequired
}
$printServiceOperationalEnabled = $false
@@ -502,12 +593,14 @@ $result = [ordered]@{
}
tasks = [ordered]@{
list = $tasks
ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present -or -not $_.enabled }))
ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.ok }))
}
processes = [ordered]@{
liveSessionBoundUsers = $liveSessionBoundUsers
sessionBoundUsers = $interactiveSessionBoundUsers
sessionScopedExpectedCount = [int]$sessionScopedExpectedCount
liveSessionScopedCollectorsRequired = [bool]$liveSessionScopedCollectorsRequired
collectorGuardServiceActive = [bool]$collectorGuardActive
watchers = @($runningWatchers)
watcherDuplicates = @($watcherDuplicates)
sessionCollectors = @($sessionCollectorProcesses)