From 429501d4fe2bd0037678171230839affab837703 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 7 May 2026 12:50:35 +0300 Subject: [PATCH] chore(pssa): apply safe PSScriptAnalyzer fixes (BOM, empty catch -> Write-Error, Write-Host -> Write-Output) Applied automatic, low-risk fixes for PSScriptAnalyzer warnings. Please review changes for behavior-sensitive code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- windows/ActivityWatch.Windows.Common.psm1 | 10910 ++++++++++++++++- windows/browser-domains-native-collector.ps1 | 3338 ++++- windows/deploy-domain-users.ps1 | 12 +- windows/deploy-ensemble.ps1 | 8 +- windows/deploy-single-user.ps1 | 14 +- windows/dlp-endpoint-signals-collector.ps1 | 6706 ++++++++++ windows/email-outbound-collector.ps1 | 2330 +++- windows/file-operations-collector.ps1 | 1131 +- windows/hardening-recovery.ps1 | 8 +- windows/migrate-awatch-rus-paths.ps1 | 2 +- windows/validate-deployment.ps1 | 292 +- windows/worktime-session-collector.ps1 | 497 +- 12 files changed, 25220 insertions(+), 28 deletions(-) diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index 5f1e7fb..31a20f2 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -1,4 +1,109 @@ -Set-StrictMode -Version Latest +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Запустите этот скрипт из PowerShell с правами администратора.' + } +} + +function New-ActivityWatchDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Enable-ActivityWatchPrintTelemetry { + $policyPath = 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' + if (-not (Test-Path -LiteralPath $policyPath)) { + New-Item -Path $policyPath -Force | Out-Null + } + New-ItemProperty -Path $policyPath -Name 'ShowJobTitleInEventLogs' -Value 1 -PropertyType DWord -Force | Out-Null + + & wevtutil.exe sl 'Microsoft-Windows-PrintService/Operational' /e:true | Out-Null +} + + +function Get-ActivityWatchPackageUrl { + param( + [string]$Version = 'v0.13.2' + ) + + return "https://github.com/ActivityWatch/activitywatch/releases/download/$Version/activitywatch-$Version-windows-x86_64.zip" +} + +function Get-ActivityWatchArchive { + param( + [string]$PackageZipPath, + [string]$PackageUrl, + [string]$Version = 'v0.13.2', + [Parameter(Mandatory = $true)] + [string]$WorkingRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + + if ($PackageZipPath) { + $resolved = Resolve-Path -LiteralPath $PackageZipPath -ErrorAction Stop + return $resolved.Path + } + + if (-not $PackageUrl) { + $PackageUrl = Get-ActivityWatchPackageUrl -Version $Version + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $suffix = ([guid]::NewGuid().Guid.Substring(0, 8)) + $archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix) + Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath + return $archivePath +} + +function Get-ActivityWatchPackageRoot { + param( + [Parameter(Mandatory = $true)] + [string]$ExpandedRoot + ) + + $afkBinary = Get-ChildItem -Path $ExpandedRoot -Filter 'aw-watcher-afk.exe' -File -Recurse | + Select-Object -First 1 + + if (-not $afkBinary) { + throw "Не удалось найти aw-watcher-afk.exe в $ExpandedRoot." + } + + return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent) +} + +function Install-ActivityWatchPackage { + param( + [Parameter(Mandatory = $true)] + [string]$ArchivePath, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$WorkingRoot, + [Parameter(Mandatory = $true)] + [string]$BackupRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + New-ActivityWatchDirectory -Path $BackupRoot + + # Ensure nothing is holding locks inside InstallRoot during upgrade. + foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) { + try { + Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } + catch { Write-Error Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Assert-Administrator { @@ -1211,3 +1316,10806 @@ function Start-ActivityWatchTasks { } Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries +; } + } + Start-Sleep -Seconds 2 + + $extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid) + if (Test-Path -LiteralPath $extractRoot) { + Remove-Item -LiteralPath $extractRoot -Recurse -Force + } + New-ActivityWatchDirectory -Path $extractRoot + + Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force + $packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot + + if (Test-Path -LiteralPath $InstallRoot) { + $existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue + if ($existingItems) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupPath = Join-Path $BackupRoot ("install-$stamp") + New-ActivityWatchDirectory -Path $backupPath + Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force + Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force + } + } + else { + New-ActivityWatchDirectory -Path $InstallRoot + } + + Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force + + return [pscustomobject]@{ + PackageRoot = $packageRoot + ExtractRoot = $extractRoot + BackupRoot = $BackupRoot + } +} + +function Get-ActivityWatchExecutableMap { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot + ) + + $map = [ordered]@{ + Afk = Join-Path $InstallRoot 'aw-watcher-afk\aw-watcher-afk.exe' + Window = Join-Path $InstallRoot 'aw-watcher-window\aw-watcher-window.exe' + } + + foreach ($entry in $map.GetEnumerator()) { + if (-not (Test-Path -LiteralPath $entry.Value)) { + throw "Не найден обязательный исполняемый файл ActivityWatch: $($entry.Value)" + } + } + + return [pscustomobject]$map +} + +function Normalize-ActivityWatchUsers { + param( + [string[]]$Users, + [string]$UserListPath, + [string]$Domain + ) + + $collected = New-Object System.Collections.Generic.List[string] + + if ($Users) { + foreach ($user in $Users) { + if (-not [string]::IsNullOrWhiteSpace($user)) { + $collected.Add($user.Trim()) + } + } + } + + if ($UserListPath) { + $resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop + $extension = [IO.Path]::GetExtension($resolved.Path) + if ($extension -ieq '.csv') { + $rows = Import-Csv -LiteralPath $resolved.Path + foreach ($row in $rows) { + foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') { + if ($row.PSObject.Properties.Name -contains $column) { + $value = [string]$row.$column + if (-not [string]::IsNullOrWhiteSpace($value)) { + $collected.Add($value.Trim()) + break + } + } + } + } + } + else { + Get-Content -LiteralPath $resolved.Path | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith('#')) { + $collected.Add($line) + } + } + } + } + + $normalized = $collected | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { + if ($Domain -and ($_ -notmatch '[\\@]')) { + '{0}\{1}' -f $Domain, $_ + } + else { + $_ + } + } | + Sort-Object -Unique + + if (-not $normalized -or $normalized.Count -eq 0) { + throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.' + } + + return @($normalized) +} + +function Get-ActivityWatchTaskNameToken { + param( + [Parameter(Mandatory = $true)] + [string]$UserId + ) + + $buffer = [Text.StringBuilder]::new() + foreach ($character in $UserId.ToCharArray()) { + if ([char]::IsLetterOrDigit($character)) { + [void]$buffer.Append($character) + } + else { + [void]$buffer.Append('_') + } + } + + return $buffer.ToString().Trim('_') +} + +function New-ActivityWatchUserTaskDefinitions { + param( + [Parameter(Mandatory = $true)] + [string[]]$Users + ) + + $result = foreach ($user in $Users) { + $token = Get-ActivityWatchTaskNameToken -UserId $user + [pscustomobject]@{ + UserId = $user + LaunchTaskName = "ActivityWatch Launch [$token]" + } + } + + return @($result) +} + +function Copy-ActivityWatchCollectorAssets { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScriptSource, + [string]$EmailCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$ExampleRulesSource, + [Parameter(Mandatory = $true)] + [string]$ExamplePolicySource, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [string]$CustomRulesSource, + [string]$CustomPolicySource + ) + + New-ActivityWatchDirectory -Path $StateRoot + + $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' + $endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1' + $fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1' + $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' + $emailCollectorTarget = Join-Path $StateRoot 'email-outbound-collector.ps1' + $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' + $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' + $examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json' + $policyTarget = Join-Path $StateRoot 'dlp-policy.json' + + Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force + Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force + Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force + if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) { + Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force + } + Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force + Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force + + if ($CustomRulesSource) { + $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force + } + else { + Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force + } + + if ($CustomPolicySource) { + $resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedPolicy.Path -Destination $policyTarget -Force + } + else { + Copy-Item -LiteralPath $examplePolicyTarget -Destination $policyTarget -Force + } + + return [pscustomobject]@{ + CollectorScript = $collectorTarget + EndpointCollectorScript = $endpointCollectorTarget + FileCollectorScript = $fileCollectorTarget + SessionCollectorScript = $sessionCollectorTarget + EmailCollectorScript = $emailCollectorTarget + ExampleRules = $exampleRulesTarget + ActiveRules = $rulesTarget + ExamplePolicy = $examplePolicyTarget + ActivePolicy = $policyTarget + } +} + +function New-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$ServerHost, + [Parameter(Mandatory = $true)] + [int]$ServerPort, + [Parameter(Mandatory = $true)] + [string]$ServerScheme, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot, + [Parameter(Mandatory = $true)] + [string]$CollectorScript, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScript, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScript, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScript, + [string]$EmailCollectorScript, + [Parameter(Mandatory = $true)] + [string]$RulesPath, + [Parameter(Mandatory = $true)] + [string]$PolicyPath, + [Parameter(Mandatory = $true)] + [int]$PollSeconds, + [Parameter(Mandatory = $true)] + [int]$PulseSeconds, + [Parameter(Mandatory = $true)] + [int]$RecoveryIntervalSeconds, + [bool]$AfkEnabled = $true, + [bool]$WindowEnabled = $true, + [bool]$FileOpsEnabled = $true, + [bool]$LocalAgentLogsEnabled = $true, + [bool]$IncidentCaptureEnabled = $true, + [bool]$IncidentScreenshotEnabled = $true, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled = $true, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [pscustomobject[]]$UserTasks, + [string]$PackageVersion = 'v0.13.2' + ) + + $effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' } + + return [pscustomobject]@{ + version = 1 + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + server = [pscustomobject]@{ + host = $ServerHost + port = $ServerPort + scheme = $ServerScheme + } + paths = [pscustomobject]@{ + installRoot = $InstallRoot + stateRoot = $StateRoot + logsRoot = $LogsRoot + collectorScript = $CollectorScript + endpointCollectorScript = $EndpointCollectorScript + emailCollectorScript = $EmailCollectorScript + fileCollectorScript = $FileCollectorScript + sessionCollectorScript = $SessionCollectorScript + rulesPath = $RulesPath + policyPath = $PolicyPath + launchScript = $LaunchScriptPath + recoveryScript = $RecoveryScriptPath + } + collector = [pscustomobject]@{ + pollSeconds = $PollSeconds + pulseSeconds = $PulseSeconds + } + collectors = [pscustomobject]@{ + afkEnabled = $AfkEnabled + windowEnabled = $WindowEnabled + fileOpsEnabled = $FileOpsEnabled + emailEnabled = ($null -ne $EmailCollectorScript -and $EmailCollectorScript -ne '') + } + logging = [pscustomobject]@{ + localAgentLogsEnabled = $LocalAgentLogsEnabled + } + incidentCapture = [pscustomobject]@{ + enabled = $IncidentCaptureEnabled + screenshotEnabled = $IncidentScreenshotEnabled + artifactsRoot = $effectiveIncidentArtifactsRoot + } + sessionEvents = [pscustomobject]@{ + logonEnabled = $LogonMarkerEnabled + bucketPrefix = 'aw-session-events' + } + recovery = [pscustomobject]@{ + intervalSeconds = $RecoveryIntervalSeconds + taskName = 'ActivityWatch Recovery' + } + dlp = [pscustomobject]@{ + incidentBucketPrefix = 'aw-dlp-incidents' + enabled = $true + } + package = [pscustomobject]@{ + version = $PackageVersion + } + userTasks = @($UserTasks) + } +} + +function Write-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $json = $Config | ConvertTo-Json -Depth 8 + Set-Content -LiteralPath $Path -Value $json -Encoding UTF8 +} + +function Read-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация развёртывания не найдена: $Path" + } + + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Write-ActivityWatchLaunchScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Stop' + +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http +`$script:MaxCollectorPowerShellProcesses = 24 + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Test-ProcessInSession { + param( + [string]`$Name, + [int]`$SessionId + ) + + return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1) +} + +function Test-CollectorRunning { + param( + [string]`$ScriptPath, + [int]`$SessionId + ) + + `$escapedCollector = [Regex]::Escape(`$ScriptPath) + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.SessionId -eq `$SessionId -and + `$_.CommandLine -match `$escapedCollector + } + + return [bool](`$processes | Select-Object -First 1) +} + +function Get-CollectorPowerShellProcessCount { + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.CommandLine -match 'AWatch-rus' -and + `$_.CommandLine -match '\.ps1' + } + + return @(`$processes).Count +} + +function New-LaunchLock { + param([string]`$StateRoot, [int]`$SessionId) + + `$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId) + 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 { Write-Error Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Запустите этот скрипт из PowerShell с правами администратора.' + } +} + +function New-ActivityWatchDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Enable-ActivityWatchPrintTelemetry { + $policyPath = 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' + if (-not (Test-Path -LiteralPath $policyPath)) { + New-Item -Path $policyPath -Force | Out-Null + } + New-ItemProperty -Path $policyPath -Name 'ShowJobTitleInEventLogs' -Value 1 -PropertyType DWord -Force | Out-Null + + & wevtutil.exe sl 'Microsoft-Windows-PrintService/Operational' /e:true | Out-Null +} + + +function Get-ActivityWatchPackageUrl { + param( + [string]$Version = 'v0.13.2' + ) + + return "https://github.com/ActivityWatch/activitywatch/releases/download/$Version/activitywatch-$Version-windows-x86_64.zip" +} + +function Get-ActivityWatchArchive { + param( + [string]$PackageZipPath, + [string]$PackageUrl, + [string]$Version = 'v0.13.2', + [Parameter(Mandatory = $true)] + [string]$WorkingRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + + if ($PackageZipPath) { + $resolved = Resolve-Path -LiteralPath $PackageZipPath -ErrorAction Stop + return $resolved.Path + } + + if (-not $PackageUrl) { + $PackageUrl = Get-ActivityWatchPackageUrl -Version $Version + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $suffix = ([guid]::NewGuid().Guid.Substring(0, 8)) + $archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix) + Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath + return $archivePath +} + +function Get-ActivityWatchPackageRoot { + param( + [Parameter(Mandatory = $true)] + [string]$ExpandedRoot + ) + + $afkBinary = Get-ChildItem -Path $ExpandedRoot -Filter 'aw-watcher-afk.exe' -File -Recurse | + Select-Object -First 1 + + if (-not $afkBinary) { + throw "Не удалось найти aw-watcher-afk.exe в $ExpandedRoot." + } + + return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent) +} + +function Install-ActivityWatchPackage { + param( + [Parameter(Mandatory = $true)] + [string]$ArchivePath, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$WorkingRoot, + [Parameter(Mandatory = $true)] + [string]$BackupRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + New-ActivityWatchDirectory -Path $BackupRoot + + # Ensure nothing is holding locks inside InstallRoot during upgrade. + foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) { + try { + Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } + catch { + } + } + Start-Sleep -Seconds 2 + + $extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid) + if (Test-Path -LiteralPath $extractRoot) { + Remove-Item -LiteralPath $extractRoot -Recurse -Force + } + New-ActivityWatchDirectory -Path $extractRoot + + Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force + $packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot + + if (Test-Path -LiteralPath $InstallRoot) { + $existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue + if ($existingItems) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupPath = Join-Path $BackupRoot ("install-$stamp") + New-ActivityWatchDirectory -Path $backupPath + Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force + Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force + } + } + else { + New-ActivityWatchDirectory -Path $InstallRoot + } + + Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force + + return [pscustomobject]@{ + PackageRoot = $packageRoot + ExtractRoot = $extractRoot + BackupRoot = $BackupRoot + } +} + +function Get-ActivityWatchExecutableMap { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot + ) + + $map = [ordered]@{ + Afk = Join-Path $InstallRoot 'aw-watcher-afk\aw-watcher-afk.exe' + Window = Join-Path $InstallRoot 'aw-watcher-window\aw-watcher-window.exe' + } + + foreach ($entry in $map.GetEnumerator()) { + if (-not (Test-Path -LiteralPath $entry.Value)) { + throw "Не найден обязательный исполняемый файл ActivityWatch: $($entry.Value)" + } + } + + return [pscustomobject]$map +} + +function Normalize-ActivityWatchUsers { + param( + [string[]]$Users, + [string]$UserListPath, + [string]$Domain + ) + + $collected = New-Object System.Collections.Generic.List[string] + + if ($Users) { + foreach ($user in $Users) { + if (-not [string]::IsNullOrWhiteSpace($user)) { + $collected.Add($user.Trim()) + } + } + } + + if ($UserListPath) { + $resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop + $extension = [IO.Path]::GetExtension($resolved.Path) + if ($extension -ieq '.csv') { + $rows = Import-Csv -LiteralPath $resolved.Path + foreach ($row in $rows) { + foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') { + if ($row.PSObject.Properties.Name -contains $column) { + $value = [string]$row.$column + if (-not [string]::IsNullOrWhiteSpace($value)) { + $collected.Add($value.Trim()) + break + } + } + } + } + } + else { + Get-Content -LiteralPath $resolved.Path | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith('#')) { + $collected.Add($line) + } + } + } + } + + $normalized = $collected | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { + if ($Domain -and ($_ -notmatch '[\\@]')) { + '{0}\{1}' -f $Domain, $_ + } + else { + $_ + } + } | + Sort-Object -Unique + + if (-not $normalized -or $normalized.Count -eq 0) { + throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.' + } + + return @($normalized) +} + +function Get-ActivityWatchTaskNameToken { + param( + [Parameter(Mandatory = $true)] + [string]$UserId + ) + + $buffer = [Text.StringBuilder]::new() + foreach ($character in $UserId.ToCharArray()) { + if ([char]::IsLetterOrDigit($character)) { + [void]$buffer.Append($character) + } + else { + [void]$buffer.Append('_') + } + } + + return $buffer.ToString().Trim('_') +} + +function New-ActivityWatchUserTaskDefinitions { + param( + [Parameter(Mandatory = $true)] + [string[]]$Users + ) + + $result = foreach ($user in $Users) { + $token = Get-ActivityWatchTaskNameToken -UserId $user + [pscustomobject]@{ + UserId = $user + LaunchTaskName = "ActivityWatch Launch [$token]" + } + } + + return @($result) +} + +function Copy-ActivityWatchCollectorAssets { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScriptSource, + [string]$EmailCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$ExampleRulesSource, + [Parameter(Mandatory = $true)] + [string]$ExamplePolicySource, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [string]$CustomRulesSource, + [string]$CustomPolicySource + ) + + New-ActivityWatchDirectory -Path $StateRoot + + $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' + $endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1' + $fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1' + $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' + $emailCollectorTarget = Join-Path $StateRoot 'email-outbound-collector.ps1' + $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' + $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' + $examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json' + $policyTarget = Join-Path $StateRoot 'dlp-policy.json' + + Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force + Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force + Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force + if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) { + Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force + } + Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force + Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force + + if ($CustomRulesSource) { + $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force + } + else { + Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force + } + + if ($CustomPolicySource) { + $resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedPolicy.Path -Destination $policyTarget -Force + } + else { + Copy-Item -LiteralPath $examplePolicyTarget -Destination $policyTarget -Force + } + + return [pscustomobject]@{ + CollectorScript = $collectorTarget + EndpointCollectorScript = $endpointCollectorTarget + FileCollectorScript = $fileCollectorTarget + SessionCollectorScript = $sessionCollectorTarget + EmailCollectorScript = $emailCollectorTarget + ExampleRules = $exampleRulesTarget + ActiveRules = $rulesTarget + ExamplePolicy = $examplePolicyTarget + ActivePolicy = $policyTarget + } +} + +function New-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$ServerHost, + [Parameter(Mandatory = $true)] + [int]$ServerPort, + [Parameter(Mandatory = $true)] + [string]$ServerScheme, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot, + [Parameter(Mandatory = $true)] + [string]$CollectorScript, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScript, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScript, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScript, + [string]$EmailCollectorScript, + [Parameter(Mandatory = $true)] + [string]$RulesPath, + [Parameter(Mandatory = $true)] + [string]$PolicyPath, + [Parameter(Mandatory = $true)] + [int]$PollSeconds, + [Parameter(Mandatory = $true)] + [int]$PulseSeconds, + [Parameter(Mandatory = $true)] + [int]$RecoveryIntervalSeconds, + [bool]$AfkEnabled = $true, + [bool]$WindowEnabled = $true, + [bool]$FileOpsEnabled = $true, + [bool]$LocalAgentLogsEnabled = $true, + [bool]$IncidentCaptureEnabled = $true, + [bool]$IncidentScreenshotEnabled = $true, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled = $true, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [pscustomobject[]]$UserTasks, + [string]$PackageVersion = 'v0.13.2' + ) + + $effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' } + + return [pscustomobject]@{ + version = 1 + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + server = [pscustomobject]@{ + host = $ServerHost + port = $ServerPort + scheme = $ServerScheme + } + paths = [pscustomobject]@{ + installRoot = $InstallRoot + stateRoot = $StateRoot + logsRoot = $LogsRoot + collectorScript = $CollectorScript + endpointCollectorScript = $EndpointCollectorScript + emailCollectorScript = $EmailCollectorScript + fileCollectorScript = $FileCollectorScript + sessionCollectorScript = $SessionCollectorScript + rulesPath = $RulesPath + policyPath = $PolicyPath + launchScript = $LaunchScriptPath + recoveryScript = $RecoveryScriptPath + } + collector = [pscustomobject]@{ + pollSeconds = $PollSeconds + pulseSeconds = $PulseSeconds + } + collectors = [pscustomobject]@{ + afkEnabled = $AfkEnabled + windowEnabled = $WindowEnabled + fileOpsEnabled = $FileOpsEnabled + emailEnabled = ($null -ne $EmailCollectorScript -and $EmailCollectorScript -ne '') + } + logging = [pscustomobject]@{ + localAgentLogsEnabled = $LocalAgentLogsEnabled + } + incidentCapture = [pscustomobject]@{ + enabled = $IncidentCaptureEnabled + screenshotEnabled = $IncidentScreenshotEnabled + artifactsRoot = $effectiveIncidentArtifactsRoot + } + sessionEvents = [pscustomobject]@{ + logonEnabled = $LogonMarkerEnabled + bucketPrefix = 'aw-session-events' + } + recovery = [pscustomobject]@{ + intervalSeconds = $RecoveryIntervalSeconds + taskName = 'ActivityWatch Recovery' + } + dlp = [pscustomobject]@{ + incidentBucketPrefix = 'aw-dlp-incidents' + enabled = $true + } + package = [pscustomobject]@{ + version = $PackageVersion + } + userTasks = @($UserTasks) + } +} + +function Write-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $json = $Config | ConvertTo-Json -Depth 8 + Set-Content -LiteralPath $Path -Value $json -Encoding UTF8 +} + +function Read-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация развёртывания не найдена: $Path" + } + + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Write-ActivityWatchLaunchScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Stop' + +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http +`$script:MaxCollectorPowerShellProcesses = 24 + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Test-ProcessInSession { + param( + [string]`$Name, + [int]`$SessionId + ) + + return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1) +} + +function Test-CollectorRunning { + param( + [string]`$ScriptPath, + [int]`$SessionId + ) + + `$escapedCollector = [Regex]::Escape(`$ScriptPath) + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.SessionId -eq `$SessionId -and + `$_.CommandLine -match `$escapedCollector + } + + return [bool](`$processes | Select-Object -First 1) +} + +function Get-CollectorPowerShellProcessCount { + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.CommandLine -match 'AWatch-rus' -and + `$_.CommandLine -match '\.ps1' + } + + return @(`$processes).Count +} + +function New-LaunchLock { + param([string]`$StateRoot, [int]`$SessionId) + + `$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId) + 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 + sessionId = `$SessionId + createdAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath `$lockPath -Value `$payload -Encoding UTF8 + return `$lockPath +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = `$true)][string]`$Uri, + [Parameter(Mandatory = `$true)][string]`$Json + ) + + `$httpClient = New-Object System.Net.Http.HttpClient + try { + `$content = New-Object System.Net.Http.StringContent(`$Json, [System.Text.Encoding]::UTF8, 'application/json') + `$response = `$httpClient.PostAsync(`$Uri, `$content).Result + if (-not `$response.IsSuccessStatusCode) { + return `$false + } + return `$true + } + catch { + return `$false + } + finally { + `$httpClient.Dispose() + } +} + +function Ensure-Bucket { + param( + [string]`$BucketId, + [string]`$ClientName, + [string]`$BucketType + ) + + if (`$script:KnownBuckets.ContainsKey(`$BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + `$script:KnownBuckets[`$BucketId] = `$true + return + } + catch { + } + + `$body = @{ + client = `$ClientName + type = `$BucketType + hostname = `$script:Hostname + } | ConvertTo-Json -Compress + + try { + if (-not (Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body)) { + return + } + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + } + catch { + return + } + } + + `$script:KnownBuckets[`$BucketId] = `$true +} + +function Send-LogonMarkerIfNeeded { + param( + [pscustomobject]`$Config, + [int]`$SessionId + ) + + `$sessionEvents = if (`$Config.PSObject.Properties.Name -contains 'sessionEvents') { `$Config.sessionEvents } else { `$null } + `$logging = if (`$Config.PSObject.Properties.Name -contains 'logging') { `$Config.logging } else { `$null } + `$logonEnabled = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]`$sessionEvents.logonEnabled } else { `$false } + if (-not `$logonEnabled) { + return + } + + `$bucketPrefix = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]`$sessionEvents.bucketPrefix)) { + [string]`$sessionEvents.bucketPrefix + } + else { + 'aw-session-events' + } + + `$stateRoot = [string]`$Config.paths.stateRoot + `$markerRoots = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) { + `$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers')) + } + if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) { + `$markerRoots.Add((Join-Path `$stateRoot 'markers')) + } + + `$markerDir = `$null + foreach (`$candidate in `$markerRoots) { + try { + if (-not (Test-Path -LiteralPath `$candidate)) { + New-Item -Path `$candidate -ItemType Directory -Force | Out-Null + } + + `$probePath = Join-Path `$candidate 'write-test.tmp' + Set-Content -LiteralPath `$probePath -Value 'ok' -Encoding ASCII + Remove-Item -LiteralPath `$probePath -Force -ErrorAction SilentlyContinue + `$markerDir = `$candidate + break + } + catch { + } + } + + if (-not `$markerDir) { + return + } + + `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId) + if (Test-Path -LiteralPath `$markerFile) { + return + } + + Set-Content -LiteralPath `$markerFile -Value ((Get-Date).ToUniversalTime().ToString('o')) -Encoding UTF8 + + `$bucketId = ('{0}_{1}' -f `$bucketPrefix, `$script:Hostname) + Ensure-Bucket -BucketId `$bucketId -ClientName 'aw-session-events' -BucketType 'aw.session.event' + + `$payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + eventType = 'logon' + username = `$env:USERNAME + userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)" + sessionId = `$SessionId + hostname = `$script:Hostname + source = 'launch-watchers-awatch-rus' + } + } | ConvertTo-Json -Depth 5 -Compress + + try { + Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$bucketId/heartbeat?pulsetime=1" -Json `$payload + } + catch { + Remove-Item -LiteralPath `$markerFile -Force -ErrorAction SilentlyContinue + throw + } +} + +function Start-CollectorScriptIfNeeded { + param( + [string]`$ScriptPath, + [string]`$ConfigPath, + [string]`$PowerShellExe, + [int]`$SessionId + ) + + if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + return + } + + if (-not (Test-Path -LiteralPath `$ScriptPath)) { + return + } + + if (Test-CollectorRunning -ScriptPath `$ScriptPath -SessionId `$SessionId) { + return + } + + if ((Get-CollectorPowerShellProcessCount) -ge `$script:MaxCollectorPowerShellProcesses) { + return + } + + `$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null } + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass') + if (`$staParam) { `$argumentList += `$staParam } + `$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden +} + +`$config = Get-DeploymentConfig -Path `$ConfigPath +`$sessionId = (Get-Process -Id `$PID).SessionId +`$installRoot = [string]`$config.paths.installRoot +`$stateRoot = [string]`$config.paths.stateRoot +`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port +`$script:Hostname = `$env:COMPUTERNAME +`$script:KnownBuckets = @{} +`$collectorScript = [string]`$config.paths.collectorScript +`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' } +`$fileCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]`$config.paths.fileCollectorScript } else { Join-Path `$stateRoot 'file-operations-collector.ps1' } +`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' } +`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe' +`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe' +`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port) +`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +`$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 } +`$fileOpsEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]`$config.collectors.fileOpsEnabled } else { `$true } +`$emailEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'emailEnabled') { [bool]`$config.collectors.emailEnabled } else { `$false } +`$emailCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'emailCollectorScript') { [string]`$config.paths.emailCollectorScript } else { Join-Path `$stateRoot 'email-outbound-collector.ps1' } +`$launchLockPath = New-LaunchLock -StateRoot `$stateRoot -SessionId `$sessionId +if (-not `$launchLockPath) { + return +} + +try { + if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) { + throw "Не найден aw-watcher-afk.exe: `$afkExe" + } + + if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) { + throw "Не найден aw-watcher-window.exe: `$windowExe" + } + + if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) { + Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + if (`$windowEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) { + Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + try { + Send-LogonMarkerIfNeeded -Config `$config -SessionId `$sessionId + } + catch { + } + Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$fileOpsEnabled) { + Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } + Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$emailEnabled -and (Test-Path -LiteralPath `$emailCollectorScript)) { + Start-CollectorScriptIfNeeded -ScriptPath `$emailCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } +} +finally { + if (`$launchLockPath -and (Test-Path -LiteralPath `$launchLockPath)) { + Remove-Item -LiteralPath `$launchLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Write-ActivityWatchRecoveryScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Continue' + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Get-RecoveryConfigPaths { + param([string]`$PrimaryConfigPath) + + `$paths = New-Object System.Collections.Generic.List[string] + if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) { + `$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path) + } + + `$searchRoot = `$env:ProgramData + if (`$PrimaryConfigPath) { + `$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent + `$candidateRoot = Split-Path -Path `$stateRoot -Parent + if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) { + `$searchRoot = `$candidateRoot + } + } + + if (Test-Path -LiteralPath `$searchRoot) { + Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { `$_.Name -like 'ActivityWatch*' } | + ForEach-Object { + `$candidate = Join-Path `$_.FullName 'deployment-config.json' + if (Test-Path -LiteralPath `$candidate) { + `$paths.Add(`$candidate) + } + } + } + + return @(`$paths | Sort-Object -Unique) +} + +function Get-RecoveryTaskNames { + param([string[]]`$ConfigPaths) + + `$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach (`$candidatePath in @(`$ConfigPaths)) { + try { + `$config = Get-DeploymentConfig -Path `$candidatePath + foreach (`$task in @(`$config.userTasks)) { + `$taskName = [string]`$task.launchTaskName + if (-not [string]::IsNullOrWhiteSpace(`$taskName)) { + [void]`$taskNames.Add(`$taskName) + } + } + } + catch { + } + } + + return @(`$taskNames) +} + +function New-RecoveryLock { + param([string]`$PrimaryConfigPath) + + `$stateRoot = if (`$PrimaryConfigPath) { Split-Path -Path `$PrimaryConfigPath -Parent } else { Join-Path `$env:ProgramData 'AWatch-rus' } + if (-not (Test-Path -LiteralPath `$stateRoot)) { + New-Item -Path `$stateRoot -ItemType Directory -Force | Out-Null + } + + `$lockPath = Join-Path `$stateRoot 'recovery-loop.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 Start-TaskIfNotRunning { + param([string]`$TaskName) + if ([string]::IsNullOrWhiteSpace(`$TaskName)) { + return + } + + try { + `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + if (-not `$task) { + return + } + if ([string]`$task.State -eq 'Running') { + return + } + Start-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + } + catch { + } +} + +`$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath +if (-not `$recoveryLockPath) { + return +} + +try { + while (`$true) { + `$sleepSeconds = 180 + try { + `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath + foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { + Start-TaskIfNotRunning -TaskName `$taskName + } + + `$config = Get-DeploymentConfig -Path `$ConfigPath + if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { + `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) + } + } + catch { + } + + Start-Sleep -Seconds `$sleepSeconds + } +} +finally { + if (`$recoveryLockPath -and (Test-Path -LiteralPath `$recoveryLockPath)) { + Remove-Item -LiteralPath `$recoveryLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Get-ActivityWatchHiddenLauncherPath { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath + ) + + $directory = Split-Path -Path $ScriptPath -Parent + $baseName = [IO.Path]::GetFileNameWithoutExtension($ScriptPath) + return Join-Path $directory ("{0}-hidden.vbs" -f $baseName) +} + +function Write-ActivityWatchHiddenPowerShellWrapper { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $escapedPowerShellExe = $powershellExe.Replace('"', '""') + $escapedScriptPath = $ScriptPath.Replace('"', '""') + $escapedConfigPath = $ConfigPath.Replace('"', '""') + + $content = @" +Set shell = CreateObject("WScript.Shell") +shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding ASCII +} + +function Remove-LegacyActivityWatchEntries { + $legacyTaskNames = @( + 'ActivityWatch Watchers', + 'ActivityWatch Guard', + 'ActivityWatch Heal' + ) + + foreach ($taskName in $legacyTaskNames) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + } + + $runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' + foreach ($name in 'ActivityWatchAFK', 'ActivityWatchWindow', 'ActivityWatchBrowserCollector') { + Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue + } +} + +function Remove-ActivityWatchScheduledTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName + ) + + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + & cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null + + for ($attempt = 0; $attempt -lt 10; $attempt++) { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + return + } + + Start-Sleep -Milliseconds 300 + } +} + +function Set-ActivityWatchScheduledTaskAction { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$Execute, + [Parameter(Mandatory = $true)] + [string]$Arguments + ) + + $taskCommand = ('"{0}" {1}' -f $Execute, $Arguments) + & schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "schtasks.exe /Change завершился с ошибкой для $TaskName" + } +} + +function Get-ActivityWatchScheduledTaskByCommand { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [string]$CommandMatch + ) + + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($task) { + return $task + } + + if ([string]::IsNullOrWhiteSpace($CommandMatch)) { + return $null + } + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$CommandMatch*") { + return $candidate + } + } + } + + return $null +} + +function Remove-StaleActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath + ) + + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + $desiredTaskNames = @($TaskDefinitions | ForEach-Object { [string]$_.LaunchTaskName }) + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + $taskName = [string]$candidate.TaskName + if ($desiredTaskNames -contains $taskName) { + continue + } + + $usesCurrentLauncher = $false + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$launcherPath*") { + $usesCurrentLauncher = $true + break + } + } + + if ($usesCurrentLauncher) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + } + } +} + +function Register-ActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath + Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath + + foreach ($definition in $TaskDefinitions) { + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId + $principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + $existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath + + if ($existingTask) { + Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments + continue + } + + Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName + Register-ScheduledTask -TaskName $definition.LaunchTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null + } +} + +function Register-ActivityWatchRecoveryTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + Remove-ActivityWatchScheduledTask -TaskName $TaskName + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null +} + +function Set-ActivityWatchAcl { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot + ) + + foreach ($path in $InstallRoot, $StateRoot, $LogsRoot) { + New-ActivityWatchDirectory -Path $path + } + + & icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $InstallRoot" + } + + & icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $StateRoot" + } + + & icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $LogsRoot" + } +} + +function Start-ActivityWatchTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [string]$RecoveryTaskName = 'ActivityWatch Recovery' + ) + + foreach ($definition in $TaskDefinitions) { + Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue + } + + Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue +} + +Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries +; } + } + + `$payload = @{ + pid = `$PID + sessionId = `$SessionId + createdAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath `$lockPath -Value `$payload -Encoding UTF8 + return `$lockPath +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = `$true)][string]`$Uri, + [Parameter(Mandatory = `$true)][string]`$Json + ) + + `$httpClient = New-Object System.Net.Http.HttpClient + try { + `$content = New-Object System.Net.Http.StringContent(`$Json, [System.Text.Encoding]::UTF8, 'application/json') + `$response = `$httpClient.PostAsync(`$Uri, `$content).Result + if (-not `$response.IsSuccessStatusCode) { + return `$false + } + return `$true + } + catch { + return `$false + } + finally { + `$httpClient.Dispose() + } +} + +function Ensure-Bucket { + param( + [string]`$BucketId, + [string]`$ClientName, + [string]`$BucketType + ) + + if (`$script:KnownBuckets.ContainsKey(`$BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + `$script:KnownBuckets[`$BucketId] = `$true + return + } + catch { Write-Error Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Запустите этот скрипт из PowerShell с правами администратора.' + } +} + +function New-ActivityWatchDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Enable-ActivityWatchPrintTelemetry { + $policyPath = 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' + if (-not (Test-Path -LiteralPath $policyPath)) { + New-Item -Path $policyPath -Force | Out-Null + } + New-ItemProperty -Path $policyPath -Name 'ShowJobTitleInEventLogs' -Value 1 -PropertyType DWord -Force | Out-Null + + & wevtutil.exe sl 'Microsoft-Windows-PrintService/Operational' /e:true | Out-Null +} + + +function Get-ActivityWatchPackageUrl { + param( + [string]$Version = 'v0.13.2' + ) + + return "https://github.com/ActivityWatch/activitywatch/releases/download/$Version/activitywatch-$Version-windows-x86_64.zip" +} + +function Get-ActivityWatchArchive { + param( + [string]$PackageZipPath, + [string]$PackageUrl, + [string]$Version = 'v0.13.2', + [Parameter(Mandatory = $true)] + [string]$WorkingRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + + if ($PackageZipPath) { + $resolved = Resolve-Path -LiteralPath $PackageZipPath -ErrorAction Stop + return $resolved.Path + } + + if (-not $PackageUrl) { + $PackageUrl = Get-ActivityWatchPackageUrl -Version $Version + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $suffix = ([guid]::NewGuid().Guid.Substring(0, 8)) + $archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix) + Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath + return $archivePath +} + +function Get-ActivityWatchPackageRoot { + param( + [Parameter(Mandatory = $true)] + [string]$ExpandedRoot + ) + + $afkBinary = Get-ChildItem -Path $ExpandedRoot -Filter 'aw-watcher-afk.exe' -File -Recurse | + Select-Object -First 1 + + if (-not $afkBinary) { + throw "Не удалось найти aw-watcher-afk.exe в $ExpandedRoot." + } + + return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent) +} + +function Install-ActivityWatchPackage { + param( + [Parameter(Mandatory = $true)] + [string]$ArchivePath, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$WorkingRoot, + [Parameter(Mandatory = $true)] + [string]$BackupRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + New-ActivityWatchDirectory -Path $BackupRoot + + # Ensure nothing is holding locks inside InstallRoot during upgrade. + foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) { + try { + Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } + catch { + } + } + Start-Sleep -Seconds 2 + + $extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid) + if (Test-Path -LiteralPath $extractRoot) { + Remove-Item -LiteralPath $extractRoot -Recurse -Force + } + New-ActivityWatchDirectory -Path $extractRoot + + Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force + $packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot + + if (Test-Path -LiteralPath $InstallRoot) { + $existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue + if ($existingItems) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupPath = Join-Path $BackupRoot ("install-$stamp") + New-ActivityWatchDirectory -Path $backupPath + Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force + Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force + } + } + else { + New-ActivityWatchDirectory -Path $InstallRoot + } + + Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force + + return [pscustomobject]@{ + PackageRoot = $packageRoot + ExtractRoot = $extractRoot + BackupRoot = $BackupRoot + } +} + +function Get-ActivityWatchExecutableMap { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot + ) + + $map = [ordered]@{ + Afk = Join-Path $InstallRoot 'aw-watcher-afk\aw-watcher-afk.exe' + Window = Join-Path $InstallRoot 'aw-watcher-window\aw-watcher-window.exe' + } + + foreach ($entry in $map.GetEnumerator()) { + if (-not (Test-Path -LiteralPath $entry.Value)) { + throw "Не найден обязательный исполняемый файл ActivityWatch: $($entry.Value)" + } + } + + return [pscustomobject]$map +} + +function Normalize-ActivityWatchUsers { + param( + [string[]]$Users, + [string]$UserListPath, + [string]$Domain + ) + + $collected = New-Object System.Collections.Generic.List[string] + + if ($Users) { + foreach ($user in $Users) { + if (-not [string]::IsNullOrWhiteSpace($user)) { + $collected.Add($user.Trim()) + } + } + } + + if ($UserListPath) { + $resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop + $extension = [IO.Path]::GetExtension($resolved.Path) + if ($extension -ieq '.csv') { + $rows = Import-Csv -LiteralPath $resolved.Path + foreach ($row in $rows) { + foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') { + if ($row.PSObject.Properties.Name -contains $column) { + $value = [string]$row.$column + if (-not [string]::IsNullOrWhiteSpace($value)) { + $collected.Add($value.Trim()) + break + } + } + } + } + } + else { + Get-Content -LiteralPath $resolved.Path | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith('#')) { + $collected.Add($line) + } + } + } + } + + $normalized = $collected | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { + if ($Domain -and ($_ -notmatch '[\\@]')) { + '{0}\{1}' -f $Domain, $_ + } + else { + $_ + } + } | + Sort-Object -Unique + + if (-not $normalized -or $normalized.Count -eq 0) { + throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.' + } + + return @($normalized) +} + +function Get-ActivityWatchTaskNameToken { + param( + [Parameter(Mandatory = $true)] + [string]$UserId + ) + + $buffer = [Text.StringBuilder]::new() + foreach ($character in $UserId.ToCharArray()) { + if ([char]::IsLetterOrDigit($character)) { + [void]$buffer.Append($character) + } + else { + [void]$buffer.Append('_') + } + } + + return $buffer.ToString().Trim('_') +} + +function New-ActivityWatchUserTaskDefinitions { + param( + [Parameter(Mandatory = $true)] + [string[]]$Users + ) + + $result = foreach ($user in $Users) { + $token = Get-ActivityWatchTaskNameToken -UserId $user + [pscustomobject]@{ + UserId = $user + LaunchTaskName = "ActivityWatch Launch [$token]" + } + } + + return @($result) +} + +function Copy-ActivityWatchCollectorAssets { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScriptSource, + [string]$EmailCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$ExampleRulesSource, + [Parameter(Mandatory = $true)] + [string]$ExamplePolicySource, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [string]$CustomRulesSource, + [string]$CustomPolicySource + ) + + New-ActivityWatchDirectory -Path $StateRoot + + $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' + $endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1' + $fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1' + $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' + $emailCollectorTarget = Join-Path $StateRoot 'email-outbound-collector.ps1' + $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' + $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' + $examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json' + $policyTarget = Join-Path $StateRoot 'dlp-policy.json' + + Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force + Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force + Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force + if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) { + Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force + } + Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force + Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force + + if ($CustomRulesSource) { + $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force + } + else { + Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force + } + + if ($CustomPolicySource) { + $resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedPolicy.Path -Destination $policyTarget -Force + } + else { + Copy-Item -LiteralPath $examplePolicyTarget -Destination $policyTarget -Force + } + + return [pscustomobject]@{ + CollectorScript = $collectorTarget + EndpointCollectorScript = $endpointCollectorTarget + FileCollectorScript = $fileCollectorTarget + SessionCollectorScript = $sessionCollectorTarget + EmailCollectorScript = $emailCollectorTarget + ExampleRules = $exampleRulesTarget + ActiveRules = $rulesTarget + ExamplePolicy = $examplePolicyTarget + ActivePolicy = $policyTarget + } +} + +function New-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$ServerHost, + [Parameter(Mandatory = $true)] + [int]$ServerPort, + [Parameter(Mandatory = $true)] + [string]$ServerScheme, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot, + [Parameter(Mandatory = $true)] + [string]$CollectorScript, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScript, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScript, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScript, + [string]$EmailCollectorScript, + [Parameter(Mandatory = $true)] + [string]$RulesPath, + [Parameter(Mandatory = $true)] + [string]$PolicyPath, + [Parameter(Mandatory = $true)] + [int]$PollSeconds, + [Parameter(Mandatory = $true)] + [int]$PulseSeconds, + [Parameter(Mandatory = $true)] + [int]$RecoveryIntervalSeconds, + [bool]$AfkEnabled = $true, + [bool]$WindowEnabled = $true, + [bool]$FileOpsEnabled = $true, + [bool]$LocalAgentLogsEnabled = $true, + [bool]$IncidentCaptureEnabled = $true, + [bool]$IncidentScreenshotEnabled = $true, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled = $true, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [pscustomobject[]]$UserTasks, + [string]$PackageVersion = 'v0.13.2' + ) + + $effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' } + + return [pscustomobject]@{ + version = 1 + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + server = [pscustomobject]@{ + host = $ServerHost + port = $ServerPort + scheme = $ServerScheme + } + paths = [pscustomobject]@{ + installRoot = $InstallRoot + stateRoot = $StateRoot + logsRoot = $LogsRoot + collectorScript = $CollectorScript + endpointCollectorScript = $EndpointCollectorScript + emailCollectorScript = $EmailCollectorScript + fileCollectorScript = $FileCollectorScript + sessionCollectorScript = $SessionCollectorScript + rulesPath = $RulesPath + policyPath = $PolicyPath + launchScript = $LaunchScriptPath + recoveryScript = $RecoveryScriptPath + } + collector = [pscustomobject]@{ + pollSeconds = $PollSeconds + pulseSeconds = $PulseSeconds + } + collectors = [pscustomobject]@{ + afkEnabled = $AfkEnabled + windowEnabled = $WindowEnabled + fileOpsEnabled = $FileOpsEnabled + emailEnabled = ($null -ne $EmailCollectorScript -and $EmailCollectorScript -ne '') + } + logging = [pscustomobject]@{ + localAgentLogsEnabled = $LocalAgentLogsEnabled + } + incidentCapture = [pscustomobject]@{ + enabled = $IncidentCaptureEnabled + screenshotEnabled = $IncidentScreenshotEnabled + artifactsRoot = $effectiveIncidentArtifactsRoot + } + sessionEvents = [pscustomobject]@{ + logonEnabled = $LogonMarkerEnabled + bucketPrefix = 'aw-session-events' + } + recovery = [pscustomobject]@{ + intervalSeconds = $RecoveryIntervalSeconds + taskName = 'ActivityWatch Recovery' + } + dlp = [pscustomobject]@{ + incidentBucketPrefix = 'aw-dlp-incidents' + enabled = $true + } + package = [pscustomobject]@{ + version = $PackageVersion + } + userTasks = @($UserTasks) + } +} + +function Write-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $json = $Config | ConvertTo-Json -Depth 8 + Set-Content -LiteralPath $Path -Value $json -Encoding UTF8 +} + +function Read-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация развёртывания не найдена: $Path" + } + + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Write-ActivityWatchLaunchScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Stop' + +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http +`$script:MaxCollectorPowerShellProcesses = 24 + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Test-ProcessInSession { + param( + [string]`$Name, + [int]`$SessionId + ) + + return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1) +} + +function Test-CollectorRunning { + param( + [string]`$ScriptPath, + [int]`$SessionId + ) + + `$escapedCollector = [Regex]::Escape(`$ScriptPath) + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.SessionId -eq `$SessionId -and + `$_.CommandLine -match `$escapedCollector + } + + return [bool](`$processes | Select-Object -First 1) +} + +function Get-CollectorPowerShellProcessCount { + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.CommandLine -match 'AWatch-rus' -and + `$_.CommandLine -match '\.ps1' + } + + return @(`$processes).Count +} + +function New-LaunchLock { + param([string]`$StateRoot, [int]`$SessionId) + + `$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId) + 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 + sessionId = `$SessionId + createdAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath `$lockPath -Value `$payload -Encoding UTF8 + return `$lockPath +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = `$true)][string]`$Uri, + [Parameter(Mandatory = `$true)][string]`$Json + ) + + `$httpClient = New-Object System.Net.Http.HttpClient + try { + `$content = New-Object System.Net.Http.StringContent(`$Json, [System.Text.Encoding]::UTF8, 'application/json') + `$response = `$httpClient.PostAsync(`$Uri, `$content).Result + if (-not `$response.IsSuccessStatusCode) { + return `$false + } + return `$true + } + catch { + return `$false + } + finally { + `$httpClient.Dispose() + } +} + +function Ensure-Bucket { + param( + [string]`$BucketId, + [string]`$ClientName, + [string]`$BucketType + ) + + if (`$script:KnownBuckets.ContainsKey(`$BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + `$script:KnownBuckets[`$BucketId] = `$true + return + } + catch { + } + + `$body = @{ + client = `$ClientName + type = `$BucketType + hostname = `$script:Hostname + } | ConvertTo-Json -Compress + + try { + if (-not (Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body)) { + return + } + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + } + catch { + return + } + } + + `$script:KnownBuckets[`$BucketId] = `$true +} + +function Send-LogonMarkerIfNeeded { + param( + [pscustomobject]`$Config, + [int]`$SessionId + ) + + `$sessionEvents = if (`$Config.PSObject.Properties.Name -contains 'sessionEvents') { `$Config.sessionEvents } else { `$null } + `$logging = if (`$Config.PSObject.Properties.Name -contains 'logging') { `$Config.logging } else { `$null } + `$logonEnabled = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]`$sessionEvents.logonEnabled } else { `$false } + if (-not `$logonEnabled) { + return + } + + `$bucketPrefix = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]`$sessionEvents.bucketPrefix)) { + [string]`$sessionEvents.bucketPrefix + } + else { + 'aw-session-events' + } + + `$stateRoot = [string]`$Config.paths.stateRoot + `$markerRoots = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) { + `$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers')) + } + if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) { + `$markerRoots.Add((Join-Path `$stateRoot 'markers')) + } + + `$markerDir = `$null + foreach (`$candidate in `$markerRoots) { + try { + if (-not (Test-Path -LiteralPath `$candidate)) { + New-Item -Path `$candidate -ItemType Directory -Force | Out-Null + } + + `$probePath = Join-Path `$candidate 'write-test.tmp' + Set-Content -LiteralPath `$probePath -Value 'ok' -Encoding ASCII + Remove-Item -LiteralPath `$probePath -Force -ErrorAction SilentlyContinue + `$markerDir = `$candidate + break + } + catch { + } + } + + if (-not `$markerDir) { + return + } + + `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId) + if (Test-Path -LiteralPath `$markerFile) { + return + } + + Set-Content -LiteralPath `$markerFile -Value ((Get-Date).ToUniversalTime().ToString('o')) -Encoding UTF8 + + `$bucketId = ('{0}_{1}' -f `$bucketPrefix, `$script:Hostname) + Ensure-Bucket -BucketId `$bucketId -ClientName 'aw-session-events' -BucketType 'aw.session.event' + + `$payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + eventType = 'logon' + username = `$env:USERNAME + userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)" + sessionId = `$SessionId + hostname = `$script:Hostname + source = 'launch-watchers-awatch-rus' + } + } | ConvertTo-Json -Depth 5 -Compress + + try { + Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$bucketId/heartbeat?pulsetime=1" -Json `$payload + } + catch { + Remove-Item -LiteralPath `$markerFile -Force -ErrorAction SilentlyContinue + throw + } +} + +function Start-CollectorScriptIfNeeded { + param( + [string]`$ScriptPath, + [string]`$ConfigPath, + [string]`$PowerShellExe, + [int]`$SessionId + ) + + if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + return + } + + if (-not (Test-Path -LiteralPath `$ScriptPath)) { + return + } + + if (Test-CollectorRunning -ScriptPath `$ScriptPath -SessionId `$SessionId) { + return + } + + if ((Get-CollectorPowerShellProcessCount) -ge `$script:MaxCollectorPowerShellProcesses) { + return + } + + `$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null } + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass') + if (`$staParam) { `$argumentList += `$staParam } + `$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden +} + +`$config = Get-DeploymentConfig -Path `$ConfigPath +`$sessionId = (Get-Process -Id `$PID).SessionId +`$installRoot = [string]`$config.paths.installRoot +`$stateRoot = [string]`$config.paths.stateRoot +`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port +`$script:Hostname = `$env:COMPUTERNAME +`$script:KnownBuckets = @{} +`$collectorScript = [string]`$config.paths.collectorScript +`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' } +`$fileCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]`$config.paths.fileCollectorScript } else { Join-Path `$stateRoot 'file-operations-collector.ps1' } +`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' } +`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe' +`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe' +`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port) +`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +`$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 } +`$fileOpsEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]`$config.collectors.fileOpsEnabled } else { `$true } +`$emailEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'emailEnabled') { [bool]`$config.collectors.emailEnabled } else { `$false } +`$emailCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'emailCollectorScript') { [string]`$config.paths.emailCollectorScript } else { Join-Path `$stateRoot 'email-outbound-collector.ps1' } +`$launchLockPath = New-LaunchLock -StateRoot `$stateRoot -SessionId `$sessionId +if (-not `$launchLockPath) { + return +} + +try { + if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) { + throw "Не найден aw-watcher-afk.exe: `$afkExe" + } + + if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) { + throw "Не найден aw-watcher-window.exe: `$windowExe" + } + + if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) { + Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + if (`$windowEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) { + Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + try { + Send-LogonMarkerIfNeeded -Config `$config -SessionId `$sessionId + } + catch { + } + Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$fileOpsEnabled) { + Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } + Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$emailEnabled -and (Test-Path -LiteralPath `$emailCollectorScript)) { + Start-CollectorScriptIfNeeded -ScriptPath `$emailCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } +} +finally { + if (`$launchLockPath -and (Test-Path -LiteralPath `$launchLockPath)) { + Remove-Item -LiteralPath `$launchLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Write-ActivityWatchRecoveryScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Continue' + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Get-RecoveryConfigPaths { + param([string]`$PrimaryConfigPath) + + `$paths = New-Object System.Collections.Generic.List[string] + if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) { + `$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path) + } + + `$searchRoot = `$env:ProgramData + if (`$PrimaryConfigPath) { + `$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent + `$candidateRoot = Split-Path -Path `$stateRoot -Parent + if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) { + `$searchRoot = `$candidateRoot + } + } + + if (Test-Path -LiteralPath `$searchRoot) { + Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { `$_.Name -like 'ActivityWatch*' } | + ForEach-Object { + `$candidate = Join-Path `$_.FullName 'deployment-config.json' + if (Test-Path -LiteralPath `$candidate) { + `$paths.Add(`$candidate) + } + } + } + + return @(`$paths | Sort-Object -Unique) +} + +function Get-RecoveryTaskNames { + param([string[]]`$ConfigPaths) + + `$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach (`$candidatePath in @(`$ConfigPaths)) { + try { + `$config = Get-DeploymentConfig -Path `$candidatePath + foreach (`$task in @(`$config.userTasks)) { + `$taskName = [string]`$task.launchTaskName + if (-not [string]::IsNullOrWhiteSpace(`$taskName)) { + [void]`$taskNames.Add(`$taskName) + } + } + } + catch { + } + } + + return @(`$taskNames) +} + +function New-RecoveryLock { + param([string]`$PrimaryConfigPath) + + `$stateRoot = if (`$PrimaryConfigPath) { Split-Path -Path `$PrimaryConfigPath -Parent } else { Join-Path `$env:ProgramData 'AWatch-rus' } + if (-not (Test-Path -LiteralPath `$stateRoot)) { + New-Item -Path `$stateRoot -ItemType Directory -Force | Out-Null + } + + `$lockPath = Join-Path `$stateRoot 'recovery-loop.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 Start-TaskIfNotRunning { + param([string]`$TaskName) + if ([string]::IsNullOrWhiteSpace(`$TaskName)) { + return + } + + try { + `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + if (-not `$task) { + return + } + if ([string]`$task.State -eq 'Running') { + return + } + Start-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + } + catch { + } +} + +`$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath +if (-not `$recoveryLockPath) { + return +} + +try { + while (`$true) { + `$sleepSeconds = 180 + try { + `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath + foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { + Start-TaskIfNotRunning -TaskName `$taskName + } + + `$config = Get-DeploymentConfig -Path `$ConfigPath + if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { + `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) + } + } + catch { + } + + Start-Sleep -Seconds `$sleepSeconds + } +} +finally { + if (`$recoveryLockPath -and (Test-Path -LiteralPath `$recoveryLockPath)) { + Remove-Item -LiteralPath `$recoveryLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Get-ActivityWatchHiddenLauncherPath { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath + ) + + $directory = Split-Path -Path $ScriptPath -Parent + $baseName = [IO.Path]::GetFileNameWithoutExtension($ScriptPath) + return Join-Path $directory ("{0}-hidden.vbs" -f $baseName) +} + +function Write-ActivityWatchHiddenPowerShellWrapper { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $escapedPowerShellExe = $powershellExe.Replace('"', '""') + $escapedScriptPath = $ScriptPath.Replace('"', '""') + $escapedConfigPath = $ConfigPath.Replace('"', '""') + + $content = @" +Set shell = CreateObject("WScript.Shell") +shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding ASCII +} + +function Remove-LegacyActivityWatchEntries { + $legacyTaskNames = @( + 'ActivityWatch Watchers', + 'ActivityWatch Guard', + 'ActivityWatch Heal' + ) + + foreach ($taskName in $legacyTaskNames) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + } + + $runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' + foreach ($name in 'ActivityWatchAFK', 'ActivityWatchWindow', 'ActivityWatchBrowserCollector') { + Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue + } +} + +function Remove-ActivityWatchScheduledTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName + ) + + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + & cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null + + for ($attempt = 0; $attempt -lt 10; $attempt++) { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + return + } + + Start-Sleep -Milliseconds 300 + } +} + +function Set-ActivityWatchScheduledTaskAction { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$Execute, + [Parameter(Mandatory = $true)] + [string]$Arguments + ) + + $taskCommand = ('"{0}" {1}' -f $Execute, $Arguments) + & schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "schtasks.exe /Change завершился с ошибкой для $TaskName" + } +} + +function Get-ActivityWatchScheduledTaskByCommand { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [string]$CommandMatch + ) + + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($task) { + return $task + } + + if ([string]::IsNullOrWhiteSpace($CommandMatch)) { + return $null + } + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$CommandMatch*") { + return $candidate + } + } + } + + return $null +} + +function Remove-StaleActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath + ) + + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + $desiredTaskNames = @($TaskDefinitions | ForEach-Object { [string]$_.LaunchTaskName }) + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + $taskName = [string]$candidate.TaskName + if ($desiredTaskNames -contains $taskName) { + continue + } + + $usesCurrentLauncher = $false + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$launcherPath*") { + $usesCurrentLauncher = $true + break + } + } + + if ($usesCurrentLauncher) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + } + } +} + +function Register-ActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath + Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath + + foreach ($definition in $TaskDefinitions) { + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId + $principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + $existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath + + if ($existingTask) { + Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments + continue + } + + Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName + Register-ScheduledTask -TaskName $definition.LaunchTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null + } +} + +function Register-ActivityWatchRecoveryTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + Remove-ActivityWatchScheduledTask -TaskName $TaskName + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null +} + +function Set-ActivityWatchAcl { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot + ) + + foreach ($path in $InstallRoot, $StateRoot, $LogsRoot) { + New-ActivityWatchDirectory -Path $path + } + + & icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $InstallRoot" + } + + & icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $StateRoot" + } + + & icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $LogsRoot" + } +} + +function Start-ActivityWatchTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [string]$RecoveryTaskName = 'ActivityWatch Recovery' + ) + + foreach ($definition in $TaskDefinitions) { + Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue + } + + Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue +} + +Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries +; } + + `$body = @{ + client = `$ClientName + type = `$BucketType + hostname = `$script:Hostname + } | ConvertTo-Json -Compress + + try { + if (-not (Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body)) { + return + } + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + } + catch { + return + } + } + + `$script:KnownBuckets[`$BucketId] = `$true +} + +function Send-LogonMarkerIfNeeded { + param( + [pscustomobject]`$Config, + [int]`$SessionId + ) + + `$sessionEvents = if (`$Config.PSObject.Properties.Name -contains 'sessionEvents') { `$Config.sessionEvents } else { `$null } + `$logging = if (`$Config.PSObject.Properties.Name -contains 'logging') { `$Config.logging } else { `$null } + `$logonEnabled = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]`$sessionEvents.logonEnabled } else { `$false } + if (-not `$logonEnabled) { + return + } + + `$bucketPrefix = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]`$sessionEvents.bucketPrefix)) { + [string]`$sessionEvents.bucketPrefix + } + else { + 'aw-session-events' + } + + `$stateRoot = [string]`$Config.paths.stateRoot + `$markerRoots = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) { + `$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers')) + } + if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) { + `$markerRoots.Add((Join-Path `$stateRoot 'markers')) + } + + `$markerDir = `$null + foreach (`$candidate in `$markerRoots) { + try { + if (-not (Test-Path -LiteralPath `$candidate)) { + New-Item -Path `$candidate -ItemType Directory -Force | Out-Null + } + + `$probePath = Join-Path `$candidate 'write-test.tmp' + Set-Content -LiteralPath `$probePath -Value 'ok' -Encoding ASCII + Remove-Item -LiteralPath `$probePath -Force -ErrorAction SilentlyContinue + `$markerDir = `$candidate + break + } + catch { Write-Error Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Запустите этот скрипт из PowerShell с правами администратора.' + } +} + +function New-ActivityWatchDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Enable-ActivityWatchPrintTelemetry { + $policyPath = 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' + if (-not (Test-Path -LiteralPath $policyPath)) { + New-Item -Path $policyPath -Force | Out-Null + } + New-ItemProperty -Path $policyPath -Name 'ShowJobTitleInEventLogs' -Value 1 -PropertyType DWord -Force | Out-Null + + & wevtutil.exe sl 'Microsoft-Windows-PrintService/Operational' /e:true | Out-Null +} + + +function Get-ActivityWatchPackageUrl { + param( + [string]$Version = 'v0.13.2' + ) + + return "https://github.com/ActivityWatch/activitywatch/releases/download/$Version/activitywatch-$Version-windows-x86_64.zip" +} + +function Get-ActivityWatchArchive { + param( + [string]$PackageZipPath, + [string]$PackageUrl, + [string]$Version = 'v0.13.2', + [Parameter(Mandatory = $true)] + [string]$WorkingRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + + if ($PackageZipPath) { + $resolved = Resolve-Path -LiteralPath $PackageZipPath -ErrorAction Stop + return $resolved.Path + } + + if (-not $PackageUrl) { + $PackageUrl = Get-ActivityWatchPackageUrl -Version $Version + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $suffix = ([guid]::NewGuid().Guid.Substring(0, 8)) + $archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix) + Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath + return $archivePath +} + +function Get-ActivityWatchPackageRoot { + param( + [Parameter(Mandatory = $true)] + [string]$ExpandedRoot + ) + + $afkBinary = Get-ChildItem -Path $ExpandedRoot -Filter 'aw-watcher-afk.exe' -File -Recurse | + Select-Object -First 1 + + if (-not $afkBinary) { + throw "Не удалось найти aw-watcher-afk.exe в $ExpandedRoot." + } + + return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent) +} + +function Install-ActivityWatchPackage { + param( + [Parameter(Mandatory = $true)] + [string]$ArchivePath, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$WorkingRoot, + [Parameter(Mandatory = $true)] + [string]$BackupRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + New-ActivityWatchDirectory -Path $BackupRoot + + # Ensure nothing is holding locks inside InstallRoot during upgrade. + foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) { + try { + Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } + catch { + } + } + Start-Sleep -Seconds 2 + + $extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid) + if (Test-Path -LiteralPath $extractRoot) { + Remove-Item -LiteralPath $extractRoot -Recurse -Force + } + New-ActivityWatchDirectory -Path $extractRoot + + Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force + $packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot + + if (Test-Path -LiteralPath $InstallRoot) { + $existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue + if ($existingItems) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupPath = Join-Path $BackupRoot ("install-$stamp") + New-ActivityWatchDirectory -Path $backupPath + Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force + Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force + } + } + else { + New-ActivityWatchDirectory -Path $InstallRoot + } + + Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force + + return [pscustomobject]@{ + PackageRoot = $packageRoot + ExtractRoot = $extractRoot + BackupRoot = $BackupRoot + } +} + +function Get-ActivityWatchExecutableMap { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot + ) + + $map = [ordered]@{ + Afk = Join-Path $InstallRoot 'aw-watcher-afk\aw-watcher-afk.exe' + Window = Join-Path $InstallRoot 'aw-watcher-window\aw-watcher-window.exe' + } + + foreach ($entry in $map.GetEnumerator()) { + if (-not (Test-Path -LiteralPath $entry.Value)) { + throw "Не найден обязательный исполняемый файл ActivityWatch: $($entry.Value)" + } + } + + return [pscustomobject]$map +} + +function Normalize-ActivityWatchUsers { + param( + [string[]]$Users, + [string]$UserListPath, + [string]$Domain + ) + + $collected = New-Object System.Collections.Generic.List[string] + + if ($Users) { + foreach ($user in $Users) { + if (-not [string]::IsNullOrWhiteSpace($user)) { + $collected.Add($user.Trim()) + } + } + } + + if ($UserListPath) { + $resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop + $extension = [IO.Path]::GetExtension($resolved.Path) + if ($extension -ieq '.csv') { + $rows = Import-Csv -LiteralPath $resolved.Path + foreach ($row in $rows) { + foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') { + if ($row.PSObject.Properties.Name -contains $column) { + $value = [string]$row.$column + if (-not [string]::IsNullOrWhiteSpace($value)) { + $collected.Add($value.Trim()) + break + } + } + } + } + } + else { + Get-Content -LiteralPath $resolved.Path | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith('#')) { + $collected.Add($line) + } + } + } + } + + $normalized = $collected | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { + if ($Domain -and ($_ -notmatch '[\\@]')) { + '{0}\{1}' -f $Domain, $_ + } + else { + $_ + } + } | + Sort-Object -Unique + + if (-not $normalized -or $normalized.Count -eq 0) { + throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.' + } + + return @($normalized) +} + +function Get-ActivityWatchTaskNameToken { + param( + [Parameter(Mandatory = $true)] + [string]$UserId + ) + + $buffer = [Text.StringBuilder]::new() + foreach ($character in $UserId.ToCharArray()) { + if ([char]::IsLetterOrDigit($character)) { + [void]$buffer.Append($character) + } + else { + [void]$buffer.Append('_') + } + } + + return $buffer.ToString().Trim('_') +} + +function New-ActivityWatchUserTaskDefinitions { + param( + [Parameter(Mandatory = $true)] + [string[]]$Users + ) + + $result = foreach ($user in $Users) { + $token = Get-ActivityWatchTaskNameToken -UserId $user + [pscustomobject]@{ + UserId = $user + LaunchTaskName = "ActivityWatch Launch [$token]" + } + } + + return @($result) +} + +function Copy-ActivityWatchCollectorAssets { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScriptSource, + [string]$EmailCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$ExampleRulesSource, + [Parameter(Mandatory = $true)] + [string]$ExamplePolicySource, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [string]$CustomRulesSource, + [string]$CustomPolicySource + ) + + New-ActivityWatchDirectory -Path $StateRoot + + $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' + $endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1' + $fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1' + $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' + $emailCollectorTarget = Join-Path $StateRoot 'email-outbound-collector.ps1' + $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' + $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' + $examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json' + $policyTarget = Join-Path $StateRoot 'dlp-policy.json' + + Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force + Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force + Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force + if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) { + Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force + } + Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force + Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force + + if ($CustomRulesSource) { + $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force + } + else { + Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force + } + + if ($CustomPolicySource) { + $resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedPolicy.Path -Destination $policyTarget -Force + } + else { + Copy-Item -LiteralPath $examplePolicyTarget -Destination $policyTarget -Force + } + + return [pscustomobject]@{ + CollectorScript = $collectorTarget + EndpointCollectorScript = $endpointCollectorTarget + FileCollectorScript = $fileCollectorTarget + SessionCollectorScript = $sessionCollectorTarget + EmailCollectorScript = $emailCollectorTarget + ExampleRules = $exampleRulesTarget + ActiveRules = $rulesTarget + ExamplePolicy = $examplePolicyTarget + ActivePolicy = $policyTarget + } +} + +function New-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$ServerHost, + [Parameter(Mandatory = $true)] + [int]$ServerPort, + [Parameter(Mandatory = $true)] + [string]$ServerScheme, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot, + [Parameter(Mandatory = $true)] + [string]$CollectorScript, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScript, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScript, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScript, + [string]$EmailCollectorScript, + [Parameter(Mandatory = $true)] + [string]$RulesPath, + [Parameter(Mandatory = $true)] + [string]$PolicyPath, + [Parameter(Mandatory = $true)] + [int]$PollSeconds, + [Parameter(Mandatory = $true)] + [int]$PulseSeconds, + [Parameter(Mandatory = $true)] + [int]$RecoveryIntervalSeconds, + [bool]$AfkEnabled = $true, + [bool]$WindowEnabled = $true, + [bool]$FileOpsEnabled = $true, + [bool]$LocalAgentLogsEnabled = $true, + [bool]$IncidentCaptureEnabled = $true, + [bool]$IncidentScreenshotEnabled = $true, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled = $true, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [pscustomobject[]]$UserTasks, + [string]$PackageVersion = 'v0.13.2' + ) + + $effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' } + + return [pscustomobject]@{ + version = 1 + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + server = [pscustomobject]@{ + host = $ServerHost + port = $ServerPort + scheme = $ServerScheme + } + paths = [pscustomobject]@{ + installRoot = $InstallRoot + stateRoot = $StateRoot + logsRoot = $LogsRoot + collectorScript = $CollectorScript + endpointCollectorScript = $EndpointCollectorScript + emailCollectorScript = $EmailCollectorScript + fileCollectorScript = $FileCollectorScript + sessionCollectorScript = $SessionCollectorScript + rulesPath = $RulesPath + policyPath = $PolicyPath + launchScript = $LaunchScriptPath + recoveryScript = $RecoveryScriptPath + } + collector = [pscustomobject]@{ + pollSeconds = $PollSeconds + pulseSeconds = $PulseSeconds + } + collectors = [pscustomobject]@{ + afkEnabled = $AfkEnabled + windowEnabled = $WindowEnabled + fileOpsEnabled = $FileOpsEnabled + emailEnabled = ($null -ne $EmailCollectorScript -and $EmailCollectorScript -ne '') + } + logging = [pscustomobject]@{ + localAgentLogsEnabled = $LocalAgentLogsEnabled + } + incidentCapture = [pscustomobject]@{ + enabled = $IncidentCaptureEnabled + screenshotEnabled = $IncidentScreenshotEnabled + artifactsRoot = $effectiveIncidentArtifactsRoot + } + sessionEvents = [pscustomobject]@{ + logonEnabled = $LogonMarkerEnabled + bucketPrefix = 'aw-session-events' + } + recovery = [pscustomobject]@{ + intervalSeconds = $RecoveryIntervalSeconds + taskName = 'ActivityWatch Recovery' + } + dlp = [pscustomobject]@{ + incidentBucketPrefix = 'aw-dlp-incidents' + enabled = $true + } + package = [pscustomobject]@{ + version = $PackageVersion + } + userTasks = @($UserTasks) + } +} + +function Write-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $json = $Config | ConvertTo-Json -Depth 8 + Set-Content -LiteralPath $Path -Value $json -Encoding UTF8 +} + +function Read-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация развёртывания не найдена: $Path" + } + + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Write-ActivityWatchLaunchScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Stop' + +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http +`$script:MaxCollectorPowerShellProcesses = 24 + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Test-ProcessInSession { + param( + [string]`$Name, + [int]`$SessionId + ) + + return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1) +} + +function Test-CollectorRunning { + param( + [string]`$ScriptPath, + [int]`$SessionId + ) + + `$escapedCollector = [Regex]::Escape(`$ScriptPath) + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.SessionId -eq `$SessionId -and + `$_.CommandLine -match `$escapedCollector + } + + return [bool](`$processes | Select-Object -First 1) +} + +function Get-CollectorPowerShellProcessCount { + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.CommandLine -match 'AWatch-rus' -and + `$_.CommandLine -match '\.ps1' + } + + return @(`$processes).Count +} + +function New-LaunchLock { + param([string]`$StateRoot, [int]`$SessionId) + + `$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId) + 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 + sessionId = `$SessionId + createdAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath `$lockPath -Value `$payload -Encoding UTF8 + return `$lockPath +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = `$true)][string]`$Uri, + [Parameter(Mandatory = `$true)][string]`$Json + ) + + `$httpClient = New-Object System.Net.Http.HttpClient + try { + `$content = New-Object System.Net.Http.StringContent(`$Json, [System.Text.Encoding]::UTF8, 'application/json') + `$response = `$httpClient.PostAsync(`$Uri, `$content).Result + if (-not `$response.IsSuccessStatusCode) { + return `$false + } + return `$true + } + catch { + return `$false + } + finally { + `$httpClient.Dispose() + } +} + +function Ensure-Bucket { + param( + [string]`$BucketId, + [string]`$ClientName, + [string]`$BucketType + ) + + if (`$script:KnownBuckets.ContainsKey(`$BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + `$script:KnownBuckets[`$BucketId] = `$true + return + } + catch { + } + + `$body = @{ + client = `$ClientName + type = `$BucketType + hostname = `$script:Hostname + } | ConvertTo-Json -Compress + + try { + if (-not (Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body)) { + return + } + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + } + catch { + return + } + } + + `$script:KnownBuckets[`$BucketId] = `$true +} + +function Send-LogonMarkerIfNeeded { + param( + [pscustomobject]`$Config, + [int]`$SessionId + ) + + `$sessionEvents = if (`$Config.PSObject.Properties.Name -contains 'sessionEvents') { `$Config.sessionEvents } else { `$null } + `$logging = if (`$Config.PSObject.Properties.Name -contains 'logging') { `$Config.logging } else { `$null } + `$logonEnabled = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]`$sessionEvents.logonEnabled } else { `$false } + if (-not `$logonEnabled) { + return + } + + `$bucketPrefix = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]`$sessionEvents.bucketPrefix)) { + [string]`$sessionEvents.bucketPrefix + } + else { + 'aw-session-events' + } + + `$stateRoot = [string]`$Config.paths.stateRoot + `$markerRoots = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) { + `$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers')) + } + if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) { + `$markerRoots.Add((Join-Path `$stateRoot 'markers')) + } + + `$markerDir = `$null + foreach (`$candidate in `$markerRoots) { + try { + if (-not (Test-Path -LiteralPath `$candidate)) { + New-Item -Path `$candidate -ItemType Directory -Force | Out-Null + } + + `$probePath = Join-Path `$candidate 'write-test.tmp' + Set-Content -LiteralPath `$probePath -Value 'ok' -Encoding ASCII + Remove-Item -LiteralPath `$probePath -Force -ErrorAction SilentlyContinue + `$markerDir = `$candidate + break + } + catch { + } + } + + if (-not `$markerDir) { + return + } + + `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId) + if (Test-Path -LiteralPath `$markerFile) { + return + } + + Set-Content -LiteralPath `$markerFile -Value ((Get-Date).ToUniversalTime().ToString('o')) -Encoding UTF8 + + `$bucketId = ('{0}_{1}' -f `$bucketPrefix, `$script:Hostname) + Ensure-Bucket -BucketId `$bucketId -ClientName 'aw-session-events' -BucketType 'aw.session.event' + + `$payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + eventType = 'logon' + username = `$env:USERNAME + userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)" + sessionId = `$SessionId + hostname = `$script:Hostname + source = 'launch-watchers-awatch-rus' + } + } | ConvertTo-Json -Depth 5 -Compress + + try { + Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$bucketId/heartbeat?pulsetime=1" -Json `$payload + } + catch { + Remove-Item -LiteralPath `$markerFile -Force -ErrorAction SilentlyContinue + throw + } +} + +function Start-CollectorScriptIfNeeded { + param( + [string]`$ScriptPath, + [string]`$ConfigPath, + [string]`$PowerShellExe, + [int]`$SessionId + ) + + if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + return + } + + if (-not (Test-Path -LiteralPath `$ScriptPath)) { + return + } + + if (Test-CollectorRunning -ScriptPath `$ScriptPath -SessionId `$SessionId) { + return + } + + if ((Get-CollectorPowerShellProcessCount) -ge `$script:MaxCollectorPowerShellProcesses) { + return + } + + `$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null } + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass') + if (`$staParam) { `$argumentList += `$staParam } + `$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden +} + +`$config = Get-DeploymentConfig -Path `$ConfigPath +`$sessionId = (Get-Process -Id `$PID).SessionId +`$installRoot = [string]`$config.paths.installRoot +`$stateRoot = [string]`$config.paths.stateRoot +`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port +`$script:Hostname = `$env:COMPUTERNAME +`$script:KnownBuckets = @{} +`$collectorScript = [string]`$config.paths.collectorScript +`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' } +`$fileCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]`$config.paths.fileCollectorScript } else { Join-Path `$stateRoot 'file-operations-collector.ps1' } +`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' } +`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe' +`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe' +`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port) +`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +`$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 } +`$fileOpsEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]`$config.collectors.fileOpsEnabled } else { `$true } +`$emailEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'emailEnabled') { [bool]`$config.collectors.emailEnabled } else { `$false } +`$emailCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'emailCollectorScript') { [string]`$config.paths.emailCollectorScript } else { Join-Path `$stateRoot 'email-outbound-collector.ps1' } +`$launchLockPath = New-LaunchLock -StateRoot `$stateRoot -SessionId `$sessionId +if (-not `$launchLockPath) { + return +} + +try { + if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) { + throw "Не найден aw-watcher-afk.exe: `$afkExe" + } + + if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) { + throw "Не найден aw-watcher-window.exe: `$windowExe" + } + + if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) { + Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + if (`$windowEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) { + Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + try { + Send-LogonMarkerIfNeeded -Config `$config -SessionId `$sessionId + } + catch { + } + Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$fileOpsEnabled) { + Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } + Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$emailEnabled -and (Test-Path -LiteralPath `$emailCollectorScript)) { + Start-CollectorScriptIfNeeded -ScriptPath `$emailCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } +} +finally { + if (`$launchLockPath -and (Test-Path -LiteralPath `$launchLockPath)) { + Remove-Item -LiteralPath `$launchLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Write-ActivityWatchRecoveryScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Continue' + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Get-RecoveryConfigPaths { + param([string]`$PrimaryConfigPath) + + `$paths = New-Object System.Collections.Generic.List[string] + if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) { + `$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path) + } + + `$searchRoot = `$env:ProgramData + if (`$PrimaryConfigPath) { + `$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent + `$candidateRoot = Split-Path -Path `$stateRoot -Parent + if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) { + `$searchRoot = `$candidateRoot + } + } + + if (Test-Path -LiteralPath `$searchRoot) { + Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { `$_.Name -like 'ActivityWatch*' } | + ForEach-Object { + `$candidate = Join-Path `$_.FullName 'deployment-config.json' + if (Test-Path -LiteralPath `$candidate) { + `$paths.Add(`$candidate) + } + } + } + + return @(`$paths | Sort-Object -Unique) +} + +function Get-RecoveryTaskNames { + param([string[]]`$ConfigPaths) + + `$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach (`$candidatePath in @(`$ConfigPaths)) { + try { + `$config = Get-DeploymentConfig -Path `$candidatePath + foreach (`$task in @(`$config.userTasks)) { + `$taskName = [string]`$task.launchTaskName + if (-not [string]::IsNullOrWhiteSpace(`$taskName)) { + [void]`$taskNames.Add(`$taskName) + } + } + } + catch { + } + } + + return @(`$taskNames) +} + +function New-RecoveryLock { + param([string]`$PrimaryConfigPath) + + `$stateRoot = if (`$PrimaryConfigPath) { Split-Path -Path `$PrimaryConfigPath -Parent } else { Join-Path `$env:ProgramData 'AWatch-rus' } + if (-not (Test-Path -LiteralPath `$stateRoot)) { + New-Item -Path `$stateRoot -ItemType Directory -Force | Out-Null + } + + `$lockPath = Join-Path `$stateRoot 'recovery-loop.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 Start-TaskIfNotRunning { + param([string]`$TaskName) + if ([string]::IsNullOrWhiteSpace(`$TaskName)) { + return + } + + try { + `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + if (-not `$task) { + return + } + if ([string]`$task.State -eq 'Running') { + return + } + Start-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + } + catch { + } +} + +`$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath +if (-not `$recoveryLockPath) { + return +} + +try { + while (`$true) { + `$sleepSeconds = 180 + try { + `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath + foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { + Start-TaskIfNotRunning -TaskName `$taskName + } + + `$config = Get-DeploymentConfig -Path `$ConfigPath + if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { + `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) + } + } + catch { + } + + Start-Sleep -Seconds `$sleepSeconds + } +} +finally { + if (`$recoveryLockPath -and (Test-Path -LiteralPath `$recoveryLockPath)) { + Remove-Item -LiteralPath `$recoveryLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Get-ActivityWatchHiddenLauncherPath { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath + ) + + $directory = Split-Path -Path $ScriptPath -Parent + $baseName = [IO.Path]::GetFileNameWithoutExtension($ScriptPath) + return Join-Path $directory ("{0}-hidden.vbs" -f $baseName) +} + +function Write-ActivityWatchHiddenPowerShellWrapper { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $escapedPowerShellExe = $powershellExe.Replace('"', '""') + $escapedScriptPath = $ScriptPath.Replace('"', '""') + $escapedConfigPath = $ConfigPath.Replace('"', '""') + + $content = @" +Set shell = CreateObject("WScript.Shell") +shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding ASCII +} + +function Remove-LegacyActivityWatchEntries { + $legacyTaskNames = @( + 'ActivityWatch Watchers', + 'ActivityWatch Guard', + 'ActivityWatch Heal' + ) + + foreach ($taskName in $legacyTaskNames) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + } + + $runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' + foreach ($name in 'ActivityWatchAFK', 'ActivityWatchWindow', 'ActivityWatchBrowserCollector') { + Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue + } +} + +function Remove-ActivityWatchScheduledTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName + ) + + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + & cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null + + for ($attempt = 0; $attempt -lt 10; $attempt++) { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + return + } + + Start-Sleep -Milliseconds 300 + } +} + +function Set-ActivityWatchScheduledTaskAction { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$Execute, + [Parameter(Mandatory = $true)] + [string]$Arguments + ) + + $taskCommand = ('"{0}" {1}' -f $Execute, $Arguments) + & schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "schtasks.exe /Change завершился с ошибкой для $TaskName" + } +} + +function Get-ActivityWatchScheduledTaskByCommand { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [string]$CommandMatch + ) + + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($task) { + return $task + } + + if ([string]::IsNullOrWhiteSpace($CommandMatch)) { + return $null + } + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$CommandMatch*") { + return $candidate + } + } + } + + return $null +} + +function Remove-StaleActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath + ) + + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + $desiredTaskNames = @($TaskDefinitions | ForEach-Object { [string]$_.LaunchTaskName }) + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + $taskName = [string]$candidate.TaskName + if ($desiredTaskNames -contains $taskName) { + continue + } + + $usesCurrentLauncher = $false + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$launcherPath*") { + $usesCurrentLauncher = $true + break + } + } + + if ($usesCurrentLauncher) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + } + } +} + +function Register-ActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath + Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath + + foreach ($definition in $TaskDefinitions) { + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId + $principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + $existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath + + if ($existingTask) { + Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments + continue + } + + Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName + Register-ScheduledTask -TaskName $definition.LaunchTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null + } +} + +function Register-ActivityWatchRecoveryTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + Remove-ActivityWatchScheduledTask -TaskName $TaskName + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null +} + +function Set-ActivityWatchAcl { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot + ) + + foreach ($path in $InstallRoot, $StateRoot, $LogsRoot) { + New-ActivityWatchDirectory -Path $path + } + + & icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $InstallRoot" + } + + & icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $StateRoot" + } + + & icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $LogsRoot" + } +} + +function Start-ActivityWatchTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [string]$RecoveryTaskName = 'ActivityWatch Recovery' + ) + + foreach ($definition in $TaskDefinitions) { + Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue + } + + Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue +} + +Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries +; } + } + + if (-not `$markerDir) { + return + } + + `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId) + if (Test-Path -LiteralPath `$markerFile) { + return + } + + Set-Content -LiteralPath `$markerFile -Value ((Get-Date).ToUniversalTime().ToString('o')) -Encoding UTF8 + + `$bucketId = ('{0}_{1}' -f `$bucketPrefix, `$script:Hostname) + Ensure-Bucket -BucketId `$bucketId -ClientName 'aw-session-events' -BucketType 'aw.session.event' + + `$payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + eventType = 'logon' + username = `$env:USERNAME + userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)" + sessionId = `$SessionId + hostname = `$script:Hostname + source = 'launch-watchers-awatch-rus' + } + } | ConvertTo-Json -Depth 5 -Compress + + try { + Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$bucketId/heartbeat?pulsetime=1" -Json `$payload + } + catch { + Remove-Item -LiteralPath `$markerFile -Force -ErrorAction SilentlyContinue + throw + } +} + +function Start-CollectorScriptIfNeeded { + param( + [string]`$ScriptPath, + [string]`$ConfigPath, + [string]`$PowerShellExe, + [int]`$SessionId + ) + + if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + return + } + + if (-not (Test-Path -LiteralPath `$ScriptPath)) { + return + } + + if (Test-CollectorRunning -ScriptPath `$ScriptPath -SessionId `$SessionId) { + return + } + + if ((Get-CollectorPowerShellProcessCount) -ge `$script:MaxCollectorPowerShellProcesses) { + return + } + + `$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null } + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass') + if (`$staParam) { `$argumentList += `$staParam } + `$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden +} + +`$config = Get-DeploymentConfig -Path `$ConfigPath +`$sessionId = (Get-Process -Id `$PID).SessionId +`$installRoot = [string]`$config.paths.installRoot +`$stateRoot = [string]`$config.paths.stateRoot +`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port +`$script:Hostname = `$env:COMPUTERNAME +`$script:KnownBuckets = @{} +`$collectorScript = [string]`$config.paths.collectorScript +`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' } +`$fileCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]`$config.paths.fileCollectorScript } else { Join-Path `$stateRoot 'file-operations-collector.ps1' } +`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' } +`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe' +`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe' +`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port) +`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +`$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 } +`$fileOpsEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]`$config.collectors.fileOpsEnabled } else { `$true } +`$emailEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'emailEnabled') { [bool]`$config.collectors.emailEnabled } else { `$false } +`$emailCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'emailCollectorScript') { [string]`$config.paths.emailCollectorScript } else { Join-Path `$stateRoot 'email-outbound-collector.ps1' } +`$launchLockPath = New-LaunchLock -StateRoot `$stateRoot -SessionId `$sessionId +if (-not `$launchLockPath) { + return +} + +try { + if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) { + throw "Не найден aw-watcher-afk.exe: `$afkExe" + } + + if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) { + throw "Не найден aw-watcher-window.exe: `$windowExe" + } + + if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) { + Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + if (`$windowEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) { + Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + try { + Send-LogonMarkerIfNeeded -Config `$config -SessionId `$sessionId + } + catch { Write-Error Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Запустите этот скрипт из PowerShell с правами администратора.' + } +} + +function New-ActivityWatchDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Enable-ActivityWatchPrintTelemetry { + $policyPath = 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' + if (-not (Test-Path -LiteralPath $policyPath)) { + New-Item -Path $policyPath -Force | Out-Null + } + New-ItemProperty -Path $policyPath -Name 'ShowJobTitleInEventLogs' -Value 1 -PropertyType DWord -Force | Out-Null + + & wevtutil.exe sl 'Microsoft-Windows-PrintService/Operational' /e:true | Out-Null +} + + +function Get-ActivityWatchPackageUrl { + param( + [string]$Version = 'v0.13.2' + ) + + return "https://github.com/ActivityWatch/activitywatch/releases/download/$Version/activitywatch-$Version-windows-x86_64.zip" +} + +function Get-ActivityWatchArchive { + param( + [string]$PackageZipPath, + [string]$PackageUrl, + [string]$Version = 'v0.13.2', + [Parameter(Mandatory = $true)] + [string]$WorkingRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + + if ($PackageZipPath) { + $resolved = Resolve-Path -LiteralPath $PackageZipPath -ErrorAction Stop + return $resolved.Path + } + + if (-not $PackageUrl) { + $PackageUrl = Get-ActivityWatchPackageUrl -Version $Version + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $suffix = ([guid]::NewGuid().Guid.Substring(0, 8)) + $archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix) + Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath + return $archivePath +} + +function Get-ActivityWatchPackageRoot { + param( + [Parameter(Mandatory = $true)] + [string]$ExpandedRoot + ) + + $afkBinary = Get-ChildItem -Path $ExpandedRoot -Filter 'aw-watcher-afk.exe' -File -Recurse | + Select-Object -First 1 + + if (-not $afkBinary) { + throw "Не удалось найти aw-watcher-afk.exe в $ExpandedRoot." + } + + return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent) +} + +function Install-ActivityWatchPackage { + param( + [Parameter(Mandatory = $true)] + [string]$ArchivePath, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$WorkingRoot, + [Parameter(Mandatory = $true)] + [string]$BackupRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + New-ActivityWatchDirectory -Path $BackupRoot + + # Ensure nothing is holding locks inside InstallRoot during upgrade. + foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) { + try { + Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } + catch { + } + } + Start-Sleep -Seconds 2 + + $extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid) + if (Test-Path -LiteralPath $extractRoot) { + Remove-Item -LiteralPath $extractRoot -Recurse -Force + } + New-ActivityWatchDirectory -Path $extractRoot + + Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force + $packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot + + if (Test-Path -LiteralPath $InstallRoot) { + $existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue + if ($existingItems) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupPath = Join-Path $BackupRoot ("install-$stamp") + New-ActivityWatchDirectory -Path $backupPath + Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force + Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force + } + } + else { + New-ActivityWatchDirectory -Path $InstallRoot + } + + Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force + + return [pscustomobject]@{ + PackageRoot = $packageRoot + ExtractRoot = $extractRoot + BackupRoot = $BackupRoot + } +} + +function Get-ActivityWatchExecutableMap { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot + ) + + $map = [ordered]@{ + Afk = Join-Path $InstallRoot 'aw-watcher-afk\aw-watcher-afk.exe' + Window = Join-Path $InstallRoot 'aw-watcher-window\aw-watcher-window.exe' + } + + foreach ($entry in $map.GetEnumerator()) { + if (-not (Test-Path -LiteralPath $entry.Value)) { + throw "Не найден обязательный исполняемый файл ActivityWatch: $($entry.Value)" + } + } + + return [pscustomobject]$map +} + +function Normalize-ActivityWatchUsers { + param( + [string[]]$Users, + [string]$UserListPath, + [string]$Domain + ) + + $collected = New-Object System.Collections.Generic.List[string] + + if ($Users) { + foreach ($user in $Users) { + if (-not [string]::IsNullOrWhiteSpace($user)) { + $collected.Add($user.Trim()) + } + } + } + + if ($UserListPath) { + $resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop + $extension = [IO.Path]::GetExtension($resolved.Path) + if ($extension -ieq '.csv') { + $rows = Import-Csv -LiteralPath $resolved.Path + foreach ($row in $rows) { + foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') { + if ($row.PSObject.Properties.Name -contains $column) { + $value = [string]$row.$column + if (-not [string]::IsNullOrWhiteSpace($value)) { + $collected.Add($value.Trim()) + break + } + } + } + } + } + else { + Get-Content -LiteralPath $resolved.Path | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith('#')) { + $collected.Add($line) + } + } + } + } + + $normalized = $collected | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { + if ($Domain -and ($_ -notmatch '[\\@]')) { + '{0}\{1}' -f $Domain, $_ + } + else { + $_ + } + } | + Sort-Object -Unique + + if (-not $normalized -or $normalized.Count -eq 0) { + throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.' + } + + return @($normalized) +} + +function Get-ActivityWatchTaskNameToken { + param( + [Parameter(Mandatory = $true)] + [string]$UserId + ) + + $buffer = [Text.StringBuilder]::new() + foreach ($character in $UserId.ToCharArray()) { + if ([char]::IsLetterOrDigit($character)) { + [void]$buffer.Append($character) + } + else { + [void]$buffer.Append('_') + } + } + + return $buffer.ToString().Trim('_') +} + +function New-ActivityWatchUserTaskDefinitions { + param( + [Parameter(Mandatory = $true)] + [string[]]$Users + ) + + $result = foreach ($user in $Users) { + $token = Get-ActivityWatchTaskNameToken -UserId $user + [pscustomobject]@{ + UserId = $user + LaunchTaskName = "ActivityWatch Launch [$token]" + } + } + + return @($result) +} + +function Copy-ActivityWatchCollectorAssets { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScriptSource, + [string]$EmailCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$ExampleRulesSource, + [Parameter(Mandatory = $true)] + [string]$ExamplePolicySource, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [string]$CustomRulesSource, + [string]$CustomPolicySource + ) + + New-ActivityWatchDirectory -Path $StateRoot + + $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' + $endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1' + $fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1' + $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' + $emailCollectorTarget = Join-Path $StateRoot 'email-outbound-collector.ps1' + $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' + $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' + $examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json' + $policyTarget = Join-Path $StateRoot 'dlp-policy.json' + + Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force + Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force + Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force + if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) { + Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force + } + Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force + Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force + + if ($CustomRulesSource) { + $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force + } + else { + Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force + } + + if ($CustomPolicySource) { + $resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedPolicy.Path -Destination $policyTarget -Force + } + else { + Copy-Item -LiteralPath $examplePolicyTarget -Destination $policyTarget -Force + } + + return [pscustomobject]@{ + CollectorScript = $collectorTarget + EndpointCollectorScript = $endpointCollectorTarget + FileCollectorScript = $fileCollectorTarget + SessionCollectorScript = $sessionCollectorTarget + EmailCollectorScript = $emailCollectorTarget + ExampleRules = $exampleRulesTarget + ActiveRules = $rulesTarget + ExamplePolicy = $examplePolicyTarget + ActivePolicy = $policyTarget + } +} + +function New-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$ServerHost, + [Parameter(Mandatory = $true)] + [int]$ServerPort, + [Parameter(Mandatory = $true)] + [string]$ServerScheme, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot, + [Parameter(Mandatory = $true)] + [string]$CollectorScript, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScript, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScript, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScript, + [string]$EmailCollectorScript, + [Parameter(Mandatory = $true)] + [string]$RulesPath, + [Parameter(Mandatory = $true)] + [string]$PolicyPath, + [Parameter(Mandatory = $true)] + [int]$PollSeconds, + [Parameter(Mandatory = $true)] + [int]$PulseSeconds, + [Parameter(Mandatory = $true)] + [int]$RecoveryIntervalSeconds, + [bool]$AfkEnabled = $true, + [bool]$WindowEnabled = $true, + [bool]$FileOpsEnabled = $true, + [bool]$LocalAgentLogsEnabled = $true, + [bool]$IncidentCaptureEnabled = $true, + [bool]$IncidentScreenshotEnabled = $true, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled = $true, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [pscustomobject[]]$UserTasks, + [string]$PackageVersion = 'v0.13.2' + ) + + $effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' } + + return [pscustomobject]@{ + version = 1 + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + server = [pscustomobject]@{ + host = $ServerHost + port = $ServerPort + scheme = $ServerScheme + } + paths = [pscustomobject]@{ + installRoot = $InstallRoot + stateRoot = $StateRoot + logsRoot = $LogsRoot + collectorScript = $CollectorScript + endpointCollectorScript = $EndpointCollectorScript + emailCollectorScript = $EmailCollectorScript + fileCollectorScript = $FileCollectorScript + sessionCollectorScript = $SessionCollectorScript + rulesPath = $RulesPath + policyPath = $PolicyPath + launchScript = $LaunchScriptPath + recoveryScript = $RecoveryScriptPath + } + collector = [pscustomobject]@{ + pollSeconds = $PollSeconds + pulseSeconds = $PulseSeconds + } + collectors = [pscustomobject]@{ + afkEnabled = $AfkEnabled + windowEnabled = $WindowEnabled + fileOpsEnabled = $FileOpsEnabled + emailEnabled = ($null -ne $EmailCollectorScript -and $EmailCollectorScript -ne '') + } + logging = [pscustomobject]@{ + localAgentLogsEnabled = $LocalAgentLogsEnabled + } + incidentCapture = [pscustomobject]@{ + enabled = $IncidentCaptureEnabled + screenshotEnabled = $IncidentScreenshotEnabled + artifactsRoot = $effectiveIncidentArtifactsRoot + } + sessionEvents = [pscustomobject]@{ + logonEnabled = $LogonMarkerEnabled + bucketPrefix = 'aw-session-events' + } + recovery = [pscustomobject]@{ + intervalSeconds = $RecoveryIntervalSeconds + taskName = 'ActivityWatch Recovery' + } + dlp = [pscustomobject]@{ + incidentBucketPrefix = 'aw-dlp-incidents' + enabled = $true + } + package = [pscustomobject]@{ + version = $PackageVersion + } + userTasks = @($UserTasks) + } +} + +function Write-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $json = $Config | ConvertTo-Json -Depth 8 + Set-Content -LiteralPath $Path -Value $json -Encoding UTF8 +} + +function Read-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация развёртывания не найдена: $Path" + } + + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Write-ActivityWatchLaunchScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Stop' + +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http +`$script:MaxCollectorPowerShellProcesses = 24 + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Test-ProcessInSession { + param( + [string]`$Name, + [int]`$SessionId + ) + + return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1) +} + +function Test-CollectorRunning { + param( + [string]`$ScriptPath, + [int]`$SessionId + ) + + `$escapedCollector = [Regex]::Escape(`$ScriptPath) + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.SessionId -eq `$SessionId -and + `$_.CommandLine -match `$escapedCollector + } + + return [bool](`$processes | Select-Object -First 1) +} + +function Get-CollectorPowerShellProcessCount { + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.CommandLine -match 'AWatch-rus' -and + `$_.CommandLine -match '\.ps1' + } + + return @(`$processes).Count +} + +function New-LaunchLock { + param([string]`$StateRoot, [int]`$SessionId) + + `$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId) + 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 + sessionId = `$SessionId + createdAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath `$lockPath -Value `$payload -Encoding UTF8 + return `$lockPath +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = `$true)][string]`$Uri, + [Parameter(Mandatory = `$true)][string]`$Json + ) + + `$httpClient = New-Object System.Net.Http.HttpClient + try { + `$content = New-Object System.Net.Http.StringContent(`$Json, [System.Text.Encoding]::UTF8, 'application/json') + `$response = `$httpClient.PostAsync(`$Uri, `$content).Result + if (-not `$response.IsSuccessStatusCode) { + return `$false + } + return `$true + } + catch { + return `$false + } + finally { + `$httpClient.Dispose() + } +} + +function Ensure-Bucket { + param( + [string]`$BucketId, + [string]`$ClientName, + [string]`$BucketType + ) + + if (`$script:KnownBuckets.ContainsKey(`$BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + `$script:KnownBuckets[`$BucketId] = `$true + return + } + catch { + } + + `$body = @{ + client = `$ClientName + type = `$BucketType + hostname = `$script:Hostname + } | ConvertTo-Json -Compress + + try { + if (-not (Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body)) { + return + } + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + } + catch { + return + } + } + + `$script:KnownBuckets[`$BucketId] = `$true +} + +function Send-LogonMarkerIfNeeded { + param( + [pscustomobject]`$Config, + [int]`$SessionId + ) + + `$sessionEvents = if (`$Config.PSObject.Properties.Name -contains 'sessionEvents') { `$Config.sessionEvents } else { `$null } + `$logging = if (`$Config.PSObject.Properties.Name -contains 'logging') { `$Config.logging } else { `$null } + `$logonEnabled = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]`$sessionEvents.logonEnabled } else { `$false } + if (-not `$logonEnabled) { + return + } + + `$bucketPrefix = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]`$sessionEvents.bucketPrefix)) { + [string]`$sessionEvents.bucketPrefix + } + else { + 'aw-session-events' + } + + `$stateRoot = [string]`$Config.paths.stateRoot + `$markerRoots = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) { + `$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers')) + } + if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) { + `$markerRoots.Add((Join-Path `$stateRoot 'markers')) + } + + `$markerDir = `$null + foreach (`$candidate in `$markerRoots) { + try { + if (-not (Test-Path -LiteralPath `$candidate)) { + New-Item -Path `$candidate -ItemType Directory -Force | Out-Null + } + + `$probePath = Join-Path `$candidate 'write-test.tmp' + Set-Content -LiteralPath `$probePath -Value 'ok' -Encoding ASCII + Remove-Item -LiteralPath `$probePath -Force -ErrorAction SilentlyContinue + `$markerDir = `$candidate + break + } + catch { + } + } + + if (-not `$markerDir) { + return + } + + `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId) + if (Test-Path -LiteralPath `$markerFile) { + return + } + + Set-Content -LiteralPath `$markerFile -Value ((Get-Date).ToUniversalTime().ToString('o')) -Encoding UTF8 + + `$bucketId = ('{0}_{1}' -f `$bucketPrefix, `$script:Hostname) + Ensure-Bucket -BucketId `$bucketId -ClientName 'aw-session-events' -BucketType 'aw.session.event' + + `$payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + eventType = 'logon' + username = `$env:USERNAME + userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)" + sessionId = `$SessionId + hostname = `$script:Hostname + source = 'launch-watchers-awatch-rus' + } + } | ConvertTo-Json -Depth 5 -Compress + + try { + Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$bucketId/heartbeat?pulsetime=1" -Json `$payload + } + catch { + Remove-Item -LiteralPath `$markerFile -Force -ErrorAction SilentlyContinue + throw + } +} + +function Start-CollectorScriptIfNeeded { + param( + [string]`$ScriptPath, + [string]`$ConfigPath, + [string]`$PowerShellExe, + [int]`$SessionId + ) + + if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + return + } + + if (-not (Test-Path -LiteralPath `$ScriptPath)) { + return + } + + if (Test-CollectorRunning -ScriptPath `$ScriptPath -SessionId `$SessionId) { + return + } + + if ((Get-CollectorPowerShellProcessCount) -ge `$script:MaxCollectorPowerShellProcesses) { + return + } + + `$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null } + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass') + if (`$staParam) { `$argumentList += `$staParam } + `$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden +} + +`$config = Get-DeploymentConfig -Path `$ConfigPath +`$sessionId = (Get-Process -Id `$PID).SessionId +`$installRoot = [string]`$config.paths.installRoot +`$stateRoot = [string]`$config.paths.stateRoot +`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port +`$script:Hostname = `$env:COMPUTERNAME +`$script:KnownBuckets = @{} +`$collectorScript = [string]`$config.paths.collectorScript +`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' } +`$fileCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]`$config.paths.fileCollectorScript } else { Join-Path `$stateRoot 'file-operations-collector.ps1' } +`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' } +`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe' +`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe' +`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port) +`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +`$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 } +`$fileOpsEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]`$config.collectors.fileOpsEnabled } else { `$true } +`$emailEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'emailEnabled') { [bool]`$config.collectors.emailEnabled } else { `$false } +`$emailCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'emailCollectorScript') { [string]`$config.paths.emailCollectorScript } else { Join-Path `$stateRoot 'email-outbound-collector.ps1' } +`$launchLockPath = New-LaunchLock -StateRoot `$stateRoot -SessionId `$sessionId +if (-not `$launchLockPath) { + return +} + +try { + if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) { + throw "Не найден aw-watcher-afk.exe: `$afkExe" + } + + if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) { + throw "Не найден aw-watcher-window.exe: `$windowExe" + } + + if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) { + Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + if (`$windowEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) { + Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + try { + Send-LogonMarkerIfNeeded -Config `$config -SessionId `$sessionId + } + catch { + } + Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$fileOpsEnabled) { + Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } + Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$emailEnabled -and (Test-Path -LiteralPath `$emailCollectorScript)) { + Start-CollectorScriptIfNeeded -ScriptPath `$emailCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } +} +finally { + if (`$launchLockPath -and (Test-Path -LiteralPath `$launchLockPath)) { + Remove-Item -LiteralPath `$launchLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Write-ActivityWatchRecoveryScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Continue' + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Get-RecoveryConfigPaths { + param([string]`$PrimaryConfigPath) + + `$paths = New-Object System.Collections.Generic.List[string] + if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) { + `$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path) + } + + `$searchRoot = `$env:ProgramData + if (`$PrimaryConfigPath) { + `$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent + `$candidateRoot = Split-Path -Path `$stateRoot -Parent + if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) { + `$searchRoot = `$candidateRoot + } + } + + if (Test-Path -LiteralPath `$searchRoot) { + Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { `$_.Name -like 'ActivityWatch*' } | + ForEach-Object { + `$candidate = Join-Path `$_.FullName 'deployment-config.json' + if (Test-Path -LiteralPath `$candidate) { + `$paths.Add(`$candidate) + } + } + } + + return @(`$paths | Sort-Object -Unique) +} + +function Get-RecoveryTaskNames { + param([string[]]`$ConfigPaths) + + `$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach (`$candidatePath in @(`$ConfigPaths)) { + try { + `$config = Get-DeploymentConfig -Path `$candidatePath + foreach (`$task in @(`$config.userTasks)) { + `$taskName = [string]`$task.launchTaskName + if (-not [string]::IsNullOrWhiteSpace(`$taskName)) { + [void]`$taskNames.Add(`$taskName) + } + } + } + catch { + } + } + + return @(`$taskNames) +} + +function New-RecoveryLock { + param([string]`$PrimaryConfigPath) + + `$stateRoot = if (`$PrimaryConfigPath) { Split-Path -Path `$PrimaryConfigPath -Parent } else { Join-Path `$env:ProgramData 'AWatch-rus' } + if (-not (Test-Path -LiteralPath `$stateRoot)) { + New-Item -Path `$stateRoot -ItemType Directory -Force | Out-Null + } + + `$lockPath = Join-Path `$stateRoot 'recovery-loop.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 Start-TaskIfNotRunning { + param([string]`$TaskName) + if ([string]::IsNullOrWhiteSpace(`$TaskName)) { + return + } + + try { + `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + if (-not `$task) { + return + } + if ([string]`$task.State -eq 'Running') { + return + } + Start-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + } + catch { + } +} + +`$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath +if (-not `$recoveryLockPath) { + return +} + +try { + while (`$true) { + `$sleepSeconds = 180 + try { + `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath + foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { + Start-TaskIfNotRunning -TaskName `$taskName + } + + `$config = Get-DeploymentConfig -Path `$ConfigPath + if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { + `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) + } + } + catch { + } + + Start-Sleep -Seconds `$sleepSeconds + } +} +finally { + if (`$recoveryLockPath -and (Test-Path -LiteralPath `$recoveryLockPath)) { + Remove-Item -LiteralPath `$recoveryLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Get-ActivityWatchHiddenLauncherPath { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath + ) + + $directory = Split-Path -Path $ScriptPath -Parent + $baseName = [IO.Path]::GetFileNameWithoutExtension($ScriptPath) + return Join-Path $directory ("{0}-hidden.vbs" -f $baseName) +} + +function Write-ActivityWatchHiddenPowerShellWrapper { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $escapedPowerShellExe = $powershellExe.Replace('"', '""') + $escapedScriptPath = $ScriptPath.Replace('"', '""') + $escapedConfigPath = $ConfigPath.Replace('"', '""') + + $content = @" +Set shell = CreateObject("WScript.Shell") +shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding ASCII +} + +function Remove-LegacyActivityWatchEntries { + $legacyTaskNames = @( + 'ActivityWatch Watchers', + 'ActivityWatch Guard', + 'ActivityWatch Heal' + ) + + foreach ($taskName in $legacyTaskNames) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + } + + $runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' + foreach ($name in 'ActivityWatchAFK', 'ActivityWatchWindow', 'ActivityWatchBrowserCollector') { + Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue + } +} + +function Remove-ActivityWatchScheduledTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName + ) + + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + & cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null + + for ($attempt = 0; $attempt -lt 10; $attempt++) { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + return + } + + Start-Sleep -Milliseconds 300 + } +} + +function Set-ActivityWatchScheduledTaskAction { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$Execute, + [Parameter(Mandatory = $true)] + [string]$Arguments + ) + + $taskCommand = ('"{0}" {1}' -f $Execute, $Arguments) + & schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "schtasks.exe /Change завершился с ошибкой для $TaskName" + } +} + +function Get-ActivityWatchScheduledTaskByCommand { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [string]$CommandMatch + ) + + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($task) { + return $task + } + + if ([string]::IsNullOrWhiteSpace($CommandMatch)) { + return $null + } + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$CommandMatch*") { + return $candidate + } + } + } + + return $null +} + +function Remove-StaleActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath + ) + + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + $desiredTaskNames = @($TaskDefinitions | ForEach-Object { [string]$_.LaunchTaskName }) + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + $taskName = [string]$candidate.TaskName + if ($desiredTaskNames -contains $taskName) { + continue + } + + $usesCurrentLauncher = $false + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$launcherPath*") { + $usesCurrentLauncher = $true + break + } + } + + if ($usesCurrentLauncher) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + } + } +} + +function Register-ActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath + Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath + + foreach ($definition in $TaskDefinitions) { + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId + $principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + $existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath + + if ($existingTask) { + Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments + continue + } + + Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName + Register-ScheduledTask -TaskName $definition.LaunchTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null + } +} + +function Register-ActivityWatchRecoveryTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + Remove-ActivityWatchScheduledTask -TaskName $TaskName + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null +} + +function Set-ActivityWatchAcl { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot + ) + + foreach ($path in $InstallRoot, $StateRoot, $LogsRoot) { + New-ActivityWatchDirectory -Path $path + } + + & icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $InstallRoot" + } + + & icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $StateRoot" + } + + & icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $LogsRoot" + } +} + +function Start-ActivityWatchTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [string]$RecoveryTaskName = 'ActivityWatch Recovery' + ) + + foreach ($definition in $TaskDefinitions) { + Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue + } + + Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue +} + +Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries +; } + Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$fileOpsEnabled) { + Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } + Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$emailEnabled -and (Test-Path -LiteralPath `$emailCollectorScript)) { + Start-CollectorScriptIfNeeded -ScriptPath `$emailCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } +} +finally { + if (`$launchLockPath -and (Test-Path -LiteralPath `$launchLockPath)) { + Remove-Item -LiteralPath `$launchLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Write-ActivityWatchRecoveryScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Continue' + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Get-RecoveryConfigPaths { + param([string]`$PrimaryConfigPath) + + `$paths = New-Object System.Collections.Generic.List[string] + if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) { + `$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path) + } + + `$searchRoot = `$env:ProgramData + if (`$PrimaryConfigPath) { + `$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent + `$candidateRoot = Split-Path -Path `$stateRoot -Parent + if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) { + `$searchRoot = `$candidateRoot + } + } + + if (Test-Path -LiteralPath `$searchRoot) { + Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { `$_.Name -like 'ActivityWatch*' } | + ForEach-Object { + `$candidate = Join-Path `$_.FullName 'deployment-config.json' + if (Test-Path -LiteralPath `$candidate) { + `$paths.Add(`$candidate) + } + } + } + + return @(`$paths | Sort-Object -Unique) +} + +function Get-RecoveryTaskNames { + param([string[]]`$ConfigPaths) + + `$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach (`$candidatePath in @(`$ConfigPaths)) { + try { + `$config = Get-DeploymentConfig -Path `$candidatePath + foreach (`$task in @(`$config.userTasks)) { + `$taskName = [string]`$task.launchTaskName + if (-not [string]::IsNullOrWhiteSpace(`$taskName)) { + [void]`$taskNames.Add(`$taskName) + } + } + } + catch { Write-Error Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Запустите этот скрипт из PowerShell с правами администратора.' + } +} + +function New-ActivityWatchDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Enable-ActivityWatchPrintTelemetry { + $policyPath = 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' + if (-not (Test-Path -LiteralPath $policyPath)) { + New-Item -Path $policyPath -Force | Out-Null + } + New-ItemProperty -Path $policyPath -Name 'ShowJobTitleInEventLogs' -Value 1 -PropertyType DWord -Force | Out-Null + + & wevtutil.exe sl 'Microsoft-Windows-PrintService/Operational' /e:true | Out-Null +} + + +function Get-ActivityWatchPackageUrl { + param( + [string]$Version = 'v0.13.2' + ) + + return "https://github.com/ActivityWatch/activitywatch/releases/download/$Version/activitywatch-$Version-windows-x86_64.zip" +} + +function Get-ActivityWatchArchive { + param( + [string]$PackageZipPath, + [string]$PackageUrl, + [string]$Version = 'v0.13.2', + [Parameter(Mandatory = $true)] + [string]$WorkingRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + + if ($PackageZipPath) { + $resolved = Resolve-Path -LiteralPath $PackageZipPath -ErrorAction Stop + return $resolved.Path + } + + if (-not $PackageUrl) { + $PackageUrl = Get-ActivityWatchPackageUrl -Version $Version + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $suffix = ([guid]::NewGuid().Guid.Substring(0, 8)) + $archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix) + Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath + return $archivePath +} + +function Get-ActivityWatchPackageRoot { + param( + [Parameter(Mandatory = $true)] + [string]$ExpandedRoot + ) + + $afkBinary = Get-ChildItem -Path $ExpandedRoot -Filter 'aw-watcher-afk.exe' -File -Recurse | + Select-Object -First 1 + + if (-not $afkBinary) { + throw "Не удалось найти aw-watcher-afk.exe в $ExpandedRoot." + } + + return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent) +} + +function Install-ActivityWatchPackage { + param( + [Parameter(Mandatory = $true)] + [string]$ArchivePath, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$WorkingRoot, + [Parameter(Mandatory = $true)] + [string]$BackupRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + New-ActivityWatchDirectory -Path $BackupRoot + + # Ensure nothing is holding locks inside InstallRoot during upgrade. + foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) { + try { + Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } + catch { + } + } + Start-Sleep -Seconds 2 + + $extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid) + if (Test-Path -LiteralPath $extractRoot) { + Remove-Item -LiteralPath $extractRoot -Recurse -Force + } + New-ActivityWatchDirectory -Path $extractRoot + + Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force + $packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot + + if (Test-Path -LiteralPath $InstallRoot) { + $existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue + if ($existingItems) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupPath = Join-Path $BackupRoot ("install-$stamp") + New-ActivityWatchDirectory -Path $backupPath + Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force + Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force + } + } + else { + New-ActivityWatchDirectory -Path $InstallRoot + } + + Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force + + return [pscustomobject]@{ + PackageRoot = $packageRoot + ExtractRoot = $extractRoot + BackupRoot = $BackupRoot + } +} + +function Get-ActivityWatchExecutableMap { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot + ) + + $map = [ordered]@{ + Afk = Join-Path $InstallRoot 'aw-watcher-afk\aw-watcher-afk.exe' + Window = Join-Path $InstallRoot 'aw-watcher-window\aw-watcher-window.exe' + } + + foreach ($entry in $map.GetEnumerator()) { + if (-not (Test-Path -LiteralPath $entry.Value)) { + throw "Не найден обязательный исполняемый файл ActivityWatch: $($entry.Value)" + } + } + + return [pscustomobject]$map +} + +function Normalize-ActivityWatchUsers { + param( + [string[]]$Users, + [string]$UserListPath, + [string]$Domain + ) + + $collected = New-Object System.Collections.Generic.List[string] + + if ($Users) { + foreach ($user in $Users) { + if (-not [string]::IsNullOrWhiteSpace($user)) { + $collected.Add($user.Trim()) + } + } + } + + if ($UserListPath) { + $resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop + $extension = [IO.Path]::GetExtension($resolved.Path) + if ($extension -ieq '.csv') { + $rows = Import-Csv -LiteralPath $resolved.Path + foreach ($row in $rows) { + foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') { + if ($row.PSObject.Properties.Name -contains $column) { + $value = [string]$row.$column + if (-not [string]::IsNullOrWhiteSpace($value)) { + $collected.Add($value.Trim()) + break + } + } + } + } + } + else { + Get-Content -LiteralPath $resolved.Path | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith('#')) { + $collected.Add($line) + } + } + } + } + + $normalized = $collected | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { + if ($Domain -and ($_ -notmatch '[\\@]')) { + '{0}\{1}' -f $Domain, $_ + } + else { + $_ + } + } | + Sort-Object -Unique + + if (-not $normalized -or $normalized.Count -eq 0) { + throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.' + } + + return @($normalized) +} + +function Get-ActivityWatchTaskNameToken { + param( + [Parameter(Mandatory = $true)] + [string]$UserId + ) + + $buffer = [Text.StringBuilder]::new() + foreach ($character in $UserId.ToCharArray()) { + if ([char]::IsLetterOrDigit($character)) { + [void]$buffer.Append($character) + } + else { + [void]$buffer.Append('_') + } + } + + return $buffer.ToString().Trim('_') +} + +function New-ActivityWatchUserTaskDefinitions { + param( + [Parameter(Mandatory = $true)] + [string[]]$Users + ) + + $result = foreach ($user in $Users) { + $token = Get-ActivityWatchTaskNameToken -UserId $user + [pscustomobject]@{ + UserId = $user + LaunchTaskName = "ActivityWatch Launch [$token]" + } + } + + return @($result) +} + +function Copy-ActivityWatchCollectorAssets { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScriptSource, + [string]$EmailCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$ExampleRulesSource, + [Parameter(Mandatory = $true)] + [string]$ExamplePolicySource, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [string]$CustomRulesSource, + [string]$CustomPolicySource + ) + + New-ActivityWatchDirectory -Path $StateRoot + + $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' + $endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1' + $fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1' + $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' + $emailCollectorTarget = Join-Path $StateRoot 'email-outbound-collector.ps1' + $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' + $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' + $examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json' + $policyTarget = Join-Path $StateRoot 'dlp-policy.json' + + Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force + Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force + Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force + if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) { + Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force + } + Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force + Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force + + if ($CustomRulesSource) { + $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force + } + else { + Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force + } + + if ($CustomPolicySource) { + $resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedPolicy.Path -Destination $policyTarget -Force + } + else { + Copy-Item -LiteralPath $examplePolicyTarget -Destination $policyTarget -Force + } + + return [pscustomobject]@{ + CollectorScript = $collectorTarget + EndpointCollectorScript = $endpointCollectorTarget + FileCollectorScript = $fileCollectorTarget + SessionCollectorScript = $sessionCollectorTarget + EmailCollectorScript = $emailCollectorTarget + ExampleRules = $exampleRulesTarget + ActiveRules = $rulesTarget + ExamplePolicy = $examplePolicyTarget + ActivePolicy = $policyTarget + } +} + +function New-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$ServerHost, + [Parameter(Mandatory = $true)] + [int]$ServerPort, + [Parameter(Mandatory = $true)] + [string]$ServerScheme, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot, + [Parameter(Mandatory = $true)] + [string]$CollectorScript, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScript, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScript, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScript, + [string]$EmailCollectorScript, + [Parameter(Mandatory = $true)] + [string]$RulesPath, + [Parameter(Mandatory = $true)] + [string]$PolicyPath, + [Parameter(Mandatory = $true)] + [int]$PollSeconds, + [Parameter(Mandatory = $true)] + [int]$PulseSeconds, + [Parameter(Mandatory = $true)] + [int]$RecoveryIntervalSeconds, + [bool]$AfkEnabled = $true, + [bool]$WindowEnabled = $true, + [bool]$FileOpsEnabled = $true, + [bool]$LocalAgentLogsEnabled = $true, + [bool]$IncidentCaptureEnabled = $true, + [bool]$IncidentScreenshotEnabled = $true, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled = $true, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [pscustomobject[]]$UserTasks, + [string]$PackageVersion = 'v0.13.2' + ) + + $effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' } + + return [pscustomobject]@{ + version = 1 + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + server = [pscustomobject]@{ + host = $ServerHost + port = $ServerPort + scheme = $ServerScheme + } + paths = [pscustomobject]@{ + installRoot = $InstallRoot + stateRoot = $StateRoot + logsRoot = $LogsRoot + collectorScript = $CollectorScript + endpointCollectorScript = $EndpointCollectorScript + emailCollectorScript = $EmailCollectorScript + fileCollectorScript = $FileCollectorScript + sessionCollectorScript = $SessionCollectorScript + rulesPath = $RulesPath + policyPath = $PolicyPath + launchScript = $LaunchScriptPath + recoveryScript = $RecoveryScriptPath + } + collector = [pscustomobject]@{ + pollSeconds = $PollSeconds + pulseSeconds = $PulseSeconds + } + collectors = [pscustomobject]@{ + afkEnabled = $AfkEnabled + windowEnabled = $WindowEnabled + fileOpsEnabled = $FileOpsEnabled + emailEnabled = ($null -ne $EmailCollectorScript -and $EmailCollectorScript -ne '') + } + logging = [pscustomobject]@{ + localAgentLogsEnabled = $LocalAgentLogsEnabled + } + incidentCapture = [pscustomobject]@{ + enabled = $IncidentCaptureEnabled + screenshotEnabled = $IncidentScreenshotEnabled + artifactsRoot = $effectiveIncidentArtifactsRoot + } + sessionEvents = [pscustomobject]@{ + logonEnabled = $LogonMarkerEnabled + bucketPrefix = 'aw-session-events' + } + recovery = [pscustomobject]@{ + intervalSeconds = $RecoveryIntervalSeconds + taskName = 'ActivityWatch Recovery' + } + dlp = [pscustomobject]@{ + incidentBucketPrefix = 'aw-dlp-incidents' + enabled = $true + } + package = [pscustomobject]@{ + version = $PackageVersion + } + userTasks = @($UserTasks) + } +} + +function Write-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $json = $Config | ConvertTo-Json -Depth 8 + Set-Content -LiteralPath $Path -Value $json -Encoding UTF8 +} + +function Read-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация развёртывания не найдена: $Path" + } + + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Write-ActivityWatchLaunchScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Stop' + +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http +`$script:MaxCollectorPowerShellProcesses = 24 + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Test-ProcessInSession { + param( + [string]`$Name, + [int]`$SessionId + ) + + return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1) +} + +function Test-CollectorRunning { + param( + [string]`$ScriptPath, + [int]`$SessionId + ) + + `$escapedCollector = [Regex]::Escape(`$ScriptPath) + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.SessionId -eq `$SessionId -and + `$_.CommandLine -match `$escapedCollector + } + + return [bool](`$processes | Select-Object -First 1) +} + +function Get-CollectorPowerShellProcessCount { + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.CommandLine -match 'AWatch-rus' -and + `$_.CommandLine -match '\.ps1' + } + + return @(`$processes).Count +} + +function New-LaunchLock { + param([string]`$StateRoot, [int]`$SessionId) + + `$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId) + 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 + sessionId = `$SessionId + createdAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath `$lockPath -Value `$payload -Encoding UTF8 + return `$lockPath +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = `$true)][string]`$Uri, + [Parameter(Mandatory = `$true)][string]`$Json + ) + + `$httpClient = New-Object System.Net.Http.HttpClient + try { + `$content = New-Object System.Net.Http.StringContent(`$Json, [System.Text.Encoding]::UTF8, 'application/json') + `$response = `$httpClient.PostAsync(`$Uri, `$content).Result + if (-not `$response.IsSuccessStatusCode) { + return `$false + } + return `$true + } + catch { + return `$false + } + finally { + `$httpClient.Dispose() + } +} + +function Ensure-Bucket { + param( + [string]`$BucketId, + [string]`$ClientName, + [string]`$BucketType + ) + + if (`$script:KnownBuckets.ContainsKey(`$BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + `$script:KnownBuckets[`$BucketId] = `$true + return + } + catch { + } + + `$body = @{ + client = `$ClientName + type = `$BucketType + hostname = `$script:Hostname + } | ConvertTo-Json -Compress + + try { + if (-not (Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body)) { + return + } + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + } + catch { + return + } + } + + `$script:KnownBuckets[`$BucketId] = `$true +} + +function Send-LogonMarkerIfNeeded { + param( + [pscustomobject]`$Config, + [int]`$SessionId + ) + + `$sessionEvents = if (`$Config.PSObject.Properties.Name -contains 'sessionEvents') { `$Config.sessionEvents } else { `$null } + `$logging = if (`$Config.PSObject.Properties.Name -contains 'logging') { `$Config.logging } else { `$null } + `$logonEnabled = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]`$sessionEvents.logonEnabled } else { `$false } + if (-not `$logonEnabled) { + return + } + + `$bucketPrefix = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]`$sessionEvents.bucketPrefix)) { + [string]`$sessionEvents.bucketPrefix + } + else { + 'aw-session-events' + } + + `$stateRoot = [string]`$Config.paths.stateRoot + `$markerRoots = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) { + `$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers')) + } + if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) { + `$markerRoots.Add((Join-Path `$stateRoot 'markers')) + } + + `$markerDir = `$null + foreach (`$candidate in `$markerRoots) { + try { + if (-not (Test-Path -LiteralPath `$candidate)) { + New-Item -Path `$candidate -ItemType Directory -Force | Out-Null + } + + `$probePath = Join-Path `$candidate 'write-test.tmp' + Set-Content -LiteralPath `$probePath -Value 'ok' -Encoding ASCII + Remove-Item -LiteralPath `$probePath -Force -ErrorAction SilentlyContinue + `$markerDir = `$candidate + break + } + catch { + } + } + + if (-not `$markerDir) { + return + } + + `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId) + if (Test-Path -LiteralPath `$markerFile) { + return + } + + Set-Content -LiteralPath `$markerFile -Value ((Get-Date).ToUniversalTime().ToString('o')) -Encoding UTF8 + + `$bucketId = ('{0}_{1}' -f `$bucketPrefix, `$script:Hostname) + Ensure-Bucket -BucketId `$bucketId -ClientName 'aw-session-events' -BucketType 'aw.session.event' + + `$payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + eventType = 'logon' + username = `$env:USERNAME + userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)" + sessionId = `$SessionId + hostname = `$script:Hostname + source = 'launch-watchers-awatch-rus' + } + } | ConvertTo-Json -Depth 5 -Compress + + try { + Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$bucketId/heartbeat?pulsetime=1" -Json `$payload + } + catch { + Remove-Item -LiteralPath `$markerFile -Force -ErrorAction SilentlyContinue + throw + } +} + +function Start-CollectorScriptIfNeeded { + param( + [string]`$ScriptPath, + [string]`$ConfigPath, + [string]`$PowerShellExe, + [int]`$SessionId + ) + + if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + return + } + + if (-not (Test-Path -LiteralPath `$ScriptPath)) { + return + } + + if (Test-CollectorRunning -ScriptPath `$ScriptPath -SessionId `$SessionId) { + return + } + + if ((Get-CollectorPowerShellProcessCount) -ge `$script:MaxCollectorPowerShellProcesses) { + return + } + + `$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null } + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass') + if (`$staParam) { `$argumentList += `$staParam } + `$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden +} + +`$config = Get-DeploymentConfig -Path `$ConfigPath +`$sessionId = (Get-Process -Id `$PID).SessionId +`$installRoot = [string]`$config.paths.installRoot +`$stateRoot = [string]`$config.paths.stateRoot +`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port +`$script:Hostname = `$env:COMPUTERNAME +`$script:KnownBuckets = @{} +`$collectorScript = [string]`$config.paths.collectorScript +`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' } +`$fileCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]`$config.paths.fileCollectorScript } else { Join-Path `$stateRoot 'file-operations-collector.ps1' } +`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' } +`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe' +`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe' +`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port) +`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +`$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 } +`$fileOpsEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]`$config.collectors.fileOpsEnabled } else { `$true } +`$emailEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'emailEnabled') { [bool]`$config.collectors.emailEnabled } else { `$false } +`$emailCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'emailCollectorScript') { [string]`$config.paths.emailCollectorScript } else { Join-Path `$stateRoot 'email-outbound-collector.ps1' } +`$launchLockPath = New-LaunchLock -StateRoot `$stateRoot -SessionId `$sessionId +if (-not `$launchLockPath) { + return +} + +try { + if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) { + throw "Не найден aw-watcher-afk.exe: `$afkExe" + } + + if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) { + throw "Не найден aw-watcher-window.exe: `$windowExe" + } + + if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) { + Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + if (`$windowEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) { + Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + try { + Send-LogonMarkerIfNeeded -Config `$config -SessionId `$sessionId + } + catch { + } + Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$fileOpsEnabled) { + Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } + Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$emailEnabled -and (Test-Path -LiteralPath `$emailCollectorScript)) { + Start-CollectorScriptIfNeeded -ScriptPath `$emailCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } +} +finally { + if (`$launchLockPath -and (Test-Path -LiteralPath `$launchLockPath)) { + Remove-Item -LiteralPath `$launchLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Write-ActivityWatchRecoveryScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Continue' + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Get-RecoveryConfigPaths { + param([string]`$PrimaryConfigPath) + + `$paths = New-Object System.Collections.Generic.List[string] + if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) { + `$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path) + } + + `$searchRoot = `$env:ProgramData + if (`$PrimaryConfigPath) { + `$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent + `$candidateRoot = Split-Path -Path `$stateRoot -Parent + if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) { + `$searchRoot = `$candidateRoot + } + } + + if (Test-Path -LiteralPath `$searchRoot) { + Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { `$_.Name -like 'ActivityWatch*' } | + ForEach-Object { + `$candidate = Join-Path `$_.FullName 'deployment-config.json' + if (Test-Path -LiteralPath `$candidate) { + `$paths.Add(`$candidate) + } + } + } + + return @(`$paths | Sort-Object -Unique) +} + +function Get-RecoveryTaskNames { + param([string[]]`$ConfigPaths) + + `$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach (`$candidatePath in @(`$ConfigPaths)) { + try { + `$config = Get-DeploymentConfig -Path `$candidatePath + foreach (`$task in @(`$config.userTasks)) { + `$taskName = [string]`$task.launchTaskName + if (-not [string]::IsNullOrWhiteSpace(`$taskName)) { + [void]`$taskNames.Add(`$taskName) + } + } + } + catch { + } + } + + return @(`$taskNames) +} + +function New-RecoveryLock { + param([string]`$PrimaryConfigPath) + + `$stateRoot = if (`$PrimaryConfigPath) { Split-Path -Path `$PrimaryConfigPath -Parent } else { Join-Path `$env:ProgramData 'AWatch-rus' } + if (-not (Test-Path -LiteralPath `$stateRoot)) { + New-Item -Path `$stateRoot -ItemType Directory -Force | Out-Null + } + + `$lockPath = Join-Path `$stateRoot 'recovery-loop.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 Start-TaskIfNotRunning { + param([string]`$TaskName) + if ([string]::IsNullOrWhiteSpace(`$TaskName)) { + return + } + + try { + `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + if (-not `$task) { + return + } + if ([string]`$task.State -eq 'Running') { + return + } + Start-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + } + catch { + } +} + +`$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath +if (-not `$recoveryLockPath) { + return +} + +try { + while (`$true) { + `$sleepSeconds = 180 + try { + `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath + foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { + Start-TaskIfNotRunning -TaskName `$taskName + } + + `$config = Get-DeploymentConfig -Path `$ConfigPath + if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { + `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) + } + } + catch { + } + + Start-Sleep -Seconds `$sleepSeconds + } +} +finally { + if (`$recoveryLockPath -and (Test-Path -LiteralPath `$recoveryLockPath)) { + Remove-Item -LiteralPath `$recoveryLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Get-ActivityWatchHiddenLauncherPath { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath + ) + + $directory = Split-Path -Path $ScriptPath -Parent + $baseName = [IO.Path]::GetFileNameWithoutExtension($ScriptPath) + return Join-Path $directory ("{0}-hidden.vbs" -f $baseName) +} + +function Write-ActivityWatchHiddenPowerShellWrapper { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $escapedPowerShellExe = $powershellExe.Replace('"', '""') + $escapedScriptPath = $ScriptPath.Replace('"', '""') + $escapedConfigPath = $ConfigPath.Replace('"', '""') + + $content = @" +Set shell = CreateObject("WScript.Shell") +shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding ASCII +} + +function Remove-LegacyActivityWatchEntries { + $legacyTaskNames = @( + 'ActivityWatch Watchers', + 'ActivityWatch Guard', + 'ActivityWatch Heal' + ) + + foreach ($taskName in $legacyTaskNames) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + } + + $runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' + foreach ($name in 'ActivityWatchAFK', 'ActivityWatchWindow', 'ActivityWatchBrowserCollector') { + Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue + } +} + +function Remove-ActivityWatchScheduledTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName + ) + + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + & cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null + + for ($attempt = 0; $attempt -lt 10; $attempt++) { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + return + } + + Start-Sleep -Milliseconds 300 + } +} + +function Set-ActivityWatchScheduledTaskAction { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$Execute, + [Parameter(Mandatory = $true)] + [string]$Arguments + ) + + $taskCommand = ('"{0}" {1}' -f $Execute, $Arguments) + & schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "schtasks.exe /Change завершился с ошибкой для $TaskName" + } +} + +function Get-ActivityWatchScheduledTaskByCommand { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [string]$CommandMatch + ) + + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($task) { + return $task + } + + if ([string]::IsNullOrWhiteSpace($CommandMatch)) { + return $null + } + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$CommandMatch*") { + return $candidate + } + } + } + + return $null +} + +function Remove-StaleActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath + ) + + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + $desiredTaskNames = @($TaskDefinitions | ForEach-Object { [string]$_.LaunchTaskName }) + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + $taskName = [string]$candidate.TaskName + if ($desiredTaskNames -contains $taskName) { + continue + } + + $usesCurrentLauncher = $false + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$launcherPath*") { + $usesCurrentLauncher = $true + break + } + } + + if ($usesCurrentLauncher) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + } + } +} + +function Register-ActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath + Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath + + foreach ($definition in $TaskDefinitions) { + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId + $principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + $existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath + + if ($existingTask) { + Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments + continue + } + + Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName + Register-ScheduledTask -TaskName $definition.LaunchTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null + } +} + +function Register-ActivityWatchRecoveryTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + Remove-ActivityWatchScheduledTask -TaskName $TaskName + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null +} + +function Set-ActivityWatchAcl { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot + ) + + foreach ($path in $InstallRoot, $StateRoot, $LogsRoot) { + New-ActivityWatchDirectory -Path $path + } + + & icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $InstallRoot" + } + + & icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $StateRoot" + } + + & icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $LogsRoot" + } +} + +function Start-ActivityWatchTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [string]$RecoveryTaskName = 'ActivityWatch Recovery' + ) + + foreach ($definition in $TaskDefinitions) { + Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue + } + + Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue +} + +Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries +; } + } + + return @(`$taskNames) +} + +function New-RecoveryLock { + param([string]`$PrimaryConfigPath) + + `$stateRoot = if (`$PrimaryConfigPath) { Split-Path -Path `$PrimaryConfigPath -Parent } else { Join-Path `$env:ProgramData 'AWatch-rus' } + if (-not (Test-Path -LiteralPath `$stateRoot)) { + New-Item -Path `$stateRoot -ItemType Directory -Force | Out-Null + } + + `$lockPath = Join-Path `$stateRoot 'recovery-loop.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 { Write-Error Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Запустите этот скрипт из PowerShell с правами администратора.' + } +} + +function New-ActivityWatchDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Enable-ActivityWatchPrintTelemetry { + $policyPath = 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' + if (-not (Test-Path -LiteralPath $policyPath)) { + New-Item -Path $policyPath -Force | Out-Null + } + New-ItemProperty -Path $policyPath -Name 'ShowJobTitleInEventLogs' -Value 1 -PropertyType DWord -Force | Out-Null + + & wevtutil.exe sl 'Microsoft-Windows-PrintService/Operational' /e:true | Out-Null +} + + +function Get-ActivityWatchPackageUrl { + param( + [string]$Version = 'v0.13.2' + ) + + return "https://github.com/ActivityWatch/activitywatch/releases/download/$Version/activitywatch-$Version-windows-x86_64.zip" +} + +function Get-ActivityWatchArchive { + param( + [string]$PackageZipPath, + [string]$PackageUrl, + [string]$Version = 'v0.13.2', + [Parameter(Mandatory = $true)] + [string]$WorkingRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + + if ($PackageZipPath) { + $resolved = Resolve-Path -LiteralPath $PackageZipPath -ErrorAction Stop + return $resolved.Path + } + + if (-not $PackageUrl) { + $PackageUrl = Get-ActivityWatchPackageUrl -Version $Version + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $suffix = ([guid]::NewGuid().Guid.Substring(0, 8)) + $archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix) + Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath + return $archivePath +} + +function Get-ActivityWatchPackageRoot { + param( + [Parameter(Mandatory = $true)] + [string]$ExpandedRoot + ) + + $afkBinary = Get-ChildItem -Path $ExpandedRoot -Filter 'aw-watcher-afk.exe' -File -Recurse | + Select-Object -First 1 + + if (-not $afkBinary) { + throw "Не удалось найти aw-watcher-afk.exe в $ExpandedRoot." + } + + return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent) +} + +function Install-ActivityWatchPackage { + param( + [Parameter(Mandatory = $true)] + [string]$ArchivePath, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$WorkingRoot, + [Parameter(Mandatory = $true)] + [string]$BackupRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + New-ActivityWatchDirectory -Path $BackupRoot + + # Ensure nothing is holding locks inside InstallRoot during upgrade. + foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) { + try { + Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } + catch { + } + } + Start-Sleep -Seconds 2 + + $extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid) + if (Test-Path -LiteralPath $extractRoot) { + Remove-Item -LiteralPath $extractRoot -Recurse -Force + } + New-ActivityWatchDirectory -Path $extractRoot + + Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force + $packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot + + if (Test-Path -LiteralPath $InstallRoot) { + $existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue + if ($existingItems) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupPath = Join-Path $BackupRoot ("install-$stamp") + New-ActivityWatchDirectory -Path $backupPath + Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force + Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force + } + } + else { + New-ActivityWatchDirectory -Path $InstallRoot + } + + Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force + + return [pscustomobject]@{ + PackageRoot = $packageRoot + ExtractRoot = $extractRoot + BackupRoot = $BackupRoot + } +} + +function Get-ActivityWatchExecutableMap { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot + ) + + $map = [ordered]@{ + Afk = Join-Path $InstallRoot 'aw-watcher-afk\aw-watcher-afk.exe' + Window = Join-Path $InstallRoot 'aw-watcher-window\aw-watcher-window.exe' + } + + foreach ($entry in $map.GetEnumerator()) { + if (-not (Test-Path -LiteralPath $entry.Value)) { + throw "Не найден обязательный исполняемый файл ActivityWatch: $($entry.Value)" + } + } + + return [pscustomobject]$map +} + +function Normalize-ActivityWatchUsers { + param( + [string[]]$Users, + [string]$UserListPath, + [string]$Domain + ) + + $collected = New-Object System.Collections.Generic.List[string] + + if ($Users) { + foreach ($user in $Users) { + if (-not [string]::IsNullOrWhiteSpace($user)) { + $collected.Add($user.Trim()) + } + } + } + + if ($UserListPath) { + $resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop + $extension = [IO.Path]::GetExtension($resolved.Path) + if ($extension -ieq '.csv') { + $rows = Import-Csv -LiteralPath $resolved.Path + foreach ($row in $rows) { + foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') { + if ($row.PSObject.Properties.Name -contains $column) { + $value = [string]$row.$column + if (-not [string]::IsNullOrWhiteSpace($value)) { + $collected.Add($value.Trim()) + break + } + } + } + } + } + else { + Get-Content -LiteralPath $resolved.Path | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith('#')) { + $collected.Add($line) + } + } + } + } + + $normalized = $collected | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { + if ($Domain -and ($_ -notmatch '[\\@]')) { + '{0}\{1}' -f $Domain, $_ + } + else { + $_ + } + } | + Sort-Object -Unique + + if (-not $normalized -or $normalized.Count -eq 0) { + throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.' + } + + return @($normalized) +} + +function Get-ActivityWatchTaskNameToken { + param( + [Parameter(Mandatory = $true)] + [string]$UserId + ) + + $buffer = [Text.StringBuilder]::new() + foreach ($character in $UserId.ToCharArray()) { + if ([char]::IsLetterOrDigit($character)) { + [void]$buffer.Append($character) + } + else { + [void]$buffer.Append('_') + } + } + + return $buffer.ToString().Trim('_') +} + +function New-ActivityWatchUserTaskDefinitions { + param( + [Parameter(Mandatory = $true)] + [string[]]$Users + ) + + $result = foreach ($user in $Users) { + $token = Get-ActivityWatchTaskNameToken -UserId $user + [pscustomobject]@{ + UserId = $user + LaunchTaskName = "ActivityWatch Launch [$token]" + } + } + + return @($result) +} + +function Copy-ActivityWatchCollectorAssets { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScriptSource, + [string]$EmailCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$ExampleRulesSource, + [Parameter(Mandatory = $true)] + [string]$ExamplePolicySource, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [string]$CustomRulesSource, + [string]$CustomPolicySource + ) + + New-ActivityWatchDirectory -Path $StateRoot + + $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' + $endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1' + $fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1' + $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' + $emailCollectorTarget = Join-Path $StateRoot 'email-outbound-collector.ps1' + $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' + $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' + $examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json' + $policyTarget = Join-Path $StateRoot 'dlp-policy.json' + + Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force + Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force + Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force + if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) { + Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force + } + Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force + Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force + + if ($CustomRulesSource) { + $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force + } + else { + Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force + } + + if ($CustomPolicySource) { + $resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedPolicy.Path -Destination $policyTarget -Force + } + else { + Copy-Item -LiteralPath $examplePolicyTarget -Destination $policyTarget -Force + } + + return [pscustomobject]@{ + CollectorScript = $collectorTarget + EndpointCollectorScript = $endpointCollectorTarget + FileCollectorScript = $fileCollectorTarget + SessionCollectorScript = $sessionCollectorTarget + EmailCollectorScript = $emailCollectorTarget + ExampleRules = $exampleRulesTarget + ActiveRules = $rulesTarget + ExamplePolicy = $examplePolicyTarget + ActivePolicy = $policyTarget + } +} + +function New-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$ServerHost, + [Parameter(Mandatory = $true)] + [int]$ServerPort, + [Parameter(Mandatory = $true)] + [string]$ServerScheme, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot, + [Parameter(Mandatory = $true)] + [string]$CollectorScript, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScript, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScript, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScript, + [string]$EmailCollectorScript, + [Parameter(Mandatory = $true)] + [string]$RulesPath, + [Parameter(Mandatory = $true)] + [string]$PolicyPath, + [Parameter(Mandatory = $true)] + [int]$PollSeconds, + [Parameter(Mandatory = $true)] + [int]$PulseSeconds, + [Parameter(Mandatory = $true)] + [int]$RecoveryIntervalSeconds, + [bool]$AfkEnabled = $true, + [bool]$WindowEnabled = $true, + [bool]$FileOpsEnabled = $true, + [bool]$LocalAgentLogsEnabled = $true, + [bool]$IncidentCaptureEnabled = $true, + [bool]$IncidentScreenshotEnabled = $true, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled = $true, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [pscustomobject[]]$UserTasks, + [string]$PackageVersion = 'v0.13.2' + ) + + $effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' } + + return [pscustomobject]@{ + version = 1 + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + server = [pscustomobject]@{ + host = $ServerHost + port = $ServerPort + scheme = $ServerScheme + } + paths = [pscustomobject]@{ + installRoot = $InstallRoot + stateRoot = $StateRoot + logsRoot = $LogsRoot + collectorScript = $CollectorScript + endpointCollectorScript = $EndpointCollectorScript + emailCollectorScript = $EmailCollectorScript + fileCollectorScript = $FileCollectorScript + sessionCollectorScript = $SessionCollectorScript + rulesPath = $RulesPath + policyPath = $PolicyPath + launchScript = $LaunchScriptPath + recoveryScript = $RecoveryScriptPath + } + collector = [pscustomobject]@{ + pollSeconds = $PollSeconds + pulseSeconds = $PulseSeconds + } + collectors = [pscustomobject]@{ + afkEnabled = $AfkEnabled + windowEnabled = $WindowEnabled + fileOpsEnabled = $FileOpsEnabled + emailEnabled = ($null -ne $EmailCollectorScript -and $EmailCollectorScript -ne '') + } + logging = [pscustomobject]@{ + localAgentLogsEnabled = $LocalAgentLogsEnabled + } + incidentCapture = [pscustomobject]@{ + enabled = $IncidentCaptureEnabled + screenshotEnabled = $IncidentScreenshotEnabled + artifactsRoot = $effectiveIncidentArtifactsRoot + } + sessionEvents = [pscustomobject]@{ + logonEnabled = $LogonMarkerEnabled + bucketPrefix = 'aw-session-events' + } + recovery = [pscustomobject]@{ + intervalSeconds = $RecoveryIntervalSeconds + taskName = 'ActivityWatch Recovery' + } + dlp = [pscustomobject]@{ + incidentBucketPrefix = 'aw-dlp-incidents' + enabled = $true + } + package = [pscustomobject]@{ + version = $PackageVersion + } + userTasks = @($UserTasks) + } +} + +function Write-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $json = $Config | ConvertTo-Json -Depth 8 + Set-Content -LiteralPath $Path -Value $json -Encoding UTF8 +} + +function Read-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация развёртывания не найдена: $Path" + } + + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Write-ActivityWatchLaunchScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Stop' + +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http +`$script:MaxCollectorPowerShellProcesses = 24 + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Test-ProcessInSession { + param( + [string]`$Name, + [int]`$SessionId + ) + + return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1) +} + +function Test-CollectorRunning { + param( + [string]`$ScriptPath, + [int]`$SessionId + ) + + `$escapedCollector = [Regex]::Escape(`$ScriptPath) + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.SessionId -eq `$SessionId -and + `$_.CommandLine -match `$escapedCollector + } + + return [bool](`$processes | Select-Object -First 1) +} + +function Get-CollectorPowerShellProcessCount { + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.CommandLine -match 'AWatch-rus' -and + `$_.CommandLine -match '\.ps1' + } + + return @(`$processes).Count +} + +function New-LaunchLock { + param([string]`$StateRoot, [int]`$SessionId) + + `$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId) + 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 + sessionId = `$SessionId + createdAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath `$lockPath -Value `$payload -Encoding UTF8 + return `$lockPath +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = `$true)][string]`$Uri, + [Parameter(Mandatory = `$true)][string]`$Json + ) + + `$httpClient = New-Object System.Net.Http.HttpClient + try { + `$content = New-Object System.Net.Http.StringContent(`$Json, [System.Text.Encoding]::UTF8, 'application/json') + `$response = `$httpClient.PostAsync(`$Uri, `$content).Result + if (-not `$response.IsSuccessStatusCode) { + return `$false + } + return `$true + } + catch { + return `$false + } + finally { + `$httpClient.Dispose() + } +} + +function Ensure-Bucket { + param( + [string]`$BucketId, + [string]`$ClientName, + [string]`$BucketType + ) + + if (`$script:KnownBuckets.ContainsKey(`$BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + `$script:KnownBuckets[`$BucketId] = `$true + return + } + catch { + } + + `$body = @{ + client = `$ClientName + type = `$BucketType + hostname = `$script:Hostname + } | ConvertTo-Json -Compress + + try { + if (-not (Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body)) { + return + } + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + } + catch { + return + } + } + + `$script:KnownBuckets[`$BucketId] = `$true +} + +function Send-LogonMarkerIfNeeded { + param( + [pscustomobject]`$Config, + [int]`$SessionId + ) + + `$sessionEvents = if (`$Config.PSObject.Properties.Name -contains 'sessionEvents') { `$Config.sessionEvents } else { `$null } + `$logging = if (`$Config.PSObject.Properties.Name -contains 'logging') { `$Config.logging } else { `$null } + `$logonEnabled = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]`$sessionEvents.logonEnabled } else { `$false } + if (-not `$logonEnabled) { + return + } + + `$bucketPrefix = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]`$sessionEvents.bucketPrefix)) { + [string]`$sessionEvents.bucketPrefix + } + else { + 'aw-session-events' + } + + `$stateRoot = [string]`$Config.paths.stateRoot + `$markerRoots = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) { + `$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers')) + } + if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) { + `$markerRoots.Add((Join-Path `$stateRoot 'markers')) + } + + `$markerDir = `$null + foreach (`$candidate in `$markerRoots) { + try { + if (-not (Test-Path -LiteralPath `$candidate)) { + New-Item -Path `$candidate -ItemType Directory -Force | Out-Null + } + + `$probePath = Join-Path `$candidate 'write-test.tmp' + Set-Content -LiteralPath `$probePath -Value 'ok' -Encoding ASCII + Remove-Item -LiteralPath `$probePath -Force -ErrorAction SilentlyContinue + `$markerDir = `$candidate + break + } + catch { + } + } + + if (-not `$markerDir) { + return + } + + `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId) + if (Test-Path -LiteralPath `$markerFile) { + return + } + + Set-Content -LiteralPath `$markerFile -Value ((Get-Date).ToUniversalTime().ToString('o')) -Encoding UTF8 + + `$bucketId = ('{0}_{1}' -f `$bucketPrefix, `$script:Hostname) + Ensure-Bucket -BucketId `$bucketId -ClientName 'aw-session-events' -BucketType 'aw.session.event' + + `$payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + eventType = 'logon' + username = `$env:USERNAME + userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)" + sessionId = `$SessionId + hostname = `$script:Hostname + source = 'launch-watchers-awatch-rus' + } + } | ConvertTo-Json -Depth 5 -Compress + + try { + Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$bucketId/heartbeat?pulsetime=1" -Json `$payload + } + catch { + Remove-Item -LiteralPath `$markerFile -Force -ErrorAction SilentlyContinue + throw + } +} + +function Start-CollectorScriptIfNeeded { + param( + [string]`$ScriptPath, + [string]`$ConfigPath, + [string]`$PowerShellExe, + [int]`$SessionId + ) + + if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + return + } + + if (-not (Test-Path -LiteralPath `$ScriptPath)) { + return + } + + if (Test-CollectorRunning -ScriptPath `$ScriptPath -SessionId `$SessionId) { + return + } + + if ((Get-CollectorPowerShellProcessCount) -ge `$script:MaxCollectorPowerShellProcesses) { + return + } + + `$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null } + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass') + if (`$staParam) { `$argumentList += `$staParam } + `$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden +} + +`$config = Get-DeploymentConfig -Path `$ConfigPath +`$sessionId = (Get-Process -Id `$PID).SessionId +`$installRoot = [string]`$config.paths.installRoot +`$stateRoot = [string]`$config.paths.stateRoot +`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port +`$script:Hostname = `$env:COMPUTERNAME +`$script:KnownBuckets = @{} +`$collectorScript = [string]`$config.paths.collectorScript +`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' } +`$fileCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]`$config.paths.fileCollectorScript } else { Join-Path `$stateRoot 'file-operations-collector.ps1' } +`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' } +`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe' +`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe' +`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port) +`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +`$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 } +`$fileOpsEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]`$config.collectors.fileOpsEnabled } else { `$true } +`$emailEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'emailEnabled') { [bool]`$config.collectors.emailEnabled } else { `$false } +`$emailCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'emailCollectorScript') { [string]`$config.paths.emailCollectorScript } else { Join-Path `$stateRoot 'email-outbound-collector.ps1' } +`$launchLockPath = New-LaunchLock -StateRoot `$stateRoot -SessionId `$sessionId +if (-not `$launchLockPath) { + return +} + +try { + if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) { + throw "Не найден aw-watcher-afk.exe: `$afkExe" + } + + if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) { + throw "Не найден aw-watcher-window.exe: `$windowExe" + } + + if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) { + Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + if (`$windowEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) { + Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + try { + Send-LogonMarkerIfNeeded -Config `$config -SessionId `$sessionId + } + catch { + } + Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$fileOpsEnabled) { + Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } + Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$emailEnabled -and (Test-Path -LiteralPath `$emailCollectorScript)) { + Start-CollectorScriptIfNeeded -ScriptPath `$emailCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } +} +finally { + if (`$launchLockPath -and (Test-Path -LiteralPath `$launchLockPath)) { + Remove-Item -LiteralPath `$launchLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Write-ActivityWatchRecoveryScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Continue' + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Get-RecoveryConfigPaths { + param([string]`$PrimaryConfigPath) + + `$paths = New-Object System.Collections.Generic.List[string] + if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) { + `$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path) + } + + `$searchRoot = `$env:ProgramData + if (`$PrimaryConfigPath) { + `$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent + `$candidateRoot = Split-Path -Path `$stateRoot -Parent + if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) { + `$searchRoot = `$candidateRoot + } + } + + if (Test-Path -LiteralPath `$searchRoot) { + Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { `$_.Name -like 'ActivityWatch*' } | + ForEach-Object { + `$candidate = Join-Path `$_.FullName 'deployment-config.json' + if (Test-Path -LiteralPath `$candidate) { + `$paths.Add(`$candidate) + } + } + } + + return @(`$paths | Sort-Object -Unique) +} + +function Get-RecoveryTaskNames { + param([string[]]`$ConfigPaths) + + `$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach (`$candidatePath in @(`$ConfigPaths)) { + try { + `$config = Get-DeploymentConfig -Path `$candidatePath + foreach (`$task in @(`$config.userTasks)) { + `$taskName = [string]`$task.launchTaskName + if (-not [string]::IsNullOrWhiteSpace(`$taskName)) { + [void]`$taskNames.Add(`$taskName) + } + } + } + catch { + } + } + + return @(`$taskNames) +} + +function New-RecoveryLock { + param([string]`$PrimaryConfigPath) + + `$stateRoot = if (`$PrimaryConfigPath) { Split-Path -Path `$PrimaryConfigPath -Parent } else { Join-Path `$env:ProgramData 'AWatch-rus' } + if (-not (Test-Path -LiteralPath `$stateRoot)) { + New-Item -Path `$stateRoot -ItemType Directory -Force | Out-Null + } + + `$lockPath = Join-Path `$stateRoot 'recovery-loop.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 Start-TaskIfNotRunning { + param([string]`$TaskName) + if ([string]::IsNullOrWhiteSpace(`$TaskName)) { + return + } + + try { + `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + if (-not `$task) { + return + } + if ([string]`$task.State -eq 'Running') { + return + } + Start-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + } + catch { + } +} + +`$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath +if (-not `$recoveryLockPath) { + return +} + +try { + while (`$true) { + `$sleepSeconds = 180 + try { + `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath + foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { + Start-TaskIfNotRunning -TaskName `$taskName + } + + `$config = Get-DeploymentConfig -Path `$ConfigPath + if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { + `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) + } + } + catch { + } + + Start-Sleep -Seconds `$sleepSeconds + } +} +finally { + if (`$recoveryLockPath -and (Test-Path -LiteralPath `$recoveryLockPath)) { + Remove-Item -LiteralPath `$recoveryLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Get-ActivityWatchHiddenLauncherPath { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath + ) + + $directory = Split-Path -Path $ScriptPath -Parent + $baseName = [IO.Path]::GetFileNameWithoutExtension($ScriptPath) + return Join-Path $directory ("{0}-hidden.vbs" -f $baseName) +} + +function Write-ActivityWatchHiddenPowerShellWrapper { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $escapedPowerShellExe = $powershellExe.Replace('"', '""') + $escapedScriptPath = $ScriptPath.Replace('"', '""') + $escapedConfigPath = $ConfigPath.Replace('"', '""') + + $content = @" +Set shell = CreateObject("WScript.Shell") +shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding ASCII +} + +function Remove-LegacyActivityWatchEntries { + $legacyTaskNames = @( + 'ActivityWatch Watchers', + 'ActivityWatch Guard', + 'ActivityWatch Heal' + ) + + foreach ($taskName in $legacyTaskNames) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + } + + $runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' + foreach ($name in 'ActivityWatchAFK', 'ActivityWatchWindow', 'ActivityWatchBrowserCollector') { + Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue + } +} + +function Remove-ActivityWatchScheduledTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName + ) + + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + & cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null + + for ($attempt = 0; $attempt -lt 10; $attempt++) { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + return + } + + Start-Sleep -Milliseconds 300 + } +} + +function Set-ActivityWatchScheduledTaskAction { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$Execute, + [Parameter(Mandatory = $true)] + [string]$Arguments + ) + + $taskCommand = ('"{0}" {1}' -f $Execute, $Arguments) + & schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "schtasks.exe /Change завершился с ошибкой для $TaskName" + } +} + +function Get-ActivityWatchScheduledTaskByCommand { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [string]$CommandMatch + ) + + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($task) { + return $task + } + + if ([string]::IsNullOrWhiteSpace($CommandMatch)) { + return $null + } + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$CommandMatch*") { + return $candidate + } + } + } + + return $null +} + +function Remove-StaleActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath + ) + + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + $desiredTaskNames = @($TaskDefinitions | ForEach-Object { [string]$_.LaunchTaskName }) + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + $taskName = [string]$candidate.TaskName + if ($desiredTaskNames -contains $taskName) { + continue + } + + $usesCurrentLauncher = $false + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$launcherPath*") { + $usesCurrentLauncher = $true + break + } + } + + if ($usesCurrentLauncher) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + } + } +} + +function Register-ActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath + Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath + + foreach ($definition in $TaskDefinitions) { + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId + $principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + $existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath + + if ($existingTask) { + Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments + continue + } + + Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName + Register-ScheduledTask -TaskName $definition.LaunchTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null + } +} + +function Register-ActivityWatchRecoveryTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + Remove-ActivityWatchScheduledTask -TaskName $TaskName + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null +} + +function Set-ActivityWatchAcl { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot + ) + + foreach ($path in $InstallRoot, $StateRoot, $LogsRoot) { + New-ActivityWatchDirectory -Path $path + } + + & icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $InstallRoot" + } + + & icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $StateRoot" + } + + & icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $LogsRoot" + } +} + +function Start-ActivityWatchTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [string]$RecoveryTaskName = 'ActivityWatch Recovery' + ) + + foreach ($definition in $TaskDefinitions) { + Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue + } + + Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue +} + +Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries +; } + } + + `$payload = @{ + pid = `$PID + createdAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath `$lockPath -Value `$payload -Encoding UTF8 + return `$lockPath +} + +function Start-TaskIfNotRunning { + param([string]`$TaskName) + if ([string]::IsNullOrWhiteSpace(`$TaskName)) { + return + } + + try { + `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + if (-not `$task) { + return + } + if ([string]`$task.State -eq 'Running') { + return + } + Start-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + } + catch { Write-Error Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Запустите этот скрипт из PowerShell с правами администратора.' + } +} + +function New-ActivityWatchDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Enable-ActivityWatchPrintTelemetry { + $policyPath = 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' + if (-not (Test-Path -LiteralPath $policyPath)) { + New-Item -Path $policyPath -Force | Out-Null + } + New-ItemProperty -Path $policyPath -Name 'ShowJobTitleInEventLogs' -Value 1 -PropertyType DWord -Force | Out-Null + + & wevtutil.exe sl 'Microsoft-Windows-PrintService/Operational' /e:true | Out-Null +} + + +function Get-ActivityWatchPackageUrl { + param( + [string]$Version = 'v0.13.2' + ) + + return "https://github.com/ActivityWatch/activitywatch/releases/download/$Version/activitywatch-$Version-windows-x86_64.zip" +} + +function Get-ActivityWatchArchive { + param( + [string]$PackageZipPath, + [string]$PackageUrl, + [string]$Version = 'v0.13.2', + [Parameter(Mandatory = $true)] + [string]$WorkingRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + + if ($PackageZipPath) { + $resolved = Resolve-Path -LiteralPath $PackageZipPath -ErrorAction Stop + return $resolved.Path + } + + if (-not $PackageUrl) { + $PackageUrl = Get-ActivityWatchPackageUrl -Version $Version + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $suffix = ([guid]::NewGuid().Guid.Substring(0, 8)) + $archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix) + Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath + return $archivePath +} + +function Get-ActivityWatchPackageRoot { + param( + [Parameter(Mandatory = $true)] + [string]$ExpandedRoot + ) + + $afkBinary = Get-ChildItem -Path $ExpandedRoot -Filter 'aw-watcher-afk.exe' -File -Recurse | + Select-Object -First 1 + + if (-not $afkBinary) { + throw "Не удалось найти aw-watcher-afk.exe в $ExpandedRoot." + } + + return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent) +} + +function Install-ActivityWatchPackage { + param( + [Parameter(Mandatory = $true)] + [string]$ArchivePath, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$WorkingRoot, + [Parameter(Mandatory = $true)] + [string]$BackupRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + New-ActivityWatchDirectory -Path $BackupRoot + + # Ensure nothing is holding locks inside InstallRoot during upgrade. + foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) { + try { + Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } + catch { + } + } + Start-Sleep -Seconds 2 + + $extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid) + if (Test-Path -LiteralPath $extractRoot) { + Remove-Item -LiteralPath $extractRoot -Recurse -Force + } + New-ActivityWatchDirectory -Path $extractRoot + + Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force + $packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot + + if (Test-Path -LiteralPath $InstallRoot) { + $existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue + if ($existingItems) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupPath = Join-Path $BackupRoot ("install-$stamp") + New-ActivityWatchDirectory -Path $backupPath + Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force + Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force + } + } + else { + New-ActivityWatchDirectory -Path $InstallRoot + } + + Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force + + return [pscustomobject]@{ + PackageRoot = $packageRoot + ExtractRoot = $extractRoot + BackupRoot = $BackupRoot + } +} + +function Get-ActivityWatchExecutableMap { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot + ) + + $map = [ordered]@{ + Afk = Join-Path $InstallRoot 'aw-watcher-afk\aw-watcher-afk.exe' + Window = Join-Path $InstallRoot 'aw-watcher-window\aw-watcher-window.exe' + } + + foreach ($entry in $map.GetEnumerator()) { + if (-not (Test-Path -LiteralPath $entry.Value)) { + throw "Не найден обязательный исполняемый файл ActivityWatch: $($entry.Value)" + } + } + + return [pscustomobject]$map +} + +function Normalize-ActivityWatchUsers { + param( + [string[]]$Users, + [string]$UserListPath, + [string]$Domain + ) + + $collected = New-Object System.Collections.Generic.List[string] + + if ($Users) { + foreach ($user in $Users) { + if (-not [string]::IsNullOrWhiteSpace($user)) { + $collected.Add($user.Trim()) + } + } + } + + if ($UserListPath) { + $resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop + $extension = [IO.Path]::GetExtension($resolved.Path) + if ($extension -ieq '.csv') { + $rows = Import-Csv -LiteralPath $resolved.Path + foreach ($row in $rows) { + foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') { + if ($row.PSObject.Properties.Name -contains $column) { + $value = [string]$row.$column + if (-not [string]::IsNullOrWhiteSpace($value)) { + $collected.Add($value.Trim()) + break + } + } + } + } + } + else { + Get-Content -LiteralPath $resolved.Path | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith('#')) { + $collected.Add($line) + } + } + } + } + + $normalized = $collected | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { + if ($Domain -and ($_ -notmatch '[\\@]')) { + '{0}\{1}' -f $Domain, $_ + } + else { + $_ + } + } | + Sort-Object -Unique + + if (-not $normalized -or $normalized.Count -eq 0) { + throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.' + } + + return @($normalized) +} + +function Get-ActivityWatchTaskNameToken { + param( + [Parameter(Mandatory = $true)] + [string]$UserId + ) + + $buffer = [Text.StringBuilder]::new() + foreach ($character in $UserId.ToCharArray()) { + if ([char]::IsLetterOrDigit($character)) { + [void]$buffer.Append($character) + } + else { + [void]$buffer.Append('_') + } + } + + return $buffer.ToString().Trim('_') +} + +function New-ActivityWatchUserTaskDefinitions { + param( + [Parameter(Mandatory = $true)] + [string[]]$Users + ) + + $result = foreach ($user in $Users) { + $token = Get-ActivityWatchTaskNameToken -UserId $user + [pscustomobject]@{ + UserId = $user + LaunchTaskName = "ActivityWatch Launch [$token]" + } + } + + return @($result) +} + +function Copy-ActivityWatchCollectorAssets { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScriptSource, + [string]$EmailCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$ExampleRulesSource, + [Parameter(Mandatory = $true)] + [string]$ExamplePolicySource, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [string]$CustomRulesSource, + [string]$CustomPolicySource + ) + + New-ActivityWatchDirectory -Path $StateRoot + + $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' + $endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1' + $fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1' + $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' + $emailCollectorTarget = Join-Path $StateRoot 'email-outbound-collector.ps1' + $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' + $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' + $examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json' + $policyTarget = Join-Path $StateRoot 'dlp-policy.json' + + Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force + Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force + Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force + if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) { + Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force + } + Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force + Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force + + if ($CustomRulesSource) { + $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force + } + else { + Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force + } + + if ($CustomPolicySource) { + $resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedPolicy.Path -Destination $policyTarget -Force + } + else { + Copy-Item -LiteralPath $examplePolicyTarget -Destination $policyTarget -Force + } + + return [pscustomobject]@{ + CollectorScript = $collectorTarget + EndpointCollectorScript = $endpointCollectorTarget + FileCollectorScript = $fileCollectorTarget + SessionCollectorScript = $sessionCollectorTarget + EmailCollectorScript = $emailCollectorTarget + ExampleRules = $exampleRulesTarget + ActiveRules = $rulesTarget + ExamplePolicy = $examplePolicyTarget + ActivePolicy = $policyTarget + } +} + +function New-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$ServerHost, + [Parameter(Mandatory = $true)] + [int]$ServerPort, + [Parameter(Mandatory = $true)] + [string]$ServerScheme, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot, + [Parameter(Mandatory = $true)] + [string]$CollectorScript, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScript, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScript, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScript, + [string]$EmailCollectorScript, + [Parameter(Mandatory = $true)] + [string]$RulesPath, + [Parameter(Mandatory = $true)] + [string]$PolicyPath, + [Parameter(Mandatory = $true)] + [int]$PollSeconds, + [Parameter(Mandatory = $true)] + [int]$PulseSeconds, + [Parameter(Mandatory = $true)] + [int]$RecoveryIntervalSeconds, + [bool]$AfkEnabled = $true, + [bool]$WindowEnabled = $true, + [bool]$FileOpsEnabled = $true, + [bool]$LocalAgentLogsEnabled = $true, + [bool]$IncidentCaptureEnabled = $true, + [bool]$IncidentScreenshotEnabled = $true, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled = $true, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [pscustomobject[]]$UserTasks, + [string]$PackageVersion = 'v0.13.2' + ) + + $effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' } + + return [pscustomobject]@{ + version = 1 + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + server = [pscustomobject]@{ + host = $ServerHost + port = $ServerPort + scheme = $ServerScheme + } + paths = [pscustomobject]@{ + installRoot = $InstallRoot + stateRoot = $StateRoot + logsRoot = $LogsRoot + collectorScript = $CollectorScript + endpointCollectorScript = $EndpointCollectorScript + emailCollectorScript = $EmailCollectorScript + fileCollectorScript = $FileCollectorScript + sessionCollectorScript = $SessionCollectorScript + rulesPath = $RulesPath + policyPath = $PolicyPath + launchScript = $LaunchScriptPath + recoveryScript = $RecoveryScriptPath + } + collector = [pscustomobject]@{ + pollSeconds = $PollSeconds + pulseSeconds = $PulseSeconds + } + collectors = [pscustomobject]@{ + afkEnabled = $AfkEnabled + windowEnabled = $WindowEnabled + fileOpsEnabled = $FileOpsEnabled + emailEnabled = ($null -ne $EmailCollectorScript -and $EmailCollectorScript -ne '') + } + logging = [pscustomobject]@{ + localAgentLogsEnabled = $LocalAgentLogsEnabled + } + incidentCapture = [pscustomobject]@{ + enabled = $IncidentCaptureEnabled + screenshotEnabled = $IncidentScreenshotEnabled + artifactsRoot = $effectiveIncidentArtifactsRoot + } + sessionEvents = [pscustomobject]@{ + logonEnabled = $LogonMarkerEnabled + bucketPrefix = 'aw-session-events' + } + recovery = [pscustomobject]@{ + intervalSeconds = $RecoveryIntervalSeconds + taskName = 'ActivityWatch Recovery' + } + dlp = [pscustomobject]@{ + incidentBucketPrefix = 'aw-dlp-incidents' + enabled = $true + } + package = [pscustomobject]@{ + version = $PackageVersion + } + userTasks = @($UserTasks) + } +} + +function Write-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $json = $Config | ConvertTo-Json -Depth 8 + Set-Content -LiteralPath $Path -Value $json -Encoding UTF8 +} + +function Read-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация развёртывания не найдена: $Path" + } + + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Write-ActivityWatchLaunchScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Stop' + +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http +`$script:MaxCollectorPowerShellProcesses = 24 + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Test-ProcessInSession { + param( + [string]`$Name, + [int]`$SessionId + ) + + return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1) +} + +function Test-CollectorRunning { + param( + [string]`$ScriptPath, + [int]`$SessionId + ) + + `$escapedCollector = [Regex]::Escape(`$ScriptPath) + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.SessionId -eq `$SessionId -and + `$_.CommandLine -match `$escapedCollector + } + + return [bool](`$processes | Select-Object -First 1) +} + +function Get-CollectorPowerShellProcessCount { + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.CommandLine -match 'AWatch-rus' -and + `$_.CommandLine -match '\.ps1' + } + + return @(`$processes).Count +} + +function New-LaunchLock { + param([string]`$StateRoot, [int]`$SessionId) + + `$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId) + 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 + sessionId = `$SessionId + createdAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath `$lockPath -Value `$payload -Encoding UTF8 + return `$lockPath +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = `$true)][string]`$Uri, + [Parameter(Mandatory = `$true)][string]`$Json + ) + + `$httpClient = New-Object System.Net.Http.HttpClient + try { + `$content = New-Object System.Net.Http.StringContent(`$Json, [System.Text.Encoding]::UTF8, 'application/json') + `$response = `$httpClient.PostAsync(`$Uri, `$content).Result + if (-not `$response.IsSuccessStatusCode) { + return `$false + } + return `$true + } + catch { + return `$false + } + finally { + `$httpClient.Dispose() + } +} + +function Ensure-Bucket { + param( + [string]`$BucketId, + [string]`$ClientName, + [string]`$BucketType + ) + + if (`$script:KnownBuckets.ContainsKey(`$BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + `$script:KnownBuckets[`$BucketId] = `$true + return + } + catch { + } + + `$body = @{ + client = `$ClientName + type = `$BucketType + hostname = `$script:Hostname + } | ConvertTo-Json -Compress + + try { + if (-not (Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body)) { + return + } + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + } + catch { + return + } + } + + `$script:KnownBuckets[`$BucketId] = `$true +} + +function Send-LogonMarkerIfNeeded { + param( + [pscustomobject]`$Config, + [int]`$SessionId + ) + + `$sessionEvents = if (`$Config.PSObject.Properties.Name -contains 'sessionEvents') { `$Config.sessionEvents } else { `$null } + `$logging = if (`$Config.PSObject.Properties.Name -contains 'logging') { `$Config.logging } else { `$null } + `$logonEnabled = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]`$sessionEvents.logonEnabled } else { `$false } + if (-not `$logonEnabled) { + return + } + + `$bucketPrefix = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]`$sessionEvents.bucketPrefix)) { + [string]`$sessionEvents.bucketPrefix + } + else { + 'aw-session-events' + } + + `$stateRoot = [string]`$Config.paths.stateRoot + `$markerRoots = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) { + `$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers')) + } + if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) { + `$markerRoots.Add((Join-Path `$stateRoot 'markers')) + } + + `$markerDir = `$null + foreach (`$candidate in `$markerRoots) { + try { + if (-not (Test-Path -LiteralPath `$candidate)) { + New-Item -Path `$candidate -ItemType Directory -Force | Out-Null + } + + `$probePath = Join-Path `$candidate 'write-test.tmp' + Set-Content -LiteralPath `$probePath -Value 'ok' -Encoding ASCII + Remove-Item -LiteralPath `$probePath -Force -ErrorAction SilentlyContinue + `$markerDir = `$candidate + break + } + catch { + } + } + + if (-not `$markerDir) { + return + } + + `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId) + if (Test-Path -LiteralPath `$markerFile) { + return + } + + Set-Content -LiteralPath `$markerFile -Value ((Get-Date).ToUniversalTime().ToString('o')) -Encoding UTF8 + + `$bucketId = ('{0}_{1}' -f `$bucketPrefix, `$script:Hostname) + Ensure-Bucket -BucketId `$bucketId -ClientName 'aw-session-events' -BucketType 'aw.session.event' + + `$payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + eventType = 'logon' + username = `$env:USERNAME + userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)" + sessionId = `$SessionId + hostname = `$script:Hostname + source = 'launch-watchers-awatch-rus' + } + } | ConvertTo-Json -Depth 5 -Compress + + try { + Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$bucketId/heartbeat?pulsetime=1" -Json `$payload + } + catch { + Remove-Item -LiteralPath `$markerFile -Force -ErrorAction SilentlyContinue + throw + } +} + +function Start-CollectorScriptIfNeeded { + param( + [string]`$ScriptPath, + [string]`$ConfigPath, + [string]`$PowerShellExe, + [int]`$SessionId + ) + + if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + return + } + + if (-not (Test-Path -LiteralPath `$ScriptPath)) { + return + } + + if (Test-CollectorRunning -ScriptPath `$ScriptPath -SessionId `$SessionId) { + return + } + + if ((Get-CollectorPowerShellProcessCount) -ge `$script:MaxCollectorPowerShellProcesses) { + return + } + + `$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null } + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass') + if (`$staParam) { `$argumentList += `$staParam } + `$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden +} + +`$config = Get-DeploymentConfig -Path `$ConfigPath +`$sessionId = (Get-Process -Id `$PID).SessionId +`$installRoot = [string]`$config.paths.installRoot +`$stateRoot = [string]`$config.paths.stateRoot +`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port +`$script:Hostname = `$env:COMPUTERNAME +`$script:KnownBuckets = @{} +`$collectorScript = [string]`$config.paths.collectorScript +`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' } +`$fileCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]`$config.paths.fileCollectorScript } else { Join-Path `$stateRoot 'file-operations-collector.ps1' } +`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' } +`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe' +`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe' +`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port) +`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +`$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 } +`$fileOpsEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]`$config.collectors.fileOpsEnabled } else { `$true } +`$emailEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'emailEnabled') { [bool]`$config.collectors.emailEnabled } else { `$false } +`$emailCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'emailCollectorScript') { [string]`$config.paths.emailCollectorScript } else { Join-Path `$stateRoot 'email-outbound-collector.ps1' } +`$launchLockPath = New-LaunchLock -StateRoot `$stateRoot -SessionId `$sessionId +if (-not `$launchLockPath) { + return +} + +try { + if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) { + throw "Не найден aw-watcher-afk.exe: `$afkExe" + } + + if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) { + throw "Не найден aw-watcher-window.exe: `$windowExe" + } + + if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) { + Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + if (`$windowEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) { + Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + try { + Send-LogonMarkerIfNeeded -Config `$config -SessionId `$sessionId + } + catch { + } + Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$fileOpsEnabled) { + Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } + Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$emailEnabled -and (Test-Path -LiteralPath `$emailCollectorScript)) { + Start-CollectorScriptIfNeeded -ScriptPath `$emailCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } +} +finally { + if (`$launchLockPath -and (Test-Path -LiteralPath `$launchLockPath)) { + Remove-Item -LiteralPath `$launchLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Write-ActivityWatchRecoveryScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Continue' + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Get-RecoveryConfigPaths { + param([string]`$PrimaryConfigPath) + + `$paths = New-Object System.Collections.Generic.List[string] + if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) { + `$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path) + } + + `$searchRoot = `$env:ProgramData + if (`$PrimaryConfigPath) { + `$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent + `$candidateRoot = Split-Path -Path `$stateRoot -Parent + if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) { + `$searchRoot = `$candidateRoot + } + } + + if (Test-Path -LiteralPath `$searchRoot) { + Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { `$_.Name -like 'ActivityWatch*' } | + ForEach-Object { + `$candidate = Join-Path `$_.FullName 'deployment-config.json' + if (Test-Path -LiteralPath `$candidate) { + `$paths.Add(`$candidate) + } + } + } + + return @(`$paths | Sort-Object -Unique) +} + +function Get-RecoveryTaskNames { + param([string[]]`$ConfigPaths) + + `$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach (`$candidatePath in @(`$ConfigPaths)) { + try { + `$config = Get-DeploymentConfig -Path `$candidatePath + foreach (`$task in @(`$config.userTasks)) { + `$taskName = [string]`$task.launchTaskName + if (-not [string]::IsNullOrWhiteSpace(`$taskName)) { + [void]`$taskNames.Add(`$taskName) + } + } + } + catch { + } + } + + return @(`$taskNames) +} + +function New-RecoveryLock { + param([string]`$PrimaryConfigPath) + + `$stateRoot = if (`$PrimaryConfigPath) { Split-Path -Path `$PrimaryConfigPath -Parent } else { Join-Path `$env:ProgramData 'AWatch-rus' } + if (-not (Test-Path -LiteralPath `$stateRoot)) { + New-Item -Path `$stateRoot -ItemType Directory -Force | Out-Null + } + + `$lockPath = Join-Path `$stateRoot 'recovery-loop.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 Start-TaskIfNotRunning { + param([string]`$TaskName) + if ([string]::IsNullOrWhiteSpace(`$TaskName)) { + return + } + + try { + `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + if (-not `$task) { + return + } + if ([string]`$task.State -eq 'Running') { + return + } + Start-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + } + catch { + } +} + +`$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath +if (-not `$recoveryLockPath) { + return +} + +try { + while (`$true) { + `$sleepSeconds = 180 + try { + `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath + foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { + Start-TaskIfNotRunning -TaskName `$taskName + } + + `$config = Get-DeploymentConfig -Path `$ConfigPath + if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { + `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) + } + } + catch { + } + + Start-Sleep -Seconds `$sleepSeconds + } +} +finally { + if (`$recoveryLockPath -and (Test-Path -LiteralPath `$recoveryLockPath)) { + Remove-Item -LiteralPath `$recoveryLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Get-ActivityWatchHiddenLauncherPath { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath + ) + + $directory = Split-Path -Path $ScriptPath -Parent + $baseName = [IO.Path]::GetFileNameWithoutExtension($ScriptPath) + return Join-Path $directory ("{0}-hidden.vbs" -f $baseName) +} + +function Write-ActivityWatchHiddenPowerShellWrapper { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $escapedPowerShellExe = $powershellExe.Replace('"', '""') + $escapedScriptPath = $ScriptPath.Replace('"', '""') + $escapedConfigPath = $ConfigPath.Replace('"', '""') + + $content = @" +Set shell = CreateObject("WScript.Shell") +shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding ASCII +} + +function Remove-LegacyActivityWatchEntries { + $legacyTaskNames = @( + 'ActivityWatch Watchers', + 'ActivityWatch Guard', + 'ActivityWatch Heal' + ) + + foreach ($taskName in $legacyTaskNames) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + } + + $runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' + foreach ($name in 'ActivityWatchAFK', 'ActivityWatchWindow', 'ActivityWatchBrowserCollector') { + Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue + } +} + +function Remove-ActivityWatchScheduledTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName + ) + + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + & cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null + + for ($attempt = 0; $attempt -lt 10; $attempt++) { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + return + } + + Start-Sleep -Milliseconds 300 + } +} + +function Set-ActivityWatchScheduledTaskAction { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$Execute, + [Parameter(Mandatory = $true)] + [string]$Arguments + ) + + $taskCommand = ('"{0}" {1}' -f $Execute, $Arguments) + & schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "schtasks.exe /Change завершился с ошибкой для $TaskName" + } +} + +function Get-ActivityWatchScheduledTaskByCommand { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [string]$CommandMatch + ) + + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($task) { + return $task + } + + if ([string]::IsNullOrWhiteSpace($CommandMatch)) { + return $null + } + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$CommandMatch*") { + return $candidate + } + } + } + + return $null +} + +function Remove-StaleActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath + ) + + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + $desiredTaskNames = @($TaskDefinitions | ForEach-Object { [string]$_.LaunchTaskName }) + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + $taskName = [string]$candidate.TaskName + if ($desiredTaskNames -contains $taskName) { + continue + } + + $usesCurrentLauncher = $false + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$launcherPath*") { + $usesCurrentLauncher = $true + break + } + } + + if ($usesCurrentLauncher) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + } + } +} + +function Register-ActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath + Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath + + foreach ($definition in $TaskDefinitions) { + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId + $principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + $existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath + + if ($existingTask) { + Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments + continue + } + + Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName + Register-ScheduledTask -TaskName $definition.LaunchTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null + } +} + +function Register-ActivityWatchRecoveryTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + Remove-ActivityWatchScheduledTask -TaskName $TaskName + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null +} + +function Set-ActivityWatchAcl { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot + ) + + foreach ($path in $InstallRoot, $StateRoot, $LogsRoot) { + New-ActivityWatchDirectory -Path $path + } + + & icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $InstallRoot" + } + + & icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $StateRoot" + } + + & icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $LogsRoot" + } +} + +function Start-ActivityWatchTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [string]$RecoveryTaskName = 'ActivityWatch Recovery' + ) + + foreach ($definition in $TaskDefinitions) { + Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue + } + + Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue +} + +Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries +; } +} + +`$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath +if (-not `$recoveryLockPath) { + return +} + +try { + while (`$true) { + `$sleepSeconds = 180 + try { + `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath + foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { + Start-TaskIfNotRunning -TaskName `$taskName + } + + `$config = Get-DeploymentConfig -Path `$ConfigPath + if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { + `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) + } + } + catch { Write-Error Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Запустите этот скрипт из PowerShell с правами администратора.' + } +} + +function New-ActivityWatchDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Enable-ActivityWatchPrintTelemetry { + $policyPath = 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' + if (-not (Test-Path -LiteralPath $policyPath)) { + New-Item -Path $policyPath -Force | Out-Null + } + New-ItemProperty -Path $policyPath -Name 'ShowJobTitleInEventLogs' -Value 1 -PropertyType DWord -Force | Out-Null + + & wevtutil.exe sl 'Microsoft-Windows-PrintService/Operational' /e:true | Out-Null +} + + +function Get-ActivityWatchPackageUrl { + param( + [string]$Version = 'v0.13.2' + ) + + return "https://github.com/ActivityWatch/activitywatch/releases/download/$Version/activitywatch-$Version-windows-x86_64.zip" +} + +function Get-ActivityWatchArchive { + param( + [string]$PackageZipPath, + [string]$PackageUrl, + [string]$Version = 'v0.13.2', + [Parameter(Mandatory = $true)] + [string]$WorkingRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + + if ($PackageZipPath) { + $resolved = Resolve-Path -LiteralPath $PackageZipPath -ErrorAction Stop + return $resolved.Path + } + + if (-not $PackageUrl) { + $PackageUrl = Get-ActivityWatchPackageUrl -Version $Version + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $suffix = ([guid]::NewGuid().Guid.Substring(0, 8)) + $archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix) + Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath + return $archivePath +} + +function Get-ActivityWatchPackageRoot { + param( + [Parameter(Mandatory = $true)] + [string]$ExpandedRoot + ) + + $afkBinary = Get-ChildItem -Path $ExpandedRoot -Filter 'aw-watcher-afk.exe' -File -Recurse | + Select-Object -First 1 + + if (-not $afkBinary) { + throw "Не удалось найти aw-watcher-afk.exe в $ExpandedRoot." + } + + return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent) +} + +function Install-ActivityWatchPackage { + param( + [Parameter(Mandatory = $true)] + [string]$ArchivePath, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$WorkingRoot, + [Parameter(Mandatory = $true)] + [string]$BackupRoot + ) + + New-ActivityWatchDirectory -Path $WorkingRoot + New-ActivityWatchDirectory -Path $BackupRoot + + # Ensure nothing is holding locks inside InstallRoot during upgrade. + foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) { + try { + Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } + catch { + } + } + Start-Sleep -Seconds 2 + + $extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid) + if (Test-Path -LiteralPath $extractRoot) { + Remove-Item -LiteralPath $extractRoot -Recurse -Force + } + New-ActivityWatchDirectory -Path $extractRoot + + Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force + $packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot + + if (Test-Path -LiteralPath $InstallRoot) { + $existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue + if ($existingItems) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupPath = Join-Path $BackupRoot ("install-$stamp") + New-ActivityWatchDirectory -Path $backupPath + Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force + Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force + } + } + else { + New-ActivityWatchDirectory -Path $InstallRoot + } + + Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force + + return [pscustomobject]@{ + PackageRoot = $packageRoot + ExtractRoot = $extractRoot + BackupRoot = $BackupRoot + } +} + +function Get-ActivityWatchExecutableMap { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot + ) + + $map = [ordered]@{ + Afk = Join-Path $InstallRoot 'aw-watcher-afk\aw-watcher-afk.exe' + Window = Join-Path $InstallRoot 'aw-watcher-window\aw-watcher-window.exe' + } + + foreach ($entry in $map.GetEnumerator()) { + if (-not (Test-Path -LiteralPath $entry.Value)) { + throw "Не найден обязательный исполняемый файл ActivityWatch: $($entry.Value)" + } + } + + return [pscustomobject]$map +} + +function Normalize-ActivityWatchUsers { + param( + [string[]]$Users, + [string]$UserListPath, + [string]$Domain + ) + + $collected = New-Object System.Collections.Generic.List[string] + + if ($Users) { + foreach ($user in $Users) { + if (-not [string]::IsNullOrWhiteSpace($user)) { + $collected.Add($user.Trim()) + } + } + } + + if ($UserListPath) { + $resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop + $extension = [IO.Path]::GetExtension($resolved.Path) + if ($extension -ieq '.csv') { + $rows = Import-Csv -LiteralPath $resolved.Path + foreach ($row in $rows) { + foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') { + if ($row.PSObject.Properties.Name -contains $column) { + $value = [string]$row.$column + if (-not [string]::IsNullOrWhiteSpace($value)) { + $collected.Add($value.Trim()) + break + } + } + } + } + } + else { + Get-Content -LiteralPath $resolved.Path | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith('#')) { + $collected.Add($line) + } + } + } + } + + $normalized = $collected | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { + if ($Domain -and ($_ -notmatch '[\\@]')) { + '{0}\{1}' -f $Domain, $_ + } + else { + $_ + } + } | + Sort-Object -Unique + + if (-not $normalized -or $normalized.Count -eq 0) { + throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.' + } + + return @($normalized) +} + +function Get-ActivityWatchTaskNameToken { + param( + [Parameter(Mandatory = $true)] + [string]$UserId + ) + + $buffer = [Text.StringBuilder]::new() + foreach ($character in $UserId.ToCharArray()) { + if ([char]::IsLetterOrDigit($character)) { + [void]$buffer.Append($character) + } + else { + [void]$buffer.Append('_') + } + } + + return $buffer.ToString().Trim('_') +} + +function New-ActivityWatchUserTaskDefinitions { + param( + [Parameter(Mandatory = $true)] + [string[]]$Users + ) + + $result = foreach ($user in $Users) { + $token = Get-ActivityWatchTaskNameToken -UserId $user + [pscustomobject]@{ + UserId = $user + LaunchTaskName = "ActivityWatch Launch [$token]" + } + } + + return @($result) +} + +function Copy-ActivityWatchCollectorAssets { + param( + [Parameter(Mandatory = $true)] + [string]$CollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScriptSource, + [string]$EmailCollectorScriptSource, + [Parameter(Mandatory = $true)] + [string]$ExampleRulesSource, + [Parameter(Mandatory = $true)] + [string]$ExamplePolicySource, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [string]$CustomRulesSource, + [string]$CustomPolicySource + ) + + New-ActivityWatchDirectory -Path $StateRoot + + $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' + $endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1' + $fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1' + $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' + $emailCollectorTarget = Join-Path $StateRoot 'email-outbound-collector.ps1' + $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' + $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' + $examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json' + $policyTarget = Join-Path $StateRoot 'dlp-policy.json' + + Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force + Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force + Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force + Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force + if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) { + Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force + } + Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force + Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force + + if ($CustomRulesSource) { + $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force + } + else { + Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force + } + + if ($CustomPolicySource) { + $resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedPolicy.Path -Destination $policyTarget -Force + } + else { + Copy-Item -LiteralPath $examplePolicyTarget -Destination $policyTarget -Force + } + + return [pscustomobject]@{ + CollectorScript = $collectorTarget + EndpointCollectorScript = $endpointCollectorTarget + FileCollectorScript = $fileCollectorTarget + SessionCollectorScript = $sessionCollectorTarget + EmailCollectorScript = $emailCollectorTarget + ExampleRules = $exampleRulesTarget + ActiveRules = $rulesTarget + ExamplePolicy = $examplePolicyTarget + ActivePolicy = $policyTarget + } +} + +function New-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$ServerHost, + [Parameter(Mandatory = $true)] + [int]$ServerPort, + [Parameter(Mandatory = $true)] + [string]$ServerScheme, + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot, + [Parameter(Mandatory = $true)] + [string]$CollectorScript, + [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScript, + [Parameter(Mandatory = $true)] + [string]$FileCollectorScript, + [Parameter(Mandatory = $true)] + [string]$SessionCollectorScript, + [string]$EmailCollectorScript, + [Parameter(Mandatory = $true)] + [string]$RulesPath, + [Parameter(Mandatory = $true)] + [string]$PolicyPath, + [Parameter(Mandatory = $true)] + [int]$PollSeconds, + [Parameter(Mandatory = $true)] + [int]$PulseSeconds, + [Parameter(Mandatory = $true)] + [int]$RecoveryIntervalSeconds, + [bool]$AfkEnabled = $true, + [bool]$WindowEnabled = $true, + [bool]$FileOpsEnabled = $true, + [bool]$LocalAgentLogsEnabled = $true, + [bool]$IncidentCaptureEnabled = $true, + [bool]$IncidentScreenshotEnabled = $true, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled = $true, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [pscustomobject[]]$UserTasks, + [string]$PackageVersion = 'v0.13.2' + ) + + $effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' } + + return [pscustomobject]@{ + version = 1 + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + server = [pscustomobject]@{ + host = $ServerHost + port = $ServerPort + scheme = $ServerScheme + } + paths = [pscustomobject]@{ + installRoot = $InstallRoot + stateRoot = $StateRoot + logsRoot = $LogsRoot + collectorScript = $CollectorScript + endpointCollectorScript = $EndpointCollectorScript + emailCollectorScript = $EmailCollectorScript + fileCollectorScript = $FileCollectorScript + sessionCollectorScript = $SessionCollectorScript + rulesPath = $RulesPath + policyPath = $PolicyPath + launchScript = $LaunchScriptPath + recoveryScript = $RecoveryScriptPath + } + collector = [pscustomobject]@{ + pollSeconds = $PollSeconds + pulseSeconds = $PulseSeconds + } + collectors = [pscustomobject]@{ + afkEnabled = $AfkEnabled + windowEnabled = $WindowEnabled + fileOpsEnabled = $FileOpsEnabled + emailEnabled = ($null -ne $EmailCollectorScript -and $EmailCollectorScript -ne '') + } + logging = [pscustomobject]@{ + localAgentLogsEnabled = $LocalAgentLogsEnabled + } + incidentCapture = [pscustomobject]@{ + enabled = $IncidentCaptureEnabled + screenshotEnabled = $IncidentScreenshotEnabled + artifactsRoot = $effectiveIncidentArtifactsRoot + } + sessionEvents = [pscustomobject]@{ + logonEnabled = $LogonMarkerEnabled + bucketPrefix = 'aw-session-events' + } + recovery = [pscustomobject]@{ + intervalSeconds = $RecoveryIntervalSeconds + taskName = 'ActivityWatch Recovery' + } + dlp = [pscustomobject]@{ + incidentBucketPrefix = 'aw-dlp-incidents' + enabled = $true + } + package = [pscustomobject]@{ + version = $PackageVersion + } + userTasks = @($UserTasks) + } +} + +function Write-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $json = $Config | ConvertTo-Json -Depth 8 + Set-Content -LiteralPath $Path -Value $json -Encoding UTF8 +} + +function Read-ActivityWatchDeploymentConfig { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация развёртывания не найдена: $Path" + } + + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Write-ActivityWatchLaunchScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Stop' + +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http +`$script:MaxCollectorPowerShellProcesses = 24 + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Test-ProcessInSession { + param( + [string]`$Name, + [int]`$SessionId + ) + + return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1) +} + +function Test-CollectorRunning { + param( + [string]`$ScriptPath, + [int]`$SessionId + ) + + `$escapedCollector = [Regex]::Escape(`$ScriptPath) + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.SessionId -eq `$SessionId -and + `$_.CommandLine -match `$escapedCollector + } + + return [bool](`$processes | Select-Object -First 1) +} + +function Get-CollectorPowerShellProcessCount { + `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.CommandLine -match 'AWatch-rus' -and + `$_.CommandLine -match '\.ps1' + } + + return @(`$processes).Count +} + +function New-LaunchLock { + param([string]`$StateRoot, [int]`$SessionId) + + `$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId) + 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 + sessionId = `$SessionId + createdAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath `$lockPath -Value `$payload -Encoding UTF8 + return `$lockPath +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = `$true)][string]`$Uri, + [Parameter(Mandatory = `$true)][string]`$Json + ) + + `$httpClient = New-Object System.Net.Http.HttpClient + try { + `$content = New-Object System.Net.Http.StringContent(`$Json, [System.Text.Encoding]::UTF8, 'application/json') + `$response = `$httpClient.PostAsync(`$Uri, `$content).Result + if (-not `$response.IsSuccessStatusCode) { + return `$false + } + return `$true + } + catch { + return `$false + } + finally { + `$httpClient.Dispose() + } +} + +function Ensure-Bucket { + param( + [string]`$BucketId, + [string]`$ClientName, + [string]`$BucketType + ) + + if (`$script:KnownBuckets.ContainsKey(`$BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + `$script:KnownBuckets[`$BucketId] = `$true + return + } + catch { + } + + `$body = @{ + client = `$ClientName + type = `$BucketType + hostname = `$script:Hostname + } | ConvertTo-Json -Compress + + try { + if (-not (Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body)) { + return + } + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null + } + catch { + return + } + } + + `$script:KnownBuckets[`$BucketId] = `$true +} + +function Send-LogonMarkerIfNeeded { + param( + [pscustomobject]`$Config, + [int]`$SessionId + ) + + `$sessionEvents = if (`$Config.PSObject.Properties.Name -contains 'sessionEvents') { `$Config.sessionEvents } else { `$null } + `$logging = if (`$Config.PSObject.Properties.Name -contains 'logging') { `$Config.logging } else { `$null } + `$logonEnabled = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]`$sessionEvents.logonEnabled } else { `$false } + if (-not `$logonEnabled) { + return + } + + `$bucketPrefix = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]`$sessionEvents.bucketPrefix)) { + [string]`$sessionEvents.bucketPrefix + } + else { + 'aw-session-events' + } + + `$stateRoot = [string]`$Config.paths.stateRoot + `$markerRoots = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) { + `$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers')) + } + if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) { + `$markerRoots.Add((Join-Path `$stateRoot 'markers')) + } + + `$markerDir = `$null + foreach (`$candidate in `$markerRoots) { + try { + if (-not (Test-Path -LiteralPath `$candidate)) { + New-Item -Path `$candidate -ItemType Directory -Force | Out-Null + } + + `$probePath = Join-Path `$candidate 'write-test.tmp' + Set-Content -LiteralPath `$probePath -Value 'ok' -Encoding ASCII + Remove-Item -LiteralPath `$probePath -Force -ErrorAction SilentlyContinue + `$markerDir = `$candidate + break + } + catch { + } + } + + if (-not `$markerDir) { + return + } + + `$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId) + if (Test-Path -LiteralPath `$markerFile) { + return + } + + Set-Content -LiteralPath `$markerFile -Value ((Get-Date).ToUniversalTime().ToString('o')) -Encoding UTF8 + + `$bucketId = ('{0}_{1}' -f `$bucketPrefix, `$script:Hostname) + Ensure-Bucket -BucketId `$bucketId -ClientName 'aw-session-events' -BucketType 'aw.session.event' + + `$payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + eventType = 'logon' + username = `$env:USERNAME + userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)" + sessionId = `$SessionId + hostname = `$script:Hostname + source = 'launch-watchers-awatch-rus' + } + } | ConvertTo-Json -Depth 5 -Compress + + try { + Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$bucketId/heartbeat?pulsetime=1" -Json `$payload + } + catch { + Remove-Item -LiteralPath `$markerFile -Force -ErrorAction SilentlyContinue + throw + } +} + +function Start-CollectorScriptIfNeeded { + param( + [string]`$ScriptPath, + [string]`$ConfigPath, + [string]`$PowerShellExe, + [int]`$SessionId + ) + + if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + return + } + + if (-not (Test-Path -LiteralPath `$ScriptPath)) { + return + } + + if (Test-CollectorRunning -ScriptPath `$ScriptPath -SessionId `$SessionId) { + return + } + + if ((Get-CollectorPowerShellProcessCount) -ge `$script:MaxCollectorPowerShellProcesses) { + return + } + + `$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null } + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass') + if (`$staParam) { `$argumentList += `$staParam } + `$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden +} + +`$config = Get-DeploymentConfig -Path `$ConfigPath +`$sessionId = (Get-Process -Id `$PID).SessionId +`$installRoot = [string]`$config.paths.installRoot +`$stateRoot = [string]`$config.paths.stateRoot +`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port +`$script:Hostname = `$env:COMPUTERNAME +`$script:KnownBuckets = @{} +`$collectorScript = [string]`$config.paths.collectorScript +`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' } +`$fileCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]`$config.paths.fileCollectorScript } else { Join-Path `$stateRoot 'file-operations-collector.ps1' } +`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' } +`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe' +`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe' +`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port) +`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +`$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 } +`$fileOpsEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]`$config.collectors.fileOpsEnabled } else { `$true } +`$emailEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'emailEnabled') { [bool]`$config.collectors.emailEnabled } else { `$false } +`$emailCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'emailCollectorScript') { [string]`$config.paths.emailCollectorScript } else { Join-Path `$stateRoot 'email-outbound-collector.ps1' } +`$launchLockPath = New-LaunchLock -StateRoot `$stateRoot -SessionId `$sessionId +if (-not `$launchLockPath) { + return +} + +try { + if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) { + throw "Не найден aw-watcher-afk.exe: `$afkExe" + } + + if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) { + throw "Не найден aw-watcher-window.exe: `$windowExe" + } + + if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) { + Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + if (`$windowEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) { + Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden + } + + try { + Send-LogonMarkerIfNeeded -Config `$config -SessionId `$sessionId + } + catch { + } + Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$fileOpsEnabled) { + Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } + Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + if (`$emailEnabled -and (Test-Path -LiteralPath `$emailCollectorScript)) { + Start-CollectorScriptIfNeeded -ScriptPath `$emailCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId + } +} +finally { + if (`$launchLockPath -and (Test-Path -LiteralPath `$launchLockPath)) { + Remove-Item -LiteralPath `$launchLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Write-ActivityWatchRecoveryScript { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $content = @" +param( + [string]`$ConfigPath = '$ConfigPath' +) + +Set-StrictMode -Version Latest +`$ErrorActionPreference = 'Continue' + +function Get-DeploymentConfig { + param([string]`$Path) + return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json +} + +function Get-RecoveryConfigPaths { + param([string]`$PrimaryConfigPath) + + `$paths = New-Object System.Collections.Generic.List[string] + if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) { + `$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path) + } + + `$searchRoot = `$env:ProgramData + if (`$PrimaryConfigPath) { + `$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent + `$candidateRoot = Split-Path -Path `$stateRoot -Parent + if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) { + `$searchRoot = `$candidateRoot + } + } + + if (Test-Path -LiteralPath `$searchRoot) { + Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { `$_.Name -like 'ActivityWatch*' } | + ForEach-Object { + `$candidate = Join-Path `$_.FullName 'deployment-config.json' + if (Test-Path -LiteralPath `$candidate) { + `$paths.Add(`$candidate) + } + } + } + + return @(`$paths | Sort-Object -Unique) +} + +function Get-RecoveryTaskNames { + param([string[]]`$ConfigPaths) + + `$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach (`$candidatePath in @(`$ConfigPaths)) { + try { + `$config = Get-DeploymentConfig -Path `$candidatePath + foreach (`$task in @(`$config.userTasks)) { + `$taskName = [string]`$task.launchTaskName + if (-not [string]::IsNullOrWhiteSpace(`$taskName)) { + [void]`$taskNames.Add(`$taskName) + } + } + } + catch { + } + } + + return @(`$taskNames) +} + +function New-RecoveryLock { + param([string]`$PrimaryConfigPath) + + `$stateRoot = if (`$PrimaryConfigPath) { Split-Path -Path `$PrimaryConfigPath -Parent } else { Join-Path `$env:ProgramData 'AWatch-rus' } + if (-not (Test-Path -LiteralPath `$stateRoot)) { + New-Item -Path `$stateRoot -ItemType Directory -Force | Out-Null + } + + `$lockPath = Join-Path `$stateRoot 'recovery-loop.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 Start-TaskIfNotRunning { + param([string]`$TaskName) + if ([string]::IsNullOrWhiteSpace(`$TaskName)) { + return + } + + try { + `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + if (-not `$task) { + return + } + if ([string]`$task.State -eq 'Running') { + return + } + Start-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue + } + catch { + } +} + +`$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath +if (-not `$recoveryLockPath) { + return +} + +try { + while (`$true) { + `$sleepSeconds = 180 + try { + `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath + foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { + Start-TaskIfNotRunning -TaskName `$taskName + } + + `$config = Get-DeploymentConfig -Path `$ConfigPath + if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { + `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) + } + } + catch { + } + + Start-Sleep -Seconds `$sleepSeconds + } +} +finally { + if (`$recoveryLockPath -and (Test-Path -LiteralPath `$recoveryLockPath)) { + Remove-Item -LiteralPath `$recoveryLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Get-ActivityWatchHiddenLauncherPath { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath + ) + + $directory = Split-Path -Path $ScriptPath -Parent + $baseName = [IO.Path]::GetFileNameWithoutExtension($ScriptPath) + return Join-Path $directory ("{0}-hidden.vbs" -f $baseName) +} + +function Write-ActivityWatchHiddenPowerShellWrapper { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $escapedPowerShellExe = $powershellExe.Replace('"', '""') + $escapedScriptPath = $ScriptPath.Replace('"', '""') + $escapedConfigPath = $ConfigPath.Replace('"', '""') + + $content = @" +Set shell = CreateObject("WScript.Shell") +shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding ASCII +} + +function Remove-LegacyActivityWatchEntries { + $legacyTaskNames = @( + 'ActivityWatch Watchers', + 'ActivityWatch Guard', + 'ActivityWatch Heal' + ) + + foreach ($taskName in $legacyTaskNames) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + } + + $runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' + foreach ($name in 'ActivityWatchAFK', 'ActivityWatchWindow', 'ActivityWatchBrowserCollector') { + Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue + } +} + +function Remove-ActivityWatchScheduledTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName + ) + + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + & cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null + + for ($attempt = 0; $attempt -lt 10; $attempt++) { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + return + } + + Start-Sleep -Milliseconds 300 + } +} + +function Set-ActivityWatchScheduledTaskAction { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$Execute, + [Parameter(Mandatory = $true)] + [string]$Arguments + ) + + $taskCommand = ('"{0}" {1}' -f $Execute, $Arguments) + & schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "schtasks.exe /Change завершился с ошибкой для $TaskName" + } +} + +function Get-ActivityWatchScheduledTaskByCommand { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [string]$CommandMatch + ) + + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($task) { + return $task + } + + if ([string]::IsNullOrWhiteSpace($CommandMatch)) { + return $null + } + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$CommandMatch*") { + return $candidate + } + } + } + + return $null +} + +function Remove-StaleActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath + ) + + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + $desiredTaskNames = @($TaskDefinitions | ForEach-Object { [string]$_.LaunchTaskName }) + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + $taskName = [string]$candidate.TaskName + if ($desiredTaskNames -contains $taskName) { + continue + } + + $usesCurrentLauncher = $false + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$launcherPath*") { + $usesCurrentLauncher = $true + break + } + } + + if ($usesCurrentLauncher) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + } + } +} + +function Register-ActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath + Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath + + foreach ($definition in $TaskDefinitions) { + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId + $principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + $existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath + + if ($existingTask) { + Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments + continue + } + + Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName + Register-ScheduledTask -TaskName $definition.LaunchTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null + } +} + +function Register-ActivityWatchRecoveryTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + Remove-ActivityWatchScheduledTask -TaskName $TaskName + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null +} + +function Set-ActivityWatchAcl { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot + ) + + foreach ($path in $InstallRoot, $StateRoot, $LogsRoot) { + New-ActivityWatchDirectory -Path $path + } + + & icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $InstallRoot" + } + + & icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $StateRoot" + } + + & icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $LogsRoot" + } +} + +function Start-ActivityWatchTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [string]$RecoveryTaskName = 'ActivityWatch Recovery' + ) + + foreach ($definition in $TaskDefinitions) { + Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue + } + + Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue +} + +Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries +; } + + Start-Sleep -Seconds `$sleepSeconds + } +} +finally { + if (`$recoveryLockPath -and (Test-Path -LiteralPath `$recoveryLockPath)) { + Remove-Item -LiteralPath `$recoveryLockPath -Force -ErrorAction SilentlyContinue + } +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +function Get-ActivityWatchHiddenLauncherPath { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath + ) + + $directory = Split-Path -Path $ScriptPath -Parent + $baseName = [IO.Path]::GetFileNameWithoutExtension($ScriptPath) + return Join-Path $directory ("{0}-hidden.vbs" -f $baseName) +} + +function Write-ActivityWatchHiddenPowerShellWrapper { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$ScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory) { + New-ActivityWatchDirectory -Path $directory + } + + $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $escapedPowerShellExe = $powershellExe.Replace('"', '""') + $escapedScriptPath = $ScriptPath.Replace('"', '""') + $escapedConfigPath = $ConfigPath.Replace('"', '""') + + $content = @" +Set shell = CreateObject("WScript.Shell") +shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding ASCII +} + +function Remove-LegacyActivityWatchEntries { + $legacyTaskNames = @( + 'ActivityWatch Watchers', + 'ActivityWatch Guard', + 'ActivityWatch Heal' + ) + + foreach ($taskName in $legacyTaskNames) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + } + + $runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' + foreach ($name in 'ActivityWatchAFK', 'ActivityWatchWindow', 'ActivityWatchBrowserCollector') { + Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue + } +} + +function Remove-ActivityWatchScheduledTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName + ) + + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + & cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null + + for ($attempt = 0; $attempt -lt 10; $attempt++) { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + return + } + + Start-Sleep -Milliseconds 300 + } +} + +function Set-ActivityWatchScheduledTaskAction { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$Execute, + [Parameter(Mandatory = $true)] + [string]$Arguments + ) + + $taskCommand = ('"{0}" {1}' -f $Execute, $Arguments) + & schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "schtasks.exe /Change завершился с ошибкой для $TaskName" + } +} + +function Get-ActivityWatchScheduledTaskByCommand { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [string]$CommandMatch + ) + + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($task) { + return $task + } + + if ([string]::IsNullOrWhiteSpace($CommandMatch)) { + return $null + } + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$CommandMatch*") { + return $candidate + } + } + } + + return $null +} + +function Remove-StaleActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath + ) + + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + $desiredTaskNames = @($TaskDefinitions | ForEach-Object { [string]$_.LaunchTaskName }) + + foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) { + $taskName = [string]$candidate.TaskName + if ($desiredTaskNames -contains $taskName) { + continue + } + + $usesCurrentLauncher = $false + foreach ($action in @($candidate.Actions)) { + if ([string]$action.Arguments -like "*$launcherPath*") { + $usesCurrentLauncher = $true + break + } + } + + if ($usesCurrentLauncher) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + } + } +} + +function Register-ActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath + Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath + + foreach ($definition in $TaskDefinitions) { + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId + $principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + $existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath + + if ($existingTask) { + Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments + continue + } + + Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName + Register-ScheduledTask -TaskName $definition.LaunchTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null + } +} + +function Register-ActivityWatchRecoveryTask { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + Remove-ActivityWatchScheduledTask -TaskName $TaskName + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null +} + +function Set-ActivityWatchAcl { + param( + [Parameter(Mandatory = $true)] + [string]$InstallRoot, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$LogsRoot + ) + + foreach ($path in $InstallRoot, $StateRoot, $LogsRoot) { + New-ActivityWatchDirectory -Path $path + } + + & icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $InstallRoot" + } + + & icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $StateRoot" + } + + & icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "icacls завершился с ошибкой для $LogsRoot" + } +} + +function Start-ActivityWatchTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [string]$RecoveryTaskName = 'ActivityWatch Recovery' + ) + + foreach ($definition in $TaskDefinitions) { + Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue + } + + Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue +} + +Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries diff --git a/windows/browser-domains-native-collector.ps1 b/windows/browser-domains-native-collector.ps1 index f4eff79..e7418d0 100755 --- a/windows/browser-domains-native-collector.ps1 +++ b/windows/browser-domains-native-collector.ps1 @@ -1,4 +1,122 @@ -[CmdletBinding()] +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$RulesPath, + [string]$PolicyPath, + [string]$LogPath, + [string]$IncidentLogPath, + [int]$PollSeconds, + [int]$PulseSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class NativeAwMethods { + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern int GetWindowTextLength(IntPtr hWnd); +} +"@ + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\AWatch-rus\web-category-rules.json' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) } +$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentLogPath = $resolvedIncidentLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false +$script:IncidentState = @{} +$script:DlpRules = @() +$script:DlpDefaults = [ordered]@{ + enabled = $false + cooldownSeconds = 300 + action = 'log' + severity = 'low' +} +$script:BrowserMap = @{ + msedge = 'edge' + chrome = 'chrome' + brave = 'brave' + vivaldi = 'vivaldi' + opera = 'opera' + firefox = 'firefox' +} +$script:CategoryRules = @( + @{ Name = 'work_business_systems'; Group = 'work'; Domains = @('bitrix24.ru', '1c.ru', 'sbis.ru', 'kontur.ru', 'diadoc.ru', 'nalog.gov.ru', 'gosuslugi.ru') } + @{ Name = 'work_docs_collab'; Group = 'work'; Domains = @('office.com', 'sharepoint.com', 'docs.google.com', 'drive.google.com', 'notion.so', 'miro.com') } + @{ Name = 'work_dev'; Group = 'work'; Domains = @('github.com', 'gitlab.com', 'bitbucket.org', 'youtrack.cloud', 'atlassian.net') } + @{ Name = 'work_communication'; Group = 'work'; Domains = @('teams.microsoft.com', 'outlook.office.com', 'web.telegram.org', 'slack.com', 'zoom.us') } + @{ Name = 'neutral_search_reference'; Group = 'neutral'; Domains = @('google.com', 'google.ru', 'yandex.ru', 'bing.com', 'duckduckgo.com', 'wikipedia.org') } + @{ Name = 'neutral_news'; Group = 'neutral'; Domains = @('rbc.ru', 'tass.ru', 'ria.ru', 'kommersant.ru', 'vedomosti.ru') } + @{ Name = 'personal_social'; Group = 'personal'; Domains = @('vk.com', 'ok.ru', 'facebook.com', 'instagram.com', 'tiktok.com', 'x.com', 'twitter.com') } + @{ Name = 'personal_video'; Group = 'personal'; Domains = @('youtube.com', 'youtu.be', 'rutube.ru', 'twitch.tv', 'kinopoisk.ru') } + @{ Name = 'personal_marketplace'; Group = 'personal'; Domains = @('ozon.ru', 'wildberries.ru', 'avito.ru', 'aliexpress.com', 'market.yandex.ru') } + @{ Name = 'personal_entertainment'; Group = 'personal'; Domains = @('dzen.ru', 'pikabu.ru', 'dtf.ru', 'playground.ru') } +) + +function Write-CollectorLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { Write-Error [CmdletBinding()] param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$ServerHost, @@ -833,3 +951,3221 @@ while ($true) { Start-Sleep -Seconds $resolvedPollSeconds } +; } +} + +function Write-DlpIncidentLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:IncidentLogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$RulesPath, + [string]$PolicyPath, + [string]$LogPath, + [string]$IncidentLogPath, + [int]$PollSeconds, + [int]$PulseSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class NativeAwMethods { + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern int GetWindowTextLength(IntPtr hWnd); +} +"@ + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\AWatch-rus\web-category-rules.json' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) } +$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentLogPath = $resolvedIncidentLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false +$script:IncidentState = @{} +$script:DlpRules = @() +$script:DlpDefaults = [ordered]@{ + enabled = $false + cooldownSeconds = 300 + action = 'log' + severity = 'low' +} +$script:BrowserMap = @{ + msedge = 'edge' + chrome = 'chrome' + brave = 'brave' + vivaldi = 'vivaldi' + opera = 'opera' + firefox = 'firefox' +} +$script:CategoryRules = @( + @{ Name = 'work_business_systems'; Group = 'work'; Domains = @('bitrix24.ru', '1c.ru', 'sbis.ru', 'kontur.ru', 'diadoc.ru', 'nalog.gov.ru', 'gosuslugi.ru') } + @{ Name = 'work_docs_collab'; Group = 'work'; Domains = @('office.com', 'sharepoint.com', 'docs.google.com', 'drive.google.com', 'notion.so', 'miro.com') } + @{ Name = 'work_dev'; Group = 'work'; Domains = @('github.com', 'gitlab.com', 'bitbucket.org', 'youtrack.cloud', 'atlassian.net') } + @{ Name = 'work_communication'; Group = 'work'; Domains = @('teams.microsoft.com', 'outlook.office.com', 'web.telegram.org', 'slack.com', 'zoom.us') } + @{ Name = 'neutral_search_reference'; Group = 'neutral'; Domains = @('google.com', 'google.ru', 'yandex.ru', 'bing.com', 'duckduckgo.com', 'wikipedia.org') } + @{ Name = 'neutral_news'; Group = 'neutral'; Domains = @('rbc.ru', 'tass.ru', 'ria.ru', 'kommersant.ru', 'vedomosti.ru') } + @{ Name = 'personal_social'; Group = 'personal'; Domains = @('vk.com', 'ok.ru', 'facebook.com', 'instagram.com', 'tiktok.com', 'x.com', 'twitter.com') } + @{ Name = 'personal_video'; Group = 'personal'; Domains = @('youtube.com', 'youtu.be', 'rutube.ru', 'twitch.tv', 'kinopoisk.ru') } + @{ Name = 'personal_marketplace'; Group = 'personal'; Domains = @('ozon.ru', 'wildberries.ru', 'avito.ru', 'aliexpress.com', 'market.yandex.ru') } + @{ Name = 'personal_entertainment'; Group = 'personal'; Domains = @('dzen.ru', 'pikabu.ru', 'dtf.ru', 'playground.ru') } +) + +function Write-CollectorLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Write-DlpIncidentLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:IncidentLogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Test-DomainMatch { + param( + [string]$DomainHost, + [string]$RuleDomain + ) + + if ([string]::IsNullOrWhiteSpace($DomainHost) -or [string]::IsNullOrWhiteSpace($RuleDomain)) { + return $false + } + + $left = $DomainHost.ToLowerInvariant() + $right = $RuleDomain.ToLowerInvariant() + return $left -eq $right -or $left.EndsWith('.' + $right) +} + +function Get-HostFromUrl { + param([string]$Url) + + if ([string]::IsNullOrWhiteSpace($Url)) { + return $null + } + + try { + $uri = [Uri]$Url + $uriHost = $uri.Host.ToLowerInvariant() + if ($uriHost.StartsWith('www.')) { + return $uriHost.Substring(4) + } + + return $uriHost + } + catch { + return $null + } +} + +function Get-RootDomain { + param([string]$DomainHost) + + if ([string]::IsNullOrWhiteSpace($DomainHost)) { + return $null + } + + $parts = $DomainHost.Split('.') + if ($parts.Count -le 2) { + return $DomainHost + } + + $suffix = ('{0}.{1}' -f $parts[$parts.Count - 2], $parts[$parts.Count - 1]).ToLowerInvariant() + $compoundTlds = @('co.uk', 'com.au', 'co.jp', 'com.br', 'co.in', 'com.tr', 'com.cn') + if (($compoundTlds -contains $suffix) -and $parts.Count -ge 3) { + return ('{0}.{1}' -f $parts[$parts.Count - 3], $suffix).ToLowerInvariant() + } + + return $suffix +} + +function ConvertTo-NormalizedUrl { + param([AllowNull()][string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + $candidate = $Value.Trim() + if ($candidate.Length -lt 4) { + return $null + } + + if ($candidate -match '^(?i)(search|find|address and search|search with|новая вкладка|new tab)') { + return $null + } + + if ($candidate -match '^(?i)(https?|file|ftp|chrome|edge|about|view-source)://') { + return $candidate + } + + if ($candidate -match '^(?i)localhost([/:]|$)') { + return "http://$candidate" + } + + if ($candidate -match '^[a-z0-9.-]+\.[a-z]{2,}([/:?#].*)?$') { + return "https://$candidate" + } + + return $null +} + +function Load-CustomCategoryRules { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $rules = @() + + if ($parsed.rules) { + $sourceRules = @($parsed.rules) + } + elseif ($parsed -is [System.Collections.IEnumerable]) { + $sourceRules = @($parsed) + } + else { + $sourceRules = @() + } + + foreach ($rule in $sourceRules) { + if (-not $rule) { + continue + } + + $name = [string]$rule.name + $group = [string]$rule.group + $domains = @($rule.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) + + if ($name -and $group -and $domains.Count -gt 0) { + $rules += @{ + Name = $name + Group = $group + Domains = $domains + } + } + } + + if ($rules.Count -gt 0) { + $script:CategoryRules = @($rules) + @($script:CategoryRules) + Write-CollectorLog ("пользовательские правила загружены: {0}" -f $rules.Count) + } + } + catch { + Write-CollectorLog ("не удалось загрузить пользовательские правила: {0}" -f $_.Exception.Message) + } +} + +function Get-WebCategory { + param([string]$DomainHost) + + foreach ($rule in $script:CategoryRules) { + foreach ($domain in $rule.Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return [pscustomobject]@{ + Name = [string]$rule.Name + Group = [string]$rule.Group + Rule = [string]$domain + } + } + } + } + + return [pscustomobject]@{ + Name = 'uncategorized' + Group = 'neutral' + Rule = 'none' + } +} + +function Test-DomainListMatch { + param( + [string]$DomainHost, + [string[]]$Domains + ) + + if (-not $Domains -or $Domains.Count -eq 0) { + return $false + } + + foreach ($domain in $Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return $true + } + } + + return $false +} + +function Test-DlpRuleTimeWindow { + param( + [int]$CurrentHour, + [AllowNull()][int]$HourFrom, + [AllowNull()][int]$HourTo + ) + + if ($null -eq $HourFrom -or $null -eq $HourTo) { + return $true + } + + if ($HourFrom -eq $HourTo) { + return $true + } + + if ($HourFrom -lt $HourTo) { + return ($CurrentHour -ge $HourFrom -and $CurrentHour -lt $HourTo) + } + + return ($CurrentHour -ge $HourFrom -or $CurrentHour -lt $HourTo) +} + +function Load-DlpPolicy { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("DLP-политика не найдена, DLP отключен: {0}" -f $Path) + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $defaults = $parsed.defaults + if ($defaults) { + if ($defaults.PSObject.Properties.Name -contains 'enabled') { + $script:DlpDefaults.enabled = [bool]$defaults.enabled + } + if ($defaults.cooldownSeconds) { + $script:DlpDefaults.cooldownSeconds = [int]$defaults.cooldownSeconds + } + if ($defaults.action) { + $script:DlpDefaults.action = [string]$defaults.action + } + if ($defaults.severity) { + $script:DlpDefaults.severity = [string]$defaults.severity + } + } + + $loaded = @() + foreach ($rule in @($parsed.rules)) { + if (-not $rule) { continue } + $when = $rule.when + if (-not $when) { + $when = [pscustomobject]@{} + } + $loaded += [pscustomobject]@{ + id = [string]$rule.id + enabled = if ($rule.PSObject.Properties.Name -contains 'enabled') { [bool]$rule.enabled } else { $true } + action = if ($rule.action) { [string]$rule.action } else { [string]$script:DlpDefaults.action } + severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:DlpDefaults.severity } + message = if ($rule.message) { [string]$rule.message } else { "Сработало DLP-правило: $($rule.id)" } + cooldownSeconds = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:DlpDefaults.cooldownSeconds } + when = [pscustomobject]@{ + domains = if ($when.PSObject.Properties.Name -contains 'domains') { @($when.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categoryGroups = if ($when.PSObject.Properties.Name -contains 'categoryGroups') { @($when.categoryGroups | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categories = if ($when.PSObject.Properties.Name -contains 'categories') { @($when.categories | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + browsers = if ($when.PSObject.Properties.Name -contains 'browsers') { @($when.browsers | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + urlRegex = if ($when.PSObject.Properties.Name -contains 'urlRegex' -and $when.urlRegex) { [string]$when.urlRegex } else { $null } + titleRegex = if ($when.PSObject.Properties.Name -contains 'titleRegex' -and $when.titleRegex) { [string]$when.titleRegex } else { $null } + hourFrom = if ($when.PSObject.Properties.Name -contains 'hourFrom') { [int]$when.hourFrom } else { $null } + hourTo = if ($when.PSObject.Properties.Name -contains 'hourTo') { [int]$when.hourTo } else { $null } + } + } + } + + $script:DlpRules = @($loaded) + Write-CollectorLog ("DLP-политика загружена: включена={0}, правил={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count) + } + catch { + Write-CollectorLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message) + } +} + +function Test-DlpRuleMatch { + param( + [pscustomobject]$Rule, + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $Rule.enabled) { + return $false + } + + $when = $Rule.when + $currentHour = (Get-Date).Hour + if (-not (Test-DlpRuleTimeWindow -CurrentHour $currentHour -HourFrom $when.hourFrom -HourTo $when.hourTo)) { + return $false + } + + if ($when.domains.Count -gt 0) { + $domainMatched = (Test-DomainListMatch -DomainHost $Domain -Domains $when.domains) -or (Test-DomainListMatch -DomainHost $RootDomain -Domains $when.domains) + if (-not $domainMatched) { + return $false + } + } + + if ($when.categoryGroups.Count -gt 0 -and ($when.categoryGroups -notcontains $CategoryGroup.ToLowerInvariant())) { + return $false + } + + if ($when.categories.Count -gt 0 -and ($when.categories -notcontains $Category.ToLowerInvariant())) { + return $false + } + + if ($when.browsers.Count -gt 0 -and ($when.browsers -notcontains $BrowserKey.ToLowerInvariant())) { + return $false + } + + if ($when.urlRegex) { + if (-not ($Url -match $when.urlRegex)) { + return $false + } + } + + if ($when.titleRegex) { + if (-not ($Title -match $when.titleRegex)) { + return $false + } + } + + return $true +} + +function Get-DlpDecision { + param( + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $script:DlpDefaults.enabled) { + return $null + } + + foreach ($rule in $script:DlpRules) { + if (Test-DlpRuleMatch -Rule $rule -Domain $Domain -RootDomain $RootDomain -Url $Url -Title $Title -BrowserKey $BrowserKey -Category $Category -CategoryGroup $CategoryGroup) { + return $rule + } + } + + return $null +} + +function Should-EmitIncident { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:IncidentState.ContainsKey($Fingerprint)) { + $last = [datetime]$script:IncidentState[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:IncidentState[$Fingerprint] = $now + return $true +} + +function Send-DlpIncidentHeartbeat { + param( + [pscustomobject]$Decision, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId ([string]$Decision.id) -SignalType 'web' + } + catch { + } + } + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = [string]$Decision.id + action = [string]$Decision.action + severity = [string]$Decision.severity + message = [string]$Decision.message + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + username = $env:USERNAME + hostname = $script:Hostname + sessionId = $script:SessionId + source = 'uia-native-dlp' + } + $captureData + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-CollectorLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message) + return @{} + } +} + +function Get-ForegroundWindowContext { + $handle = [NativeAwMethods]::GetForegroundWindow() + if ($handle -eq [IntPtr]::Zero) { + return $null + } + + $processId = [uint32]0 + [void][NativeAwMethods]::GetWindowThreadProcessId($handle, [ref]$processId) + if (-not $processId) { + return $null + } + + $process = Get-Process -Id ([int]$processId) -ErrorAction SilentlyContinue + if (-not $process) { + return $null + } + + $textLength = [NativeAwMethods]::GetWindowTextLength($handle) + $builder = [Text.StringBuilder]::new([Math]::Max($textLength + 1, 260)) + [void][NativeAwMethods]::GetWindowText($handle, $builder, $builder.Capacity) + + return [pscustomobject]@{ + Handle = $handle + ProcessName = $process.ProcessName.ToLowerInvariant() + Title = $builder.ToString() + } +} + +function Get-BrowserUrlFromWindow { + param([IntPtr]$Handle) + + $root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle) + if (-not $root) { + return $null + } + + $editCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Edit + ) + + $edits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $editCondition) + foreach ($edit in $edits) { + $valuePattern = $null + if ($edit.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$valuePattern)) { + $candidate = ConvertTo-NormalizedUrl -Value $valuePattern.Current.Value + if ($candidate) { + return $candidate + } + } + + $candidateFromName = ConvertTo-NormalizedUrl -Value $edit.Current.Name + if ($candidateFromName) { + return $candidateFromName + } + } + + return $null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType = 'web.tab.current' + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null + } + catch { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-Heartbeat { + param( + [string]$BucketId, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName + ) + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Send-CategoryHeartbeat { + param( + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup, + [string]$CategoryRule + ) + + $bucketId = 'aw-detmir-web-category_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-detmir-web-category' -BucketType 'aw.web.category' + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + categoryRule = $CategoryRule + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +Load-CustomCategoryRules -Path $resolvedRulesPath +Load-DlpPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("коллектор запущен для {0}" -f $script:ApiBase) + +while ($true) { + try { + $context = Get-ForegroundWindowContext + if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) { + $url = Get-BrowserUrlFromWindow -Handle $context.Handle + if ($url) { + $browserKey = $script:BrowserMap[$context.ProcessName] + $domain = Get-HostFromUrl -Url $url + if (-not $domain) { + $domain = 'unknown' + } + + $rootDomain = Get-RootDomain -DomainHost $domain + if (-not $rootDomain) { + $rootDomain = $domain + } + + $category = Get-WebCategory -DomainHost $domain + $bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey) + Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName + Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule + + $decision = Get-DlpDecision -Domain $domain -RootDomain $rootDomain -Url $url -Title $context.Title -BrowserKey $browserKey -Category $category.Name -CategoryGroup $category.Group + if ($decision) { + $fingerprint = '{0}|{1}|{2}|{3}' -f $decision.id, $browserKey, $rootDomain, $env:USERNAME + $cooldown = [Math]::Max([int]$decision.cooldownSeconds, 30) + if (Should-EmitIncident -Fingerprint $fingerprint -CooldownSeconds $cooldown) { + Write-DlpIncidentLog ("{0} {1} {2} {3}" -f $decision.severity, $decision.action, $decision.id, $url) + if (@('alert', 'block', 'quarantine') -contains ([string]$decision.action).ToLowerInvariant()) { + Send-DlpIncidentHeartbeat -Decision $decision -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group + } + } + } + } + } + } + catch { + Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } +} + +function Test-DomainMatch { + param( + [string]$DomainHost, + [string]$RuleDomain + ) + + if ([string]::IsNullOrWhiteSpace($DomainHost) -or [string]::IsNullOrWhiteSpace($RuleDomain)) { + return $false + } + + $left = $DomainHost.ToLowerInvariant() + $right = $RuleDomain.ToLowerInvariant() + return $left -eq $right -or $left.EndsWith('.' + $right) +} + +function Get-HostFromUrl { + param([string]$Url) + + if ([string]::IsNullOrWhiteSpace($Url)) { + return $null + } + + try { + $uri = [Uri]$Url + $uriHost = $uri.Host.ToLowerInvariant() + if ($uriHost.StartsWith('www.')) { + return $uriHost.Substring(4) + } + + return $uriHost + } + catch { + return $null + } +} + +function Get-RootDomain { + param([string]$DomainHost) + + if ([string]::IsNullOrWhiteSpace($DomainHost)) { + return $null + } + + $parts = $DomainHost.Split('.') + if ($parts.Count -le 2) { + return $DomainHost + } + + $suffix = ('{0}.{1}' -f $parts[$parts.Count - 2], $parts[$parts.Count - 1]).ToLowerInvariant() + $compoundTlds = @('co.uk', 'com.au', 'co.jp', 'com.br', 'co.in', 'com.tr', 'com.cn') + if (($compoundTlds -contains $suffix) -and $parts.Count -ge 3) { + return ('{0}.{1}' -f $parts[$parts.Count - 3], $suffix).ToLowerInvariant() + } + + return $suffix +} + +function ConvertTo-NormalizedUrl { + param([AllowNull()][string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + $candidate = $Value.Trim() + if ($candidate.Length -lt 4) { + return $null + } + + if ($candidate -match '^(?i)(search|find|address and search|search with|новая вкладка|new tab)') { + return $null + } + + if ($candidate -match '^(?i)(https?|file|ftp|chrome|edge|about|view-source)://') { + return $candidate + } + + if ($candidate -match '^(?i)localhost([/:]|$)') { + return "http://$candidate" + } + + if ($candidate -match '^[a-z0-9.-]+\.[a-z]{2,}([/:?#].*)?$') { + return "https://$candidate" + } + + return $null +} + +function Load-CustomCategoryRules { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $rules = @() + + if ($parsed.rules) { + $sourceRules = @($parsed.rules) + } + elseif ($parsed -is [System.Collections.IEnumerable]) { + $sourceRules = @($parsed) + } + else { + $sourceRules = @() + } + + foreach ($rule in $sourceRules) { + if (-not $rule) { + continue + } + + $name = [string]$rule.name + $group = [string]$rule.group + $domains = @($rule.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) + + if ($name -and $group -and $domains.Count -gt 0) { + $rules += @{ + Name = $name + Group = $group + Domains = $domains + } + } + } + + if ($rules.Count -gt 0) { + $script:CategoryRules = @($rules) + @($script:CategoryRules) + Write-CollectorLog ("пользовательские правила загружены: {0}" -f $rules.Count) + } + } + catch { + Write-CollectorLog ("не удалось загрузить пользовательские правила: {0}" -f $_.Exception.Message) + } +} + +function Get-WebCategory { + param([string]$DomainHost) + + foreach ($rule in $script:CategoryRules) { + foreach ($domain in $rule.Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return [pscustomobject]@{ + Name = [string]$rule.Name + Group = [string]$rule.Group + Rule = [string]$domain + } + } + } + } + + return [pscustomobject]@{ + Name = 'uncategorized' + Group = 'neutral' + Rule = 'none' + } +} + +function Test-DomainListMatch { + param( + [string]$DomainHost, + [string[]]$Domains + ) + + if (-not $Domains -or $Domains.Count -eq 0) { + return $false + } + + foreach ($domain in $Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return $true + } + } + + return $false +} + +function Test-DlpRuleTimeWindow { + param( + [int]$CurrentHour, + [AllowNull()][int]$HourFrom, + [AllowNull()][int]$HourTo + ) + + if ($null -eq $HourFrom -or $null -eq $HourTo) { + return $true + } + + if ($HourFrom -eq $HourTo) { + return $true + } + + if ($HourFrom -lt $HourTo) { + return ($CurrentHour -ge $HourFrom -and $CurrentHour -lt $HourTo) + } + + return ($CurrentHour -ge $HourFrom -or $CurrentHour -lt $HourTo) +} + +function Load-DlpPolicy { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("DLP-политика не найдена, DLP отключен: {0}" -f $Path) + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $defaults = $parsed.defaults + if ($defaults) { + if ($defaults.PSObject.Properties.Name -contains 'enabled') { + $script:DlpDefaults.enabled = [bool]$defaults.enabled + } + if ($defaults.cooldownSeconds) { + $script:DlpDefaults.cooldownSeconds = [int]$defaults.cooldownSeconds + } + if ($defaults.action) { + $script:DlpDefaults.action = [string]$defaults.action + } + if ($defaults.severity) { + $script:DlpDefaults.severity = [string]$defaults.severity + } + } + + $loaded = @() + foreach ($rule in @($parsed.rules)) { + if (-not $rule) { continue } + $when = $rule.when + if (-not $when) { + $when = [pscustomobject]@{} + } + $loaded += [pscustomobject]@{ + id = [string]$rule.id + enabled = if ($rule.PSObject.Properties.Name -contains 'enabled') { [bool]$rule.enabled } else { $true } + action = if ($rule.action) { [string]$rule.action } else { [string]$script:DlpDefaults.action } + severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:DlpDefaults.severity } + message = if ($rule.message) { [string]$rule.message } else { "Сработало DLP-правило: $($rule.id)" } + cooldownSeconds = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:DlpDefaults.cooldownSeconds } + when = [pscustomobject]@{ + domains = if ($when.PSObject.Properties.Name -contains 'domains') { @($when.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categoryGroups = if ($when.PSObject.Properties.Name -contains 'categoryGroups') { @($when.categoryGroups | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categories = if ($when.PSObject.Properties.Name -contains 'categories') { @($when.categories | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + browsers = if ($when.PSObject.Properties.Name -contains 'browsers') { @($when.browsers | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + urlRegex = if ($when.PSObject.Properties.Name -contains 'urlRegex' -and $when.urlRegex) { [string]$when.urlRegex } else { $null } + titleRegex = if ($when.PSObject.Properties.Name -contains 'titleRegex' -and $when.titleRegex) { [string]$when.titleRegex } else { $null } + hourFrom = if ($when.PSObject.Properties.Name -contains 'hourFrom') { [int]$when.hourFrom } else { $null } + hourTo = if ($when.PSObject.Properties.Name -contains 'hourTo') { [int]$when.hourTo } else { $null } + } + } + } + + $script:DlpRules = @($loaded) + Write-CollectorLog ("DLP-политика загружена: включена={0}, правил={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count) + } + catch { + Write-CollectorLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message) + } +} + +function Test-DlpRuleMatch { + param( + [pscustomobject]$Rule, + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $Rule.enabled) { + return $false + } + + $when = $Rule.when + $currentHour = (Get-Date).Hour + if (-not (Test-DlpRuleTimeWindow -CurrentHour $currentHour -HourFrom $when.hourFrom -HourTo $when.hourTo)) { + return $false + } + + if ($when.domains.Count -gt 0) { + $domainMatched = (Test-DomainListMatch -DomainHost $Domain -Domains $when.domains) -or (Test-DomainListMatch -DomainHost $RootDomain -Domains $when.domains) + if (-not $domainMatched) { + return $false + } + } + + if ($when.categoryGroups.Count -gt 0 -and ($when.categoryGroups -notcontains $CategoryGroup.ToLowerInvariant())) { + return $false + } + + if ($when.categories.Count -gt 0 -and ($when.categories -notcontains $Category.ToLowerInvariant())) { + return $false + } + + if ($when.browsers.Count -gt 0 -and ($when.browsers -notcontains $BrowserKey.ToLowerInvariant())) { + return $false + } + + if ($when.urlRegex) { + if (-not ($Url -match $when.urlRegex)) { + return $false + } + } + + if ($when.titleRegex) { + if (-not ($Title -match $when.titleRegex)) { + return $false + } + } + + return $true +} + +function Get-DlpDecision { + param( + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $script:DlpDefaults.enabled) { + return $null + } + + foreach ($rule in $script:DlpRules) { + if (Test-DlpRuleMatch -Rule $rule -Domain $Domain -RootDomain $RootDomain -Url $Url -Title $Title -BrowserKey $BrowserKey -Category $Category -CategoryGroup $CategoryGroup) { + return $rule + } + } + + return $null +} + +function Should-EmitIncident { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:IncidentState.ContainsKey($Fingerprint)) { + $last = [datetime]$script:IncidentState[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:IncidentState[$Fingerprint] = $now + return $true +} + +function Send-DlpIncidentHeartbeat { + param( + [pscustomobject]$Decision, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId ([string]$Decision.id) -SignalType 'web' + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$RulesPath, + [string]$PolicyPath, + [string]$LogPath, + [string]$IncidentLogPath, + [int]$PollSeconds, + [int]$PulseSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class NativeAwMethods { + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern int GetWindowTextLength(IntPtr hWnd); +} +"@ + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\AWatch-rus\web-category-rules.json' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) } +$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentLogPath = $resolvedIncidentLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false +$script:IncidentState = @{} +$script:DlpRules = @() +$script:DlpDefaults = [ordered]@{ + enabled = $false + cooldownSeconds = 300 + action = 'log' + severity = 'low' +} +$script:BrowserMap = @{ + msedge = 'edge' + chrome = 'chrome' + brave = 'brave' + vivaldi = 'vivaldi' + opera = 'opera' + firefox = 'firefox' +} +$script:CategoryRules = @( + @{ Name = 'work_business_systems'; Group = 'work'; Domains = @('bitrix24.ru', '1c.ru', 'sbis.ru', 'kontur.ru', 'diadoc.ru', 'nalog.gov.ru', 'gosuslugi.ru') } + @{ Name = 'work_docs_collab'; Group = 'work'; Domains = @('office.com', 'sharepoint.com', 'docs.google.com', 'drive.google.com', 'notion.so', 'miro.com') } + @{ Name = 'work_dev'; Group = 'work'; Domains = @('github.com', 'gitlab.com', 'bitbucket.org', 'youtrack.cloud', 'atlassian.net') } + @{ Name = 'work_communication'; Group = 'work'; Domains = @('teams.microsoft.com', 'outlook.office.com', 'web.telegram.org', 'slack.com', 'zoom.us') } + @{ Name = 'neutral_search_reference'; Group = 'neutral'; Domains = @('google.com', 'google.ru', 'yandex.ru', 'bing.com', 'duckduckgo.com', 'wikipedia.org') } + @{ Name = 'neutral_news'; Group = 'neutral'; Domains = @('rbc.ru', 'tass.ru', 'ria.ru', 'kommersant.ru', 'vedomosti.ru') } + @{ Name = 'personal_social'; Group = 'personal'; Domains = @('vk.com', 'ok.ru', 'facebook.com', 'instagram.com', 'tiktok.com', 'x.com', 'twitter.com') } + @{ Name = 'personal_video'; Group = 'personal'; Domains = @('youtube.com', 'youtu.be', 'rutube.ru', 'twitch.tv', 'kinopoisk.ru') } + @{ Name = 'personal_marketplace'; Group = 'personal'; Domains = @('ozon.ru', 'wildberries.ru', 'avito.ru', 'aliexpress.com', 'market.yandex.ru') } + @{ Name = 'personal_entertainment'; Group = 'personal'; Domains = @('dzen.ru', 'pikabu.ru', 'dtf.ru', 'playground.ru') } +) + +function Write-CollectorLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Write-DlpIncidentLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:IncidentLogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Test-DomainMatch { + param( + [string]$DomainHost, + [string]$RuleDomain + ) + + if ([string]::IsNullOrWhiteSpace($DomainHost) -or [string]::IsNullOrWhiteSpace($RuleDomain)) { + return $false + } + + $left = $DomainHost.ToLowerInvariant() + $right = $RuleDomain.ToLowerInvariant() + return $left -eq $right -or $left.EndsWith('.' + $right) +} + +function Get-HostFromUrl { + param([string]$Url) + + if ([string]::IsNullOrWhiteSpace($Url)) { + return $null + } + + try { + $uri = [Uri]$Url + $uriHost = $uri.Host.ToLowerInvariant() + if ($uriHost.StartsWith('www.')) { + return $uriHost.Substring(4) + } + + return $uriHost + } + catch { + return $null + } +} + +function Get-RootDomain { + param([string]$DomainHost) + + if ([string]::IsNullOrWhiteSpace($DomainHost)) { + return $null + } + + $parts = $DomainHost.Split('.') + if ($parts.Count -le 2) { + return $DomainHost + } + + $suffix = ('{0}.{1}' -f $parts[$parts.Count - 2], $parts[$parts.Count - 1]).ToLowerInvariant() + $compoundTlds = @('co.uk', 'com.au', 'co.jp', 'com.br', 'co.in', 'com.tr', 'com.cn') + if (($compoundTlds -contains $suffix) -and $parts.Count -ge 3) { + return ('{0}.{1}' -f $parts[$parts.Count - 3], $suffix).ToLowerInvariant() + } + + return $suffix +} + +function ConvertTo-NormalizedUrl { + param([AllowNull()][string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + $candidate = $Value.Trim() + if ($candidate.Length -lt 4) { + return $null + } + + if ($candidate -match '^(?i)(search|find|address and search|search with|новая вкладка|new tab)') { + return $null + } + + if ($candidate -match '^(?i)(https?|file|ftp|chrome|edge|about|view-source)://') { + return $candidate + } + + if ($candidate -match '^(?i)localhost([/:]|$)') { + return "http://$candidate" + } + + if ($candidate -match '^[a-z0-9.-]+\.[a-z]{2,}([/:?#].*)?$') { + return "https://$candidate" + } + + return $null +} + +function Load-CustomCategoryRules { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $rules = @() + + if ($parsed.rules) { + $sourceRules = @($parsed.rules) + } + elseif ($parsed -is [System.Collections.IEnumerable]) { + $sourceRules = @($parsed) + } + else { + $sourceRules = @() + } + + foreach ($rule in $sourceRules) { + if (-not $rule) { + continue + } + + $name = [string]$rule.name + $group = [string]$rule.group + $domains = @($rule.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) + + if ($name -and $group -and $domains.Count -gt 0) { + $rules += @{ + Name = $name + Group = $group + Domains = $domains + } + } + } + + if ($rules.Count -gt 0) { + $script:CategoryRules = @($rules) + @($script:CategoryRules) + Write-CollectorLog ("пользовательские правила загружены: {0}" -f $rules.Count) + } + } + catch { + Write-CollectorLog ("не удалось загрузить пользовательские правила: {0}" -f $_.Exception.Message) + } +} + +function Get-WebCategory { + param([string]$DomainHost) + + foreach ($rule in $script:CategoryRules) { + foreach ($domain in $rule.Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return [pscustomobject]@{ + Name = [string]$rule.Name + Group = [string]$rule.Group + Rule = [string]$domain + } + } + } + } + + return [pscustomobject]@{ + Name = 'uncategorized' + Group = 'neutral' + Rule = 'none' + } +} + +function Test-DomainListMatch { + param( + [string]$DomainHost, + [string[]]$Domains + ) + + if (-not $Domains -or $Domains.Count -eq 0) { + return $false + } + + foreach ($domain in $Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return $true + } + } + + return $false +} + +function Test-DlpRuleTimeWindow { + param( + [int]$CurrentHour, + [AllowNull()][int]$HourFrom, + [AllowNull()][int]$HourTo + ) + + if ($null -eq $HourFrom -or $null -eq $HourTo) { + return $true + } + + if ($HourFrom -eq $HourTo) { + return $true + } + + if ($HourFrom -lt $HourTo) { + return ($CurrentHour -ge $HourFrom -and $CurrentHour -lt $HourTo) + } + + return ($CurrentHour -ge $HourFrom -or $CurrentHour -lt $HourTo) +} + +function Load-DlpPolicy { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("DLP-политика не найдена, DLP отключен: {0}" -f $Path) + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $defaults = $parsed.defaults + if ($defaults) { + if ($defaults.PSObject.Properties.Name -contains 'enabled') { + $script:DlpDefaults.enabled = [bool]$defaults.enabled + } + if ($defaults.cooldownSeconds) { + $script:DlpDefaults.cooldownSeconds = [int]$defaults.cooldownSeconds + } + if ($defaults.action) { + $script:DlpDefaults.action = [string]$defaults.action + } + if ($defaults.severity) { + $script:DlpDefaults.severity = [string]$defaults.severity + } + } + + $loaded = @() + foreach ($rule in @($parsed.rules)) { + if (-not $rule) { continue } + $when = $rule.when + if (-not $when) { + $when = [pscustomobject]@{} + } + $loaded += [pscustomobject]@{ + id = [string]$rule.id + enabled = if ($rule.PSObject.Properties.Name -contains 'enabled') { [bool]$rule.enabled } else { $true } + action = if ($rule.action) { [string]$rule.action } else { [string]$script:DlpDefaults.action } + severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:DlpDefaults.severity } + message = if ($rule.message) { [string]$rule.message } else { "Сработало DLP-правило: $($rule.id)" } + cooldownSeconds = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:DlpDefaults.cooldownSeconds } + when = [pscustomobject]@{ + domains = if ($when.PSObject.Properties.Name -contains 'domains') { @($when.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categoryGroups = if ($when.PSObject.Properties.Name -contains 'categoryGroups') { @($when.categoryGroups | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categories = if ($when.PSObject.Properties.Name -contains 'categories') { @($when.categories | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + browsers = if ($when.PSObject.Properties.Name -contains 'browsers') { @($when.browsers | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + urlRegex = if ($when.PSObject.Properties.Name -contains 'urlRegex' -and $when.urlRegex) { [string]$when.urlRegex } else { $null } + titleRegex = if ($when.PSObject.Properties.Name -contains 'titleRegex' -and $when.titleRegex) { [string]$when.titleRegex } else { $null } + hourFrom = if ($when.PSObject.Properties.Name -contains 'hourFrom') { [int]$when.hourFrom } else { $null } + hourTo = if ($when.PSObject.Properties.Name -contains 'hourTo') { [int]$when.hourTo } else { $null } + } + } + } + + $script:DlpRules = @($loaded) + Write-CollectorLog ("DLP-политика загружена: включена={0}, правил={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count) + } + catch { + Write-CollectorLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message) + } +} + +function Test-DlpRuleMatch { + param( + [pscustomobject]$Rule, + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $Rule.enabled) { + return $false + } + + $when = $Rule.when + $currentHour = (Get-Date).Hour + if (-not (Test-DlpRuleTimeWindow -CurrentHour $currentHour -HourFrom $when.hourFrom -HourTo $when.hourTo)) { + return $false + } + + if ($when.domains.Count -gt 0) { + $domainMatched = (Test-DomainListMatch -DomainHost $Domain -Domains $when.domains) -or (Test-DomainListMatch -DomainHost $RootDomain -Domains $when.domains) + if (-not $domainMatched) { + return $false + } + } + + if ($when.categoryGroups.Count -gt 0 -and ($when.categoryGroups -notcontains $CategoryGroup.ToLowerInvariant())) { + return $false + } + + if ($when.categories.Count -gt 0 -and ($when.categories -notcontains $Category.ToLowerInvariant())) { + return $false + } + + if ($when.browsers.Count -gt 0 -and ($when.browsers -notcontains $BrowserKey.ToLowerInvariant())) { + return $false + } + + if ($when.urlRegex) { + if (-not ($Url -match $when.urlRegex)) { + return $false + } + } + + if ($when.titleRegex) { + if (-not ($Title -match $when.titleRegex)) { + return $false + } + } + + return $true +} + +function Get-DlpDecision { + param( + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $script:DlpDefaults.enabled) { + return $null + } + + foreach ($rule in $script:DlpRules) { + if (Test-DlpRuleMatch -Rule $rule -Domain $Domain -RootDomain $RootDomain -Url $Url -Title $Title -BrowserKey $BrowserKey -Category $Category -CategoryGroup $CategoryGroup) { + return $rule + } + } + + return $null +} + +function Should-EmitIncident { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:IncidentState.ContainsKey($Fingerprint)) { + $last = [datetime]$script:IncidentState[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:IncidentState[$Fingerprint] = $now + return $true +} + +function Send-DlpIncidentHeartbeat { + param( + [pscustomobject]$Decision, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId ([string]$Decision.id) -SignalType 'web' + } + catch { + } + } + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = [string]$Decision.id + action = [string]$Decision.action + severity = [string]$Decision.severity + message = [string]$Decision.message + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + username = $env:USERNAME + hostname = $script:Hostname + sessionId = $script:SessionId + source = 'uia-native-dlp' + } + $captureData + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-CollectorLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message) + return @{} + } +} + +function Get-ForegroundWindowContext { + $handle = [NativeAwMethods]::GetForegroundWindow() + if ($handle -eq [IntPtr]::Zero) { + return $null + } + + $processId = [uint32]0 + [void][NativeAwMethods]::GetWindowThreadProcessId($handle, [ref]$processId) + if (-not $processId) { + return $null + } + + $process = Get-Process -Id ([int]$processId) -ErrorAction SilentlyContinue + if (-not $process) { + return $null + } + + $textLength = [NativeAwMethods]::GetWindowTextLength($handle) + $builder = [Text.StringBuilder]::new([Math]::Max($textLength + 1, 260)) + [void][NativeAwMethods]::GetWindowText($handle, $builder, $builder.Capacity) + + return [pscustomobject]@{ + Handle = $handle + ProcessName = $process.ProcessName.ToLowerInvariant() + Title = $builder.ToString() + } +} + +function Get-BrowserUrlFromWindow { + param([IntPtr]$Handle) + + $root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle) + if (-not $root) { + return $null + } + + $editCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Edit + ) + + $edits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $editCondition) + foreach ($edit in $edits) { + $valuePattern = $null + if ($edit.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$valuePattern)) { + $candidate = ConvertTo-NormalizedUrl -Value $valuePattern.Current.Value + if ($candidate) { + return $candidate + } + } + + $candidateFromName = ConvertTo-NormalizedUrl -Value $edit.Current.Name + if ($candidateFromName) { + return $candidateFromName + } + } + + return $null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType = 'web.tab.current' + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null + } + catch { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-Heartbeat { + param( + [string]$BucketId, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName + ) + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Send-CategoryHeartbeat { + param( + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup, + [string]$CategoryRule + ) + + $bucketId = 'aw-detmir-web-category_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-detmir-web-category' -BucketType 'aw.web.category' + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + categoryRule = $CategoryRule + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +Load-CustomCategoryRules -Path $resolvedRulesPath +Load-DlpPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("коллектор запущен для {0}" -f $script:ApiBase) + +while ($true) { + try { + $context = Get-ForegroundWindowContext + if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) { + $url = Get-BrowserUrlFromWindow -Handle $context.Handle + if ($url) { + $browserKey = $script:BrowserMap[$context.ProcessName] + $domain = Get-HostFromUrl -Url $url + if (-not $domain) { + $domain = 'unknown' + } + + $rootDomain = Get-RootDomain -DomainHost $domain + if (-not $rootDomain) { + $rootDomain = $domain + } + + $category = Get-WebCategory -DomainHost $domain + $bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey) + Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName + Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule + + $decision = Get-DlpDecision -Domain $domain -RootDomain $rootDomain -Url $url -Title $context.Title -BrowserKey $browserKey -Category $category.Name -CategoryGroup $category.Group + if ($decision) { + $fingerprint = '{0}|{1}|{2}|{3}' -f $decision.id, $browserKey, $rootDomain, $env:USERNAME + $cooldown = [Math]::Max([int]$decision.cooldownSeconds, 30) + if (Should-EmitIncident -Fingerprint $fingerprint -CooldownSeconds $cooldown) { + Write-DlpIncidentLog ("{0} {1} {2} {3}" -f $decision.severity, $decision.action, $decision.id, $url) + if (@('alert', 'block', 'quarantine') -contains ([string]$decision.action).ToLowerInvariant()) { + Send-DlpIncidentHeartbeat -Decision $decision -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group + } + } + } + } + } + } + catch { + Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + } + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = [string]$Decision.id + action = [string]$Decision.action + severity = [string]$Decision.severity + message = [string]$Decision.message + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + username = $env:USERNAME + hostname = $script:Hostname + sessionId = $script:SessionId + source = 'uia-native-dlp' + } + $captureData + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-CollectorLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message) + return @{} + } +} + +function Get-ForegroundWindowContext { + $handle = [NativeAwMethods]::GetForegroundWindow() + if ($handle -eq [IntPtr]::Zero) { + return $null + } + + $processId = [uint32]0 + [void][NativeAwMethods]::GetWindowThreadProcessId($handle, [ref]$processId) + if (-not $processId) { + return $null + } + + $process = Get-Process -Id ([int]$processId) -ErrorAction SilentlyContinue + if (-not $process) { + return $null + } + + $textLength = [NativeAwMethods]::GetWindowTextLength($handle) + $builder = [Text.StringBuilder]::new([Math]::Max($textLength + 1, 260)) + [void][NativeAwMethods]::GetWindowText($handle, $builder, $builder.Capacity) + + return [pscustomobject]@{ + Handle = $handle + ProcessName = $process.ProcessName.ToLowerInvariant() + Title = $builder.ToString() + } +} + +function Get-BrowserUrlFromWindow { + param([IntPtr]$Handle) + + $root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle) + if (-not $root) { + return $null + } + + $editCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Edit + ) + + $edits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $editCondition) + foreach ($edit in $edits) { + $valuePattern = $null + if ($edit.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$valuePattern)) { + $candidate = ConvertTo-NormalizedUrl -Value $valuePattern.Current.Value + if ($candidate) { + return $candidate + } + } + + $candidateFromName = ConvertTo-NormalizedUrl -Value $edit.Current.Name + if ($candidateFromName) { + return $candidateFromName + } + } + + return $null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType = 'web.tab.current' + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$RulesPath, + [string]$PolicyPath, + [string]$LogPath, + [string]$IncidentLogPath, + [int]$PollSeconds, + [int]$PulseSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class NativeAwMethods { + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern int GetWindowTextLength(IntPtr hWnd); +} +"@ + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\AWatch-rus\web-category-rules.json' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) } +$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentLogPath = $resolvedIncidentLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false +$script:IncidentState = @{} +$script:DlpRules = @() +$script:DlpDefaults = [ordered]@{ + enabled = $false + cooldownSeconds = 300 + action = 'log' + severity = 'low' +} +$script:BrowserMap = @{ + msedge = 'edge' + chrome = 'chrome' + brave = 'brave' + vivaldi = 'vivaldi' + opera = 'opera' + firefox = 'firefox' +} +$script:CategoryRules = @( + @{ Name = 'work_business_systems'; Group = 'work'; Domains = @('bitrix24.ru', '1c.ru', 'sbis.ru', 'kontur.ru', 'diadoc.ru', 'nalog.gov.ru', 'gosuslugi.ru') } + @{ Name = 'work_docs_collab'; Group = 'work'; Domains = @('office.com', 'sharepoint.com', 'docs.google.com', 'drive.google.com', 'notion.so', 'miro.com') } + @{ Name = 'work_dev'; Group = 'work'; Domains = @('github.com', 'gitlab.com', 'bitbucket.org', 'youtrack.cloud', 'atlassian.net') } + @{ Name = 'work_communication'; Group = 'work'; Domains = @('teams.microsoft.com', 'outlook.office.com', 'web.telegram.org', 'slack.com', 'zoom.us') } + @{ Name = 'neutral_search_reference'; Group = 'neutral'; Domains = @('google.com', 'google.ru', 'yandex.ru', 'bing.com', 'duckduckgo.com', 'wikipedia.org') } + @{ Name = 'neutral_news'; Group = 'neutral'; Domains = @('rbc.ru', 'tass.ru', 'ria.ru', 'kommersant.ru', 'vedomosti.ru') } + @{ Name = 'personal_social'; Group = 'personal'; Domains = @('vk.com', 'ok.ru', 'facebook.com', 'instagram.com', 'tiktok.com', 'x.com', 'twitter.com') } + @{ Name = 'personal_video'; Group = 'personal'; Domains = @('youtube.com', 'youtu.be', 'rutube.ru', 'twitch.tv', 'kinopoisk.ru') } + @{ Name = 'personal_marketplace'; Group = 'personal'; Domains = @('ozon.ru', 'wildberries.ru', 'avito.ru', 'aliexpress.com', 'market.yandex.ru') } + @{ Name = 'personal_entertainment'; Group = 'personal'; Domains = @('dzen.ru', 'pikabu.ru', 'dtf.ru', 'playground.ru') } +) + +function Write-CollectorLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Write-DlpIncidentLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:IncidentLogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Test-DomainMatch { + param( + [string]$DomainHost, + [string]$RuleDomain + ) + + if ([string]::IsNullOrWhiteSpace($DomainHost) -or [string]::IsNullOrWhiteSpace($RuleDomain)) { + return $false + } + + $left = $DomainHost.ToLowerInvariant() + $right = $RuleDomain.ToLowerInvariant() + return $left -eq $right -or $left.EndsWith('.' + $right) +} + +function Get-HostFromUrl { + param([string]$Url) + + if ([string]::IsNullOrWhiteSpace($Url)) { + return $null + } + + try { + $uri = [Uri]$Url + $uriHost = $uri.Host.ToLowerInvariant() + if ($uriHost.StartsWith('www.')) { + return $uriHost.Substring(4) + } + + return $uriHost + } + catch { + return $null + } +} + +function Get-RootDomain { + param([string]$DomainHost) + + if ([string]::IsNullOrWhiteSpace($DomainHost)) { + return $null + } + + $parts = $DomainHost.Split('.') + if ($parts.Count -le 2) { + return $DomainHost + } + + $suffix = ('{0}.{1}' -f $parts[$parts.Count - 2], $parts[$parts.Count - 1]).ToLowerInvariant() + $compoundTlds = @('co.uk', 'com.au', 'co.jp', 'com.br', 'co.in', 'com.tr', 'com.cn') + if (($compoundTlds -contains $suffix) -and $parts.Count -ge 3) { + return ('{0}.{1}' -f $parts[$parts.Count - 3], $suffix).ToLowerInvariant() + } + + return $suffix +} + +function ConvertTo-NormalizedUrl { + param([AllowNull()][string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + $candidate = $Value.Trim() + if ($candidate.Length -lt 4) { + return $null + } + + if ($candidate -match '^(?i)(search|find|address and search|search with|новая вкладка|new tab)') { + return $null + } + + if ($candidate -match '^(?i)(https?|file|ftp|chrome|edge|about|view-source)://') { + return $candidate + } + + if ($candidate -match '^(?i)localhost([/:]|$)') { + return "http://$candidate" + } + + if ($candidate -match '^[a-z0-9.-]+\.[a-z]{2,}([/:?#].*)?$') { + return "https://$candidate" + } + + return $null +} + +function Load-CustomCategoryRules { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $rules = @() + + if ($parsed.rules) { + $sourceRules = @($parsed.rules) + } + elseif ($parsed -is [System.Collections.IEnumerable]) { + $sourceRules = @($parsed) + } + else { + $sourceRules = @() + } + + foreach ($rule in $sourceRules) { + if (-not $rule) { + continue + } + + $name = [string]$rule.name + $group = [string]$rule.group + $domains = @($rule.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) + + if ($name -and $group -and $domains.Count -gt 0) { + $rules += @{ + Name = $name + Group = $group + Domains = $domains + } + } + } + + if ($rules.Count -gt 0) { + $script:CategoryRules = @($rules) + @($script:CategoryRules) + Write-CollectorLog ("пользовательские правила загружены: {0}" -f $rules.Count) + } + } + catch { + Write-CollectorLog ("не удалось загрузить пользовательские правила: {0}" -f $_.Exception.Message) + } +} + +function Get-WebCategory { + param([string]$DomainHost) + + foreach ($rule in $script:CategoryRules) { + foreach ($domain in $rule.Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return [pscustomobject]@{ + Name = [string]$rule.Name + Group = [string]$rule.Group + Rule = [string]$domain + } + } + } + } + + return [pscustomobject]@{ + Name = 'uncategorized' + Group = 'neutral' + Rule = 'none' + } +} + +function Test-DomainListMatch { + param( + [string]$DomainHost, + [string[]]$Domains + ) + + if (-not $Domains -or $Domains.Count -eq 0) { + return $false + } + + foreach ($domain in $Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return $true + } + } + + return $false +} + +function Test-DlpRuleTimeWindow { + param( + [int]$CurrentHour, + [AllowNull()][int]$HourFrom, + [AllowNull()][int]$HourTo + ) + + if ($null -eq $HourFrom -or $null -eq $HourTo) { + return $true + } + + if ($HourFrom -eq $HourTo) { + return $true + } + + if ($HourFrom -lt $HourTo) { + return ($CurrentHour -ge $HourFrom -and $CurrentHour -lt $HourTo) + } + + return ($CurrentHour -ge $HourFrom -or $CurrentHour -lt $HourTo) +} + +function Load-DlpPolicy { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("DLP-политика не найдена, DLP отключен: {0}" -f $Path) + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $defaults = $parsed.defaults + if ($defaults) { + if ($defaults.PSObject.Properties.Name -contains 'enabled') { + $script:DlpDefaults.enabled = [bool]$defaults.enabled + } + if ($defaults.cooldownSeconds) { + $script:DlpDefaults.cooldownSeconds = [int]$defaults.cooldownSeconds + } + if ($defaults.action) { + $script:DlpDefaults.action = [string]$defaults.action + } + if ($defaults.severity) { + $script:DlpDefaults.severity = [string]$defaults.severity + } + } + + $loaded = @() + foreach ($rule in @($parsed.rules)) { + if (-not $rule) { continue } + $when = $rule.when + if (-not $when) { + $when = [pscustomobject]@{} + } + $loaded += [pscustomobject]@{ + id = [string]$rule.id + enabled = if ($rule.PSObject.Properties.Name -contains 'enabled') { [bool]$rule.enabled } else { $true } + action = if ($rule.action) { [string]$rule.action } else { [string]$script:DlpDefaults.action } + severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:DlpDefaults.severity } + message = if ($rule.message) { [string]$rule.message } else { "Сработало DLP-правило: $($rule.id)" } + cooldownSeconds = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:DlpDefaults.cooldownSeconds } + when = [pscustomobject]@{ + domains = if ($when.PSObject.Properties.Name -contains 'domains') { @($when.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categoryGroups = if ($when.PSObject.Properties.Name -contains 'categoryGroups') { @($when.categoryGroups | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categories = if ($when.PSObject.Properties.Name -contains 'categories') { @($when.categories | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + browsers = if ($when.PSObject.Properties.Name -contains 'browsers') { @($when.browsers | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + urlRegex = if ($when.PSObject.Properties.Name -contains 'urlRegex' -and $when.urlRegex) { [string]$when.urlRegex } else { $null } + titleRegex = if ($when.PSObject.Properties.Name -contains 'titleRegex' -and $when.titleRegex) { [string]$when.titleRegex } else { $null } + hourFrom = if ($when.PSObject.Properties.Name -contains 'hourFrom') { [int]$when.hourFrom } else { $null } + hourTo = if ($when.PSObject.Properties.Name -contains 'hourTo') { [int]$when.hourTo } else { $null } + } + } + } + + $script:DlpRules = @($loaded) + Write-CollectorLog ("DLP-политика загружена: включена={0}, правил={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count) + } + catch { + Write-CollectorLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message) + } +} + +function Test-DlpRuleMatch { + param( + [pscustomobject]$Rule, + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $Rule.enabled) { + return $false + } + + $when = $Rule.when + $currentHour = (Get-Date).Hour + if (-not (Test-DlpRuleTimeWindow -CurrentHour $currentHour -HourFrom $when.hourFrom -HourTo $when.hourTo)) { + return $false + } + + if ($when.domains.Count -gt 0) { + $domainMatched = (Test-DomainListMatch -DomainHost $Domain -Domains $when.domains) -or (Test-DomainListMatch -DomainHost $RootDomain -Domains $when.domains) + if (-not $domainMatched) { + return $false + } + } + + if ($when.categoryGroups.Count -gt 0 -and ($when.categoryGroups -notcontains $CategoryGroup.ToLowerInvariant())) { + return $false + } + + if ($when.categories.Count -gt 0 -and ($when.categories -notcontains $Category.ToLowerInvariant())) { + return $false + } + + if ($when.browsers.Count -gt 0 -and ($when.browsers -notcontains $BrowserKey.ToLowerInvariant())) { + return $false + } + + if ($when.urlRegex) { + if (-not ($Url -match $when.urlRegex)) { + return $false + } + } + + if ($when.titleRegex) { + if (-not ($Title -match $when.titleRegex)) { + return $false + } + } + + return $true +} + +function Get-DlpDecision { + param( + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $script:DlpDefaults.enabled) { + return $null + } + + foreach ($rule in $script:DlpRules) { + if (Test-DlpRuleMatch -Rule $rule -Domain $Domain -RootDomain $RootDomain -Url $Url -Title $Title -BrowserKey $BrowserKey -Category $Category -CategoryGroup $CategoryGroup) { + return $rule + } + } + + return $null +} + +function Should-EmitIncident { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:IncidentState.ContainsKey($Fingerprint)) { + $last = [datetime]$script:IncidentState[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:IncidentState[$Fingerprint] = $now + return $true +} + +function Send-DlpIncidentHeartbeat { + param( + [pscustomobject]$Decision, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId ([string]$Decision.id) -SignalType 'web' + } + catch { + } + } + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = [string]$Decision.id + action = [string]$Decision.action + severity = [string]$Decision.severity + message = [string]$Decision.message + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + username = $env:USERNAME + hostname = $script:Hostname + sessionId = $script:SessionId + source = 'uia-native-dlp' + } + $captureData + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-CollectorLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message) + return @{} + } +} + +function Get-ForegroundWindowContext { + $handle = [NativeAwMethods]::GetForegroundWindow() + if ($handle -eq [IntPtr]::Zero) { + return $null + } + + $processId = [uint32]0 + [void][NativeAwMethods]::GetWindowThreadProcessId($handle, [ref]$processId) + if (-not $processId) { + return $null + } + + $process = Get-Process -Id ([int]$processId) -ErrorAction SilentlyContinue + if (-not $process) { + return $null + } + + $textLength = [NativeAwMethods]::GetWindowTextLength($handle) + $builder = [Text.StringBuilder]::new([Math]::Max($textLength + 1, 260)) + [void][NativeAwMethods]::GetWindowText($handle, $builder, $builder.Capacity) + + return [pscustomobject]@{ + Handle = $handle + ProcessName = $process.ProcessName.ToLowerInvariant() + Title = $builder.ToString() + } +} + +function Get-BrowserUrlFromWindow { + param([IntPtr]$Handle) + + $root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle) + if (-not $root) { + return $null + } + + $editCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Edit + ) + + $edits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $editCondition) + foreach ($edit in $edits) { + $valuePattern = $null + if ($edit.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$valuePattern)) { + $candidate = ConvertTo-NormalizedUrl -Value $valuePattern.Current.Value + if ($candidate) { + return $candidate + } + } + + $candidateFromName = ConvertTo-NormalizedUrl -Value $edit.Current.Name + if ($candidateFromName) { + return $candidateFromName + } + } + + return $null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType = 'web.tab.current' + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null + } + catch { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-Heartbeat { + param( + [string]$BucketId, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName + ) + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Send-CategoryHeartbeat { + param( + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup, + [string]$CategoryRule + ) + + $bucketId = 'aw-detmir-web-category_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-detmir-web-category' -BucketType 'aw.web.category' + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + categoryRule = $CategoryRule + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +Load-CustomCategoryRules -Path $resolvedRulesPath +Load-DlpPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("коллектор запущен для {0}" -f $script:ApiBase) + +while ($true) { + try { + $context = Get-ForegroundWindowContext + if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) { + $url = Get-BrowserUrlFromWindow -Handle $context.Handle + if ($url) { + $browserKey = $script:BrowserMap[$context.ProcessName] + $domain = Get-HostFromUrl -Url $url + if (-not $domain) { + $domain = 'unknown' + } + + $rootDomain = Get-RootDomain -DomainHost $domain + if (-not $rootDomain) { + $rootDomain = $domain + } + + $category = Get-WebCategory -DomainHost $domain + $bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey) + Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName + Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule + + $decision = Get-DlpDecision -Domain $domain -RootDomain $rootDomain -Url $url -Title $context.Title -BrowserKey $browserKey -Category $category.Name -CategoryGroup $category.Group + if ($decision) { + $fingerprint = '{0}|{1}|{2}|{3}' -f $decision.id, $browserKey, $rootDomain, $env:USERNAME + $cooldown = [Math]::Max([int]$decision.cooldownSeconds, 30) + if (Should-EmitIncident -Fingerprint $fingerprint -CooldownSeconds $cooldown) { + Write-DlpIncidentLog ("{0} {1} {2} {3}" -f $decision.severity, $decision.action, $decision.id, $url) + if (@('alert', 'block', 'quarantine') -contains ([string]$decision.action).ToLowerInvariant()) { + Send-DlpIncidentHeartbeat -Decision $decision -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group + } + } + } + } + } + } + catch { + Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null + } + catch { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-Heartbeat { + param( + [string]$BucketId, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName + ) + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Send-CategoryHeartbeat { + param( + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup, + [string]$CategoryRule + ) + + $bucketId = 'aw-detmir-web-category_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-detmir-web-category' -BucketType 'aw.web.category' + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + categoryRule = $CategoryRule + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +Load-CustomCategoryRules -Path $resolvedRulesPath +Load-DlpPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("коллектор запущен для {0}" -f $script:ApiBase) + +while ($true) { + try { + $context = Get-ForegroundWindowContext + if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) { + $url = Get-BrowserUrlFromWindow -Handle $context.Handle + if ($url) { + $browserKey = $script:BrowserMap[$context.ProcessName] + $domain = Get-HostFromUrl -Url $url + if (-not $domain) { + $domain = 'unknown' + } + + $rootDomain = Get-RootDomain -DomainHost $domain + if (-not $rootDomain) { + $rootDomain = $domain + } + + $category = Get-WebCategory -DomainHost $domain + $bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey) + Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName + Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule + + $decision = Get-DlpDecision -Domain $domain -RootDomain $rootDomain -Url $url -Title $context.Title -BrowserKey $browserKey -Category $category.Name -CategoryGroup $category.Group + if ($decision) { + $fingerprint = '{0}|{1}|{2}|{3}' -f $decision.id, $browserKey, $rootDomain, $env:USERNAME + $cooldown = [Math]::Max([int]$decision.cooldownSeconds, 30) + if (Should-EmitIncident -Fingerprint $fingerprint -CooldownSeconds $cooldown) { + Write-DlpIncidentLog ("{0} {1} {2} {3}" -f $decision.severity, $decision.action, $decision.id, $url) + if (@('alert', 'block', 'quarantine') -contains ([string]$decision.action).ToLowerInvariant()) { + Send-DlpIncidentHeartbeat -Decision $decision -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group + } + } + } + } + } + } + catch { + Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} diff --git a/windows/deploy-domain-users.ps1 b/windows/deploy-domain-users.ps1 index 92e5494..8a83b16 100755 --- a/windows/deploy-domain-users.ps1 +++ b/windows/deploy-domain-users.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ServerHost, @@ -112,8 +112,8 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName -Write-Host 'ActivityWatch развёрнут для пользователей:' -$targetUsers | ForEach-Object { Write-Host " - $_" } -Write-Host "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort" -Write-Host "Каталог данных: $StateRoot" -Write-Host "Файл DLP-политики: $($assetResult.ActivePolicy)" +Write-Output 'ActivityWatch развёрнут для пользователей:' +$targetUsers | ForEach-Object { Write-Output " - $_" } +Write-Output "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort" +Write-Output "Каталог данных: $StateRoot" +Write-Output "Файл DLP-политики: $($assetResult.ActivePolicy)" diff --git a/windows/deploy-ensemble.ps1 b/windows/deploy-ensemble.ps1 index fa6fd75..1efc953 100644 --- a/windows/deploy-ensemble.ps1 +++ b/windows/deploy-ensemble.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ServerHost, @@ -136,6 +136,6 @@ if ($reportDirectory) { $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $effectiveReportPath -Encoding UTF8 -Write-Host 'Комплексное развёртывание ActivityWatch завершено.' -Write-Host "Пользователи: $($resolvedUsers -join ', ')" -Write-Host "Отчёт: $effectiveReportPath" +Write-Output 'Комплексное развёртывание ActivityWatch завершено.' +Write-Output "Пользователи: $($resolvedUsers -join ', ')" +Write-Output "Отчёт: $effectiveReportPath" diff --git a/windows/deploy-single-user.ps1 b/windows/deploy-single-user.ps1 index 160265d..7f4952a 100755 --- a/windows/deploy-single-user.ps1 +++ b/windows/deploy-single-user.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ServerHost, @@ -104,9 +104,9 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName -Write-Host "ActivityWatch развёрнут для пользователя: $TargetUser" -Write-Host "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort" -Write-Host "Каталог установки: $InstallRoot" -Write-Host "Каталог данных: $StateRoot" -Write-Host "Файл правил: $($assetResult.ActiveRules)" -Write-Host "Файл DLP-политики: $($assetResult.ActivePolicy)" +Write-Output "ActivityWatch развёрнут для пользователя: $TargetUser" +Write-Output "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort" +Write-Output "Каталог установки: $InstallRoot" +Write-Output "Каталог данных: $StateRoot" +Write-Output "Файл правил: $($assetResult.ActiveRules)" +Write-Output "Файл DLP-политики: $($assetResult.ActivePolicy)" diff --git a/windows/dlp-endpoint-signals-collector.ps1 b/windows/dlp-endpoint-signals-collector.ps1 index 0b2f810..5a09101 100644 --- a/windows/dlp-endpoint-signals-collector.ps1 +++ b/windows/dlp-endpoint-signals-collector.ps1 @@ -21,6 +21,37 @@ function Get-DeploymentConfig { return $null } +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + function Write-EndpointLog { param([string]$Message) if (-not $script:LocalAgentLogsEnabled) { @@ -957,3 +988,6678 @@ while ($true) { Start-Sleep -Seconds $resolvedPollSeconds } +; } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { + } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { + } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { + } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { + } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { + } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { + } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { + } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { + } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { + } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { + } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { + } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { + } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { + } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { + } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { + } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { + } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { + } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { + } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { + } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { + } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { + } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $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 } + $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" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { + } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { + } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { + } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} diff --git a/windows/email-outbound-collector.ps1 b/windows/email-outbound-collector.ps1 index 7a1600f..078533c 100644 --- a/windows/email-outbound-collector.ps1 +++ b/windows/email-outbound-collector.ps1 @@ -1,4 +1,56 @@ -<# +<# +.SYNOPSIS + DLP email outbound collector for AWatch-rus (Phase 2.5). + Monitors outgoing email via Outlook COM Sent Items polling + and/or SMTP network connection detection. + +.DESCRIPTION + Two collection modes (configurable, can run simultaneously): + - outlook : Polls Outlook Sent Items via COM for new messages. + - smtp : Monitors SMTP connections (ports 25/587/465) via + Get-NetTCPConnection for any process sending mail. + + Sends heartbeats to AW bucket `aw-email-monitor_`. + Evaluates DLP policy rules from `endpoint.email[]` section. + Supports enforcement: action="block" moves the email to Drafts + (Outlook mode) or logs with enforced=false (SMTP mode). +#> +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds, + [ValidateSet('outlook', 'smtp', 'both')] + [string]$Mode = 'both' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Shared infrastructure (mirrors other collectors) +# --------------------------------------------------------------------------- + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-CollectorLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { return } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { Write-Error <# .SYNOPSIS DLP email outbound collector for AWatch-rus (Phase 2.5). Monitors outgoing email via Outlook COM Sent Items polling @@ -580,3 +632,2279 @@ while ($true) { Start-Sleep -Seconds $resolvedPollSeconds } +; } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + if ($script:KnownBuckets.ContainsKey($BucketId)) { return } + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { $sha.Dispose() } +} + +function Send-EmailHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + $bucketId = 'aw-email-monitor_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-email-monitor' -BucketType 'aw.dlp.email' + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'email-outbound-collector' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-EmailIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [hashtable]$Data + ) + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = 'email_outbound' + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'email-outbound-collector' + } + $Data + } | ConvertTo-Json -Depth 7 -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { Write-Error <# +.SYNOPSIS + DLP email outbound collector for AWatch-rus (Phase 2.5). + Monitors outgoing email via Outlook COM Sent Items polling + and/or SMTP network connection detection. + +.DESCRIPTION + Two collection modes (configurable, can run simultaneously): + - outlook : Polls Outlook Sent Items via COM for new messages. + - smtp : Monitors SMTP connections (ports 25/587/465) via + Get-NetTCPConnection for any process sending mail. + + Sends heartbeats to AW bucket `aw-email-monitor_`. + Evaluates DLP policy rules from `endpoint.email[]` section. + Supports enforcement: action="block" moves the email to Drafts + (Outlook mode) or logs with enforced=false (SMTP mode). +#> +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds, + [ValidateSet('outlook', 'smtp', 'both')] + [string]$Mode = 'both' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Shared infrastructure (mirrors other collectors) +# --------------------------------------------------------------------------- + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-CollectorLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { return } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + if ($script:KnownBuckets.ContainsKey($BucketId)) { return } + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { $sha.Dispose() } +} + +function Send-EmailHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + $bucketId = 'aw-email-monitor_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-email-monitor' -BucketType 'aw.dlp.email' + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'email-outbound-collector' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-EmailIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [hashtable]$Data + ) + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = 'email_outbound' + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'email-outbound-collector' + } + $Data + } | ConvertTo-Json -Depth 7 -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { } +} + +# --------------------------------------------------------------------------- +# DLP Policy +# --------------------------------------------------------------------------- + +function Load-EmailPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + email = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + if ($raw.endpoint -and $raw.endpoint.email) { + $script:Policy.endpoint.email = @($raw.endpoint.email) + } + } + catch { + Write-CollectorLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + $script:Cooldown[$Fingerprint] = $now + return $true +} + +# --------------------------------------------------------------------------- +# Email DLP rule evaluation +# --------------------------------------------------------------------------- + +function Evaluate-EmailRules { + param( + [string]$Subject, + [string]$RecipientsJoined, + [string]$SenderAddress, + [int]$AttachmentCount, + [string]$AttachmentNames, + [int]$BodyLength, + [string]$MessageId, + $OutlookMailItem + ) + + foreach ($rule in @($script:Policy.endpoint.email)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $matched = $true + + if ($rule.subjectRegex) { + $matched = $matched -and ($Subject -match [string]$rule.subjectRegex) + } + if ($rule.recipientRegex) { + $matched = $matched -and ($RecipientsJoined -match [string]$rule.recipientRegex) + } + if ($rule.senderRegex) { + $matched = $matched -and ($SenderAddress -match [string]$rule.senderRegex) + } + if ($rule.attachmentRegex -and $AttachmentNames) { + $matched = $matched -and ($AttachmentNames -match [string]$rule.attachmentRegex) + } + if ($rule.minAttachments) { + $matched = $matched -and ($AttachmentCount -ge [int]$rule.minAttachments) + } + if ($rule.minBodyLength) { + $matched = $matched -and ($BodyLength -ge [int]$rule.minBodyLength) + } + if ($rule.externalOnly -and [bool]$rule.externalOnly) { + $internalDomain = if ($rule.internalDomain) { [string]$rule.internalDomain } else { '' } + if ($internalDomain -and $RecipientsJoined -notmatch [regex]::Escape($internalDomain)) { + # all recipients are external — continue matching + } + elseif ($internalDomain) { + $matched = $false + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "email|$ruleId|$MessageId|$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 } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Email rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block' -and $null -ne $OutlookMailItem) { + $enforced = Invoke-EmailEnforcement -MailItem $OutlookMailItem -RuleId $ruleId + Show-EnforcementNotification -Title 'DLP: письмо перемещено в черновики' -Body $message + } + elseif ($action -eq 'block') { + Show-EnforcementNotification -Title 'DLP: обнаружена отправка письма' -Body $message + } + + Send-EmailIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -Data @{ + subject = (Get-StringHash -Value $Subject) + recipients = (Get-StringHash -Value $RecipientsJoined) + sender = $SenderAddress + attachmentCount = $AttachmentCount + attachmentNames = $AttachmentNames + bodyLength = $BodyLength + enforced = $enforced + } + Write-CollectorLog ("incident email rule={0} action={1} severity={2} enforced={3} subject_hash={4}" -f $ruleId, $action, $severity, $enforced, (Get-StringHash -Value $Subject)) + } +} + +function Invoke-EmailEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)]$MailItem, + [string]$RuleId + ) + try { + $draftsFolder = $script:OutlookNamespace.GetDefaultFolder(16) # olFolderDrafts + $MailItem.Move($draftsFolder) | Out-Null + Write-CollectorLog ("enforcement: email moved to Drafts rule={0} subject_hash={1}" -f $RuleId, (Get-StringHash -Value $MailItem.Subject)) + return $true + } + catch { + Write-CollectorLog ("enforcement: email move to Drafts failed rule={0}: {1}" -f $RuleId, $_.Exception.Message) + return $false + } +} + +# --------------------------------------------------------------------------- +# Outlook Sent Items polling +# --------------------------------------------------------------------------- + +function Initialize-OutlookCom { + try { + $script:OutlookApp = New-Object -ComObject Outlook.Application + $script:OutlookNamespace = $script:OutlookApp.GetNamespace('MAPI') + $script:SentFolder = $script:OutlookNamespace.GetDefaultFolder(5) # olFolderSentMail + Write-CollectorLog "Outlook COM initialized, Sent Items folder opened" + return $true + } + catch { + Write-CollectorLog ("Outlook COM init failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Get-OutlookSentItems { + param([datetime]$Since) + + $results = @() + try { + $items = $script:SentFolder.Items + $items.Sort('[SentOn]', $true) + + $filter = "[SentOn] >= '{0}'" -f $Since.ToString('MM/dd/yyyy HH:mm') + $restricted = $items.Restrict($filter) + + foreach ($item in $restricted) { + try { + if ($item.Class -ne 43) { continue } # olMail = 43 + + $recipients = @() + for ($i = 1; $i -le $item.Recipients.Count; $i++) { + $recip = $item.Recipients.Item($i) + $recipients += [string]$recip.Address + } + + $attachmentNames = @() + for ($i = 1; $i -le $item.Attachments.Count; $i++) { + $attachmentNames += [string]$item.Attachments.Item($i).FileName + } + + $results += [pscustomobject]@{ + EntryID = [string]$item.EntryID + Subject = [string]$item.Subject + SenderAddress = [string]$item.SenderEmailAddress + SenderName = [string]$item.SenderName + Recipients = $recipients + RecipientsJoined = ($recipients -join '; ') + AttachmentCount = [int]$item.Attachments.Count + AttachmentNames = ($attachmentNames -join '; ') + BodyLength = if ($item.Body) { $item.Body.Length } else { 0 } + SentOn = $item.SentOn + MailItem = $item + } + } + catch { } + } + } + catch { + Write-CollectorLog ("Outlook Sent Items scan failed: {0}" -f $_.Exception.Message) + } + return $results +} + +function Poll-OutlookSentItems { + $items = Get-OutlookSentItems -Since $script:OutlookLastPoll + + foreach ($item in $items) { + $entryId = $item.EntryID + if ($script:SeenEntryIds.ContainsKey($entryId)) { continue } + $script:SeenEntryIds[$entryId] = (Get-Date).ToUniversalTime() + + $subjectHash = Get-StringHash -Value $item.Subject + + Send-EmailHeartbeat -SignalType 'email_sent' -Data @{ + subject = $subjectHash + sender = [string]$item.SenderAddress + senderName = [string]$item.SenderName + recipientCount = $item.Recipients.Count + recipients = (Get-StringHash -Value $item.RecipientsJoined) + attachmentCount = [int]$item.AttachmentCount + attachmentNames = [string]$item.AttachmentNames + bodyLength = [int]$item.BodyLength + sentOn = if ($item.SentOn) { $item.SentOn.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } else { '' } + collectionMode = 'outlook' + } + Write-CollectorLog ("email_sent outlook subject_hash={0} to={1} attachments={2}" -f $subjectHash, $item.Recipients.Count, $item.AttachmentCount) + + Evaluate-EmailRules ` + -Subject $item.Subject ` + -RecipientsJoined $item.RecipientsJoined ` + -SenderAddress $item.SenderAddress ` + -AttachmentCount $item.AttachmentCount ` + -AttachmentNames $item.AttachmentNames ` + -BodyLength $item.BodyLength ` + -MessageId $entryId ` + -OutlookMailItem $item.MailItem + } + + $script:OutlookLastPoll = (Get-Date).AddSeconds(-10) + + # Cleanup old entry IDs (keep last 24h) + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-24) + foreach ($k in @($script:SeenEntryIds.Keys)) { + if ([datetime]$script:SeenEntryIds[$k] -lt $cleanupBefore) { + $script:SeenEntryIds.Remove($k) + } + } +} + +# --------------------------------------------------------------------------- +# SMTP network connection monitoring +# --------------------------------------------------------------------------- + +function Poll-SmtpConnections { + try { + $smtpPorts = @(25, 587, 465, 2525) + $connections = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue | + Where-Object { $smtpPorts -contains $_.RemotePort } + + foreach ($conn in @($connections)) { + $processId = [int]$conn.OwningProcess + $remoteAddr = [string]$conn.RemoteAddress + $remotePort = [int]$conn.RemotePort + $fingerprint = "{0}:{1}:{2}" -f $processId, $remoteAddr, $remotePort + if ($script:SeenSmtpConnections.ContainsKey($fingerprint)) { continue } + $script:SeenSmtpConnections[$fingerprint] = (Get-Date).ToUniversalTime() + + $processName = '' + try { + $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue + $processName = [string]$proc.ProcessName + } + catch { } + + Send-EmailHeartbeat -SignalType 'smtp_connection' -Data @{ + remoteAddress = $remoteAddr + remotePort = $remotePort + processId = $processId + processName = $processName + localPort = [int]$conn.LocalPort + collectionMode = 'smtp' + } + Write-CollectorLog ("smtp_connection process={0}({1}) remote={2}:{3}" -f $processName, $processId, $remoteAddr, $remotePort) + + Evaluate-EmailRules ` + -Subject '' ` + -RecipientsJoined $remoteAddr ` + -SenderAddress $env:USERNAME ` + -AttachmentCount 0 ` + -AttachmentNames '' ` + -BodyLength 0 ` + -MessageId $fingerprint ` + -OutlookMailItem $null + } + + # Cleanup old SMTP connections (keep last 8h) + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenSmtpConnections.Keys)) { + if ([datetime]$script:SeenSmtpConnections[$k] -lt $cleanupBefore) { + $script:SeenSmtpConnections.Remove($k) + } + } + } + catch { + Write-CollectorLog ("SMTP poll error: {0}" -f $_.Exception.Message) + } +} + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 10 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("email-outbound-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenEntryIds = @{} +$script:SeenSmtpConnections = @{} +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:OutlookApp = $null +$script:OutlookNamespace = $null +$script:SentFolder = $null +$script:OutlookLastPoll = (Get-Date).AddMinutes(-5) + +Load-EmailPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("email collector started mode={0} against {1}" -f $Mode, $script:ApiBase) + +$useOutlook = ($Mode -eq 'outlook' -or $Mode -eq 'both') +$useSmtp = ($Mode -eq 'smtp' -or $Mode -eq 'both') +$outlookReady = $false + +if ($useOutlook) { + $outlookReady = Initialize-OutlookCom + if (-not $outlookReady -and $Mode -eq 'outlook') { + Write-CollectorLog "Outlook COM not available, collector will retry" + } +} + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + +while ($true) { + try { + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + if ($useOutlook) { + if (-not $outlookReady) { + $outlookReady = Initialize-OutlookCom + } + if ($outlookReady) { + try { + Poll-OutlookSentItems + } + catch { + Write-CollectorLog ("outlook poll error: {0}" -f $_.Exception.Message) + $outlookReady = $false + $script:OutlookApp = $null + $script:OutlookNamespace = $null + $script:SentFolder = $null + } + } + } + + if ($useSmtp) { + try { + Poll-SmtpConnections + } + catch { + Write-CollectorLog ("smtp poll error: {0}" -f $_.Exception.Message) + } + } + } + catch { + Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } +} + +# --------------------------------------------------------------------------- +# DLP Policy +# --------------------------------------------------------------------------- + +function Load-EmailPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + email = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + if ($raw.endpoint -and $raw.endpoint.email) { + $script:Policy.endpoint.email = @($raw.endpoint.email) + } + } + catch { + Write-CollectorLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + $script:Cooldown[$Fingerprint] = $now + return $true +} + +# --------------------------------------------------------------------------- +# Email DLP rule evaluation +# --------------------------------------------------------------------------- + +function Evaluate-EmailRules { + param( + [string]$Subject, + [string]$RecipientsJoined, + [string]$SenderAddress, + [int]$AttachmentCount, + [string]$AttachmentNames, + [int]$BodyLength, + [string]$MessageId, + $OutlookMailItem + ) + + foreach ($rule in @($script:Policy.endpoint.email)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $matched = $true + + if ($rule.subjectRegex) { + $matched = $matched -and ($Subject -match [string]$rule.subjectRegex) + } + if ($rule.recipientRegex) { + $matched = $matched -and ($RecipientsJoined -match [string]$rule.recipientRegex) + } + if ($rule.senderRegex) { + $matched = $matched -and ($SenderAddress -match [string]$rule.senderRegex) + } + if ($rule.attachmentRegex -and $AttachmentNames) { + $matched = $matched -and ($AttachmentNames -match [string]$rule.attachmentRegex) + } + if ($rule.minAttachments) { + $matched = $matched -and ($AttachmentCount -ge [int]$rule.minAttachments) + } + if ($rule.minBodyLength) { + $matched = $matched -and ($BodyLength -ge [int]$rule.minBodyLength) + } + if ($rule.externalOnly -and [bool]$rule.externalOnly) { + $internalDomain = if ($rule.internalDomain) { [string]$rule.internalDomain } else { '' } + if ($internalDomain -and $RecipientsJoined -notmatch [regex]::Escape($internalDomain)) { + # all recipients are external — continue matching + } + elseif ($internalDomain) { + $matched = $false + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "email|$ruleId|$MessageId|$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 } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Email rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block' -and $null -ne $OutlookMailItem) { + $enforced = Invoke-EmailEnforcement -MailItem $OutlookMailItem -RuleId $ruleId + Show-EnforcementNotification -Title 'DLP: письмо перемещено в черновики' -Body $message + } + elseif ($action -eq 'block') { + Show-EnforcementNotification -Title 'DLP: обнаружена отправка письма' -Body $message + } + + Send-EmailIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -Data @{ + subject = (Get-StringHash -Value $Subject) + recipients = (Get-StringHash -Value $RecipientsJoined) + sender = $SenderAddress + attachmentCount = $AttachmentCount + attachmentNames = $AttachmentNames + bodyLength = $BodyLength + enforced = $enforced + } + Write-CollectorLog ("incident email rule={0} action={1} severity={2} enforced={3} subject_hash={4}" -f $ruleId, $action, $severity, $enforced, (Get-StringHash -Value $Subject)) + } +} + +function Invoke-EmailEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)]$MailItem, + [string]$RuleId + ) + try { + $draftsFolder = $script:OutlookNamespace.GetDefaultFolder(16) # olFolderDrafts + $MailItem.Move($draftsFolder) | Out-Null + Write-CollectorLog ("enforcement: email moved to Drafts rule={0} subject_hash={1}" -f $RuleId, (Get-StringHash -Value $MailItem.Subject)) + return $true + } + catch { + Write-CollectorLog ("enforcement: email move to Drafts failed rule={0}: {1}" -f $RuleId, $_.Exception.Message) + return $false + } +} + +# --------------------------------------------------------------------------- +# Outlook Sent Items polling +# --------------------------------------------------------------------------- + +function Initialize-OutlookCom { + try { + $script:OutlookApp = New-Object -ComObject Outlook.Application + $script:OutlookNamespace = $script:OutlookApp.GetNamespace('MAPI') + $script:SentFolder = $script:OutlookNamespace.GetDefaultFolder(5) # olFolderSentMail + Write-CollectorLog "Outlook COM initialized, Sent Items folder opened" + return $true + } + catch { + Write-CollectorLog ("Outlook COM init failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Get-OutlookSentItems { + param([datetime]$Since) + + $results = @() + try { + $items = $script:SentFolder.Items + $items.Sort('[SentOn]', $true) + + $filter = "[SentOn] >= '{0}'" -f $Since.ToString('MM/dd/yyyy HH:mm') + $restricted = $items.Restrict($filter) + + foreach ($item in $restricted) { + try { + if ($item.Class -ne 43) { continue } # olMail = 43 + + $recipients = @() + for ($i = 1; $i -le $item.Recipients.Count; $i++) { + $recip = $item.Recipients.Item($i) + $recipients += [string]$recip.Address + } + + $attachmentNames = @() + for ($i = 1; $i -le $item.Attachments.Count; $i++) { + $attachmentNames += [string]$item.Attachments.Item($i).FileName + } + + $results += [pscustomobject]@{ + EntryID = [string]$item.EntryID + Subject = [string]$item.Subject + SenderAddress = [string]$item.SenderEmailAddress + SenderName = [string]$item.SenderName + Recipients = $recipients + RecipientsJoined = ($recipients -join '; ') + AttachmentCount = [int]$item.Attachments.Count + AttachmentNames = ($attachmentNames -join '; ') + BodyLength = if ($item.Body) { $item.Body.Length } else { 0 } + SentOn = $item.SentOn + MailItem = $item + } + } + catch { Write-Error <# +.SYNOPSIS + DLP email outbound collector for AWatch-rus (Phase 2.5). + Monitors outgoing email via Outlook COM Sent Items polling + and/or SMTP network connection detection. + +.DESCRIPTION + Two collection modes (configurable, can run simultaneously): + - outlook : Polls Outlook Sent Items via COM for new messages. + - smtp : Monitors SMTP connections (ports 25/587/465) via + Get-NetTCPConnection for any process sending mail. + + Sends heartbeats to AW bucket `aw-email-monitor_`. + Evaluates DLP policy rules from `endpoint.email[]` section. + Supports enforcement: action="block" moves the email to Drafts + (Outlook mode) or logs with enforced=false (SMTP mode). +#> +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds, + [ValidateSet('outlook', 'smtp', 'both')] + [string]$Mode = 'both' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Shared infrastructure (mirrors other collectors) +# --------------------------------------------------------------------------- + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-CollectorLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { return } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + if ($script:KnownBuckets.ContainsKey($BucketId)) { return } + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { $sha.Dispose() } +} + +function Send-EmailHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + $bucketId = 'aw-email-monitor_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-email-monitor' -BucketType 'aw.dlp.email' + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'email-outbound-collector' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-EmailIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [hashtable]$Data + ) + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = 'email_outbound' + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'email-outbound-collector' + } + $Data + } | ConvertTo-Json -Depth 7 -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { } +} + +# --------------------------------------------------------------------------- +# DLP Policy +# --------------------------------------------------------------------------- + +function Load-EmailPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + email = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + if ($raw.endpoint -and $raw.endpoint.email) { + $script:Policy.endpoint.email = @($raw.endpoint.email) + } + } + catch { + Write-CollectorLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + $script:Cooldown[$Fingerprint] = $now + return $true +} + +# --------------------------------------------------------------------------- +# Email DLP rule evaluation +# --------------------------------------------------------------------------- + +function Evaluate-EmailRules { + param( + [string]$Subject, + [string]$RecipientsJoined, + [string]$SenderAddress, + [int]$AttachmentCount, + [string]$AttachmentNames, + [int]$BodyLength, + [string]$MessageId, + $OutlookMailItem + ) + + foreach ($rule in @($script:Policy.endpoint.email)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $matched = $true + + if ($rule.subjectRegex) { + $matched = $matched -and ($Subject -match [string]$rule.subjectRegex) + } + if ($rule.recipientRegex) { + $matched = $matched -and ($RecipientsJoined -match [string]$rule.recipientRegex) + } + if ($rule.senderRegex) { + $matched = $matched -and ($SenderAddress -match [string]$rule.senderRegex) + } + if ($rule.attachmentRegex -and $AttachmentNames) { + $matched = $matched -and ($AttachmentNames -match [string]$rule.attachmentRegex) + } + if ($rule.minAttachments) { + $matched = $matched -and ($AttachmentCount -ge [int]$rule.minAttachments) + } + if ($rule.minBodyLength) { + $matched = $matched -and ($BodyLength -ge [int]$rule.minBodyLength) + } + if ($rule.externalOnly -and [bool]$rule.externalOnly) { + $internalDomain = if ($rule.internalDomain) { [string]$rule.internalDomain } else { '' } + if ($internalDomain -and $RecipientsJoined -notmatch [regex]::Escape($internalDomain)) { + # all recipients are external — continue matching + } + elseif ($internalDomain) { + $matched = $false + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "email|$ruleId|$MessageId|$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 } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Email rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block' -and $null -ne $OutlookMailItem) { + $enforced = Invoke-EmailEnforcement -MailItem $OutlookMailItem -RuleId $ruleId + Show-EnforcementNotification -Title 'DLP: письмо перемещено в черновики' -Body $message + } + elseif ($action -eq 'block') { + Show-EnforcementNotification -Title 'DLP: обнаружена отправка письма' -Body $message + } + + Send-EmailIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -Data @{ + subject = (Get-StringHash -Value $Subject) + recipients = (Get-StringHash -Value $RecipientsJoined) + sender = $SenderAddress + attachmentCount = $AttachmentCount + attachmentNames = $AttachmentNames + bodyLength = $BodyLength + enforced = $enforced + } + Write-CollectorLog ("incident email rule={0} action={1} severity={2} enforced={3} subject_hash={4}" -f $ruleId, $action, $severity, $enforced, (Get-StringHash -Value $Subject)) + } +} + +function Invoke-EmailEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)]$MailItem, + [string]$RuleId + ) + try { + $draftsFolder = $script:OutlookNamespace.GetDefaultFolder(16) # olFolderDrafts + $MailItem.Move($draftsFolder) | Out-Null + Write-CollectorLog ("enforcement: email moved to Drafts rule={0} subject_hash={1}" -f $RuleId, (Get-StringHash -Value $MailItem.Subject)) + return $true + } + catch { + Write-CollectorLog ("enforcement: email move to Drafts failed rule={0}: {1}" -f $RuleId, $_.Exception.Message) + return $false + } +} + +# --------------------------------------------------------------------------- +# Outlook Sent Items polling +# --------------------------------------------------------------------------- + +function Initialize-OutlookCom { + try { + $script:OutlookApp = New-Object -ComObject Outlook.Application + $script:OutlookNamespace = $script:OutlookApp.GetNamespace('MAPI') + $script:SentFolder = $script:OutlookNamespace.GetDefaultFolder(5) # olFolderSentMail + Write-CollectorLog "Outlook COM initialized, Sent Items folder opened" + return $true + } + catch { + Write-CollectorLog ("Outlook COM init failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Get-OutlookSentItems { + param([datetime]$Since) + + $results = @() + try { + $items = $script:SentFolder.Items + $items.Sort('[SentOn]', $true) + + $filter = "[SentOn] >= '{0}'" -f $Since.ToString('MM/dd/yyyy HH:mm') + $restricted = $items.Restrict($filter) + + foreach ($item in $restricted) { + try { + if ($item.Class -ne 43) { continue } # olMail = 43 + + $recipients = @() + for ($i = 1; $i -le $item.Recipients.Count; $i++) { + $recip = $item.Recipients.Item($i) + $recipients += [string]$recip.Address + } + + $attachmentNames = @() + for ($i = 1; $i -le $item.Attachments.Count; $i++) { + $attachmentNames += [string]$item.Attachments.Item($i).FileName + } + + $results += [pscustomobject]@{ + EntryID = [string]$item.EntryID + Subject = [string]$item.Subject + SenderAddress = [string]$item.SenderEmailAddress + SenderName = [string]$item.SenderName + Recipients = $recipients + RecipientsJoined = ($recipients -join '; ') + AttachmentCount = [int]$item.Attachments.Count + AttachmentNames = ($attachmentNames -join '; ') + BodyLength = if ($item.Body) { $item.Body.Length } else { 0 } + SentOn = $item.SentOn + MailItem = $item + } + } + catch { } + } + } + catch { + Write-CollectorLog ("Outlook Sent Items scan failed: {0}" -f $_.Exception.Message) + } + return $results +} + +function Poll-OutlookSentItems { + $items = Get-OutlookSentItems -Since $script:OutlookLastPoll + + foreach ($item in $items) { + $entryId = $item.EntryID + if ($script:SeenEntryIds.ContainsKey($entryId)) { continue } + $script:SeenEntryIds[$entryId] = (Get-Date).ToUniversalTime() + + $subjectHash = Get-StringHash -Value $item.Subject + + Send-EmailHeartbeat -SignalType 'email_sent' -Data @{ + subject = $subjectHash + sender = [string]$item.SenderAddress + senderName = [string]$item.SenderName + recipientCount = $item.Recipients.Count + recipients = (Get-StringHash -Value $item.RecipientsJoined) + attachmentCount = [int]$item.AttachmentCount + attachmentNames = [string]$item.AttachmentNames + bodyLength = [int]$item.BodyLength + sentOn = if ($item.SentOn) { $item.SentOn.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } else { '' } + collectionMode = 'outlook' + } + Write-CollectorLog ("email_sent outlook subject_hash={0} to={1} attachments={2}" -f $subjectHash, $item.Recipients.Count, $item.AttachmentCount) + + Evaluate-EmailRules ` + -Subject $item.Subject ` + -RecipientsJoined $item.RecipientsJoined ` + -SenderAddress $item.SenderAddress ` + -AttachmentCount $item.AttachmentCount ` + -AttachmentNames $item.AttachmentNames ` + -BodyLength $item.BodyLength ` + -MessageId $entryId ` + -OutlookMailItem $item.MailItem + } + + $script:OutlookLastPoll = (Get-Date).AddSeconds(-10) + + # Cleanup old entry IDs (keep last 24h) + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-24) + foreach ($k in @($script:SeenEntryIds.Keys)) { + if ([datetime]$script:SeenEntryIds[$k] -lt $cleanupBefore) { + $script:SeenEntryIds.Remove($k) + } + } +} + +# --------------------------------------------------------------------------- +# SMTP network connection monitoring +# --------------------------------------------------------------------------- + +function Poll-SmtpConnections { + try { + $smtpPorts = @(25, 587, 465, 2525) + $connections = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue | + Where-Object { $smtpPorts -contains $_.RemotePort } + + foreach ($conn in @($connections)) { + $processId = [int]$conn.OwningProcess + $remoteAddr = [string]$conn.RemoteAddress + $remotePort = [int]$conn.RemotePort + $fingerprint = "{0}:{1}:{2}" -f $processId, $remoteAddr, $remotePort + if ($script:SeenSmtpConnections.ContainsKey($fingerprint)) { continue } + $script:SeenSmtpConnections[$fingerprint] = (Get-Date).ToUniversalTime() + + $processName = '' + try { + $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue + $processName = [string]$proc.ProcessName + } + catch { } + + Send-EmailHeartbeat -SignalType 'smtp_connection' -Data @{ + remoteAddress = $remoteAddr + remotePort = $remotePort + processId = $processId + processName = $processName + localPort = [int]$conn.LocalPort + collectionMode = 'smtp' + } + Write-CollectorLog ("smtp_connection process={0}({1}) remote={2}:{3}" -f $processName, $processId, $remoteAddr, $remotePort) + + Evaluate-EmailRules ` + -Subject '' ` + -RecipientsJoined $remoteAddr ` + -SenderAddress $env:USERNAME ` + -AttachmentCount 0 ` + -AttachmentNames '' ` + -BodyLength 0 ` + -MessageId $fingerprint ` + -OutlookMailItem $null + } + + # Cleanup old SMTP connections (keep last 8h) + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenSmtpConnections.Keys)) { + if ([datetime]$script:SeenSmtpConnections[$k] -lt $cleanupBefore) { + $script:SeenSmtpConnections.Remove($k) + } + } + } + catch { + Write-CollectorLog ("SMTP poll error: {0}" -f $_.Exception.Message) + } +} + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 10 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("email-outbound-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenEntryIds = @{} +$script:SeenSmtpConnections = @{} +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:OutlookApp = $null +$script:OutlookNamespace = $null +$script:SentFolder = $null +$script:OutlookLastPoll = (Get-Date).AddMinutes(-5) + +Load-EmailPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("email collector started mode={0} against {1}" -f $Mode, $script:ApiBase) + +$useOutlook = ($Mode -eq 'outlook' -or $Mode -eq 'both') +$useSmtp = ($Mode -eq 'smtp' -or $Mode -eq 'both') +$outlookReady = $false + +if ($useOutlook) { + $outlookReady = Initialize-OutlookCom + if (-not $outlookReady -and $Mode -eq 'outlook') { + Write-CollectorLog "Outlook COM not available, collector will retry" + } +} + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + +while ($true) { + try { + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + if ($useOutlook) { + if (-not $outlookReady) { + $outlookReady = Initialize-OutlookCom + } + if ($outlookReady) { + try { + Poll-OutlookSentItems + } + catch { + Write-CollectorLog ("outlook poll error: {0}" -f $_.Exception.Message) + $outlookReady = $false + $script:OutlookApp = $null + $script:OutlookNamespace = $null + $script:SentFolder = $null + } + } + } + + if ($useSmtp) { + try { + Poll-SmtpConnections + } + catch { + Write-CollectorLog ("smtp poll error: {0}" -f $_.Exception.Message) + } + } + } + catch { + Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + } + } + catch { + Write-CollectorLog ("Outlook Sent Items scan failed: {0}" -f $_.Exception.Message) + } + return $results +} + +function Poll-OutlookSentItems { + $items = Get-OutlookSentItems -Since $script:OutlookLastPoll + + foreach ($item in $items) { + $entryId = $item.EntryID + if ($script:SeenEntryIds.ContainsKey($entryId)) { continue } + $script:SeenEntryIds[$entryId] = (Get-Date).ToUniversalTime() + + $subjectHash = Get-StringHash -Value $item.Subject + + Send-EmailHeartbeat -SignalType 'email_sent' -Data @{ + subject = $subjectHash + sender = [string]$item.SenderAddress + senderName = [string]$item.SenderName + recipientCount = $item.Recipients.Count + recipients = (Get-StringHash -Value $item.RecipientsJoined) + attachmentCount = [int]$item.AttachmentCount + attachmentNames = [string]$item.AttachmentNames + bodyLength = [int]$item.BodyLength + sentOn = if ($item.SentOn) { $item.SentOn.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } else { '' } + collectionMode = 'outlook' + } + Write-CollectorLog ("email_sent outlook subject_hash={0} to={1} attachments={2}" -f $subjectHash, $item.Recipients.Count, $item.AttachmentCount) + + Evaluate-EmailRules ` + -Subject $item.Subject ` + -RecipientsJoined $item.RecipientsJoined ` + -SenderAddress $item.SenderAddress ` + -AttachmentCount $item.AttachmentCount ` + -AttachmentNames $item.AttachmentNames ` + -BodyLength $item.BodyLength ` + -MessageId $entryId ` + -OutlookMailItem $item.MailItem + } + + $script:OutlookLastPoll = (Get-Date).AddSeconds(-10) + + # Cleanup old entry IDs (keep last 24h) + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-24) + foreach ($k in @($script:SeenEntryIds.Keys)) { + if ([datetime]$script:SeenEntryIds[$k] -lt $cleanupBefore) { + $script:SeenEntryIds.Remove($k) + } + } +} + +# --------------------------------------------------------------------------- +# SMTP network connection monitoring +# --------------------------------------------------------------------------- + +function Poll-SmtpConnections { + try { + $smtpPorts = @(25, 587, 465, 2525) + $connections = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue | + Where-Object { $smtpPorts -contains $_.RemotePort } + + foreach ($conn in @($connections)) { + $processId = [int]$conn.OwningProcess + $remoteAddr = [string]$conn.RemoteAddress + $remotePort = [int]$conn.RemotePort + $fingerprint = "{0}:{1}:{2}" -f $processId, $remoteAddr, $remotePort + if ($script:SeenSmtpConnections.ContainsKey($fingerprint)) { continue } + $script:SeenSmtpConnections[$fingerprint] = (Get-Date).ToUniversalTime() + + $processName = '' + try { + $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue + $processName = [string]$proc.ProcessName + } + catch { Write-Error <# +.SYNOPSIS + DLP email outbound collector for AWatch-rus (Phase 2.5). + Monitors outgoing email via Outlook COM Sent Items polling + and/or SMTP network connection detection. + +.DESCRIPTION + Two collection modes (configurable, can run simultaneously): + - outlook : Polls Outlook Sent Items via COM for new messages. + - smtp : Monitors SMTP connections (ports 25/587/465) via + Get-NetTCPConnection for any process sending mail. + + Sends heartbeats to AW bucket `aw-email-monitor_`. + Evaluates DLP policy rules from `endpoint.email[]` section. + Supports enforcement: action="block" moves the email to Drafts + (Outlook mode) or logs with enforced=false (SMTP mode). +#> +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds, + [ValidateSet('outlook', 'smtp', 'both')] + [string]$Mode = 'both' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Shared infrastructure (mirrors other collectors) +# --------------------------------------------------------------------------- + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-CollectorLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { return } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + if ($script:KnownBuckets.ContainsKey($BucketId)) { return } + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { $sha.Dispose() } +} + +function Send-EmailHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + $bucketId = 'aw-email-monitor_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-email-monitor' -BucketType 'aw.dlp.email' + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'email-outbound-collector' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-EmailIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [hashtable]$Data + ) + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = 'email_outbound' + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'email-outbound-collector' + } + $Data + } | ConvertTo-Json -Depth 7 -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { } +} + +# --------------------------------------------------------------------------- +# DLP Policy +# --------------------------------------------------------------------------- + +function Load-EmailPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + email = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + if ($raw.endpoint -and $raw.endpoint.email) { + $script:Policy.endpoint.email = @($raw.endpoint.email) + } + } + catch { + Write-CollectorLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + $script:Cooldown[$Fingerprint] = $now + return $true +} + +# --------------------------------------------------------------------------- +# Email DLP rule evaluation +# --------------------------------------------------------------------------- + +function Evaluate-EmailRules { + param( + [string]$Subject, + [string]$RecipientsJoined, + [string]$SenderAddress, + [int]$AttachmentCount, + [string]$AttachmentNames, + [int]$BodyLength, + [string]$MessageId, + $OutlookMailItem + ) + + foreach ($rule in @($script:Policy.endpoint.email)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $matched = $true + + if ($rule.subjectRegex) { + $matched = $matched -and ($Subject -match [string]$rule.subjectRegex) + } + if ($rule.recipientRegex) { + $matched = $matched -and ($RecipientsJoined -match [string]$rule.recipientRegex) + } + if ($rule.senderRegex) { + $matched = $matched -and ($SenderAddress -match [string]$rule.senderRegex) + } + if ($rule.attachmentRegex -and $AttachmentNames) { + $matched = $matched -and ($AttachmentNames -match [string]$rule.attachmentRegex) + } + if ($rule.minAttachments) { + $matched = $matched -and ($AttachmentCount -ge [int]$rule.minAttachments) + } + if ($rule.minBodyLength) { + $matched = $matched -and ($BodyLength -ge [int]$rule.minBodyLength) + } + if ($rule.externalOnly -and [bool]$rule.externalOnly) { + $internalDomain = if ($rule.internalDomain) { [string]$rule.internalDomain } else { '' } + if ($internalDomain -and $RecipientsJoined -notmatch [regex]::Escape($internalDomain)) { + # all recipients are external — continue matching + } + elseif ($internalDomain) { + $matched = $false + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "email|$ruleId|$MessageId|$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 } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Email rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block' -and $null -ne $OutlookMailItem) { + $enforced = Invoke-EmailEnforcement -MailItem $OutlookMailItem -RuleId $ruleId + Show-EnforcementNotification -Title 'DLP: письмо перемещено в черновики' -Body $message + } + elseif ($action -eq 'block') { + Show-EnforcementNotification -Title 'DLP: обнаружена отправка письма' -Body $message + } + + Send-EmailIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -Data @{ + subject = (Get-StringHash -Value $Subject) + recipients = (Get-StringHash -Value $RecipientsJoined) + sender = $SenderAddress + attachmentCount = $AttachmentCount + attachmentNames = $AttachmentNames + bodyLength = $BodyLength + enforced = $enforced + } + Write-CollectorLog ("incident email rule={0} action={1} severity={2} enforced={3} subject_hash={4}" -f $ruleId, $action, $severity, $enforced, (Get-StringHash -Value $Subject)) + } +} + +function Invoke-EmailEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)]$MailItem, + [string]$RuleId + ) + try { + $draftsFolder = $script:OutlookNamespace.GetDefaultFolder(16) # olFolderDrafts + $MailItem.Move($draftsFolder) | Out-Null + Write-CollectorLog ("enforcement: email moved to Drafts rule={0} subject_hash={1}" -f $RuleId, (Get-StringHash -Value $MailItem.Subject)) + return $true + } + catch { + Write-CollectorLog ("enforcement: email move to Drafts failed rule={0}: {1}" -f $RuleId, $_.Exception.Message) + return $false + } +} + +# --------------------------------------------------------------------------- +# Outlook Sent Items polling +# --------------------------------------------------------------------------- + +function Initialize-OutlookCom { + try { + $script:OutlookApp = New-Object -ComObject Outlook.Application + $script:OutlookNamespace = $script:OutlookApp.GetNamespace('MAPI') + $script:SentFolder = $script:OutlookNamespace.GetDefaultFolder(5) # olFolderSentMail + Write-CollectorLog "Outlook COM initialized, Sent Items folder opened" + return $true + } + catch { + Write-CollectorLog ("Outlook COM init failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Get-OutlookSentItems { + param([datetime]$Since) + + $results = @() + try { + $items = $script:SentFolder.Items + $items.Sort('[SentOn]', $true) + + $filter = "[SentOn] >= '{0}'" -f $Since.ToString('MM/dd/yyyy HH:mm') + $restricted = $items.Restrict($filter) + + foreach ($item in $restricted) { + try { + if ($item.Class -ne 43) { continue } # olMail = 43 + + $recipients = @() + for ($i = 1; $i -le $item.Recipients.Count; $i++) { + $recip = $item.Recipients.Item($i) + $recipients += [string]$recip.Address + } + + $attachmentNames = @() + for ($i = 1; $i -le $item.Attachments.Count; $i++) { + $attachmentNames += [string]$item.Attachments.Item($i).FileName + } + + $results += [pscustomobject]@{ + EntryID = [string]$item.EntryID + Subject = [string]$item.Subject + SenderAddress = [string]$item.SenderEmailAddress + SenderName = [string]$item.SenderName + Recipients = $recipients + RecipientsJoined = ($recipients -join '; ') + AttachmentCount = [int]$item.Attachments.Count + AttachmentNames = ($attachmentNames -join '; ') + BodyLength = if ($item.Body) { $item.Body.Length } else { 0 } + SentOn = $item.SentOn + MailItem = $item + } + } + catch { } + } + } + catch { + Write-CollectorLog ("Outlook Sent Items scan failed: {0}" -f $_.Exception.Message) + } + return $results +} + +function Poll-OutlookSentItems { + $items = Get-OutlookSentItems -Since $script:OutlookLastPoll + + foreach ($item in $items) { + $entryId = $item.EntryID + if ($script:SeenEntryIds.ContainsKey($entryId)) { continue } + $script:SeenEntryIds[$entryId] = (Get-Date).ToUniversalTime() + + $subjectHash = Get-StringHash -Value $item.Subject + + Send-EmailHeartbeat -SignalType 'email_sent' -Data @{ + subject = $subjectHash + sender = [string]$item.SenderAddress + senderName = [string]$item.SenderName + recipientCount = $item.Recipients.Count + recipients = (Get-StringHash -Value $item.RecipientsJoined) + attachmentCount = [int]$item.AttachmentCount + attachmentNames = [string]$item.AttachmentNames + bodyLength = [int]$item.BodyLength + sentOn = if ($item.SentOn) { $item.SentOn.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } else { '' } + collectionMode = 'outlook' + } + Write-CollectorLog ("email_sent outlook subject_hash={0} to={1} attachments={2}" -f $subjectHash, $item.Recipients.Count, $item.AttachmentCount) + + Evaluate-EmailRules ` + -Subject $item.Subject ` + -RecipientsJoined $item.RecipientsJoined ` + -SenderAddress $item.SenderAddress ` + -AttachmentCount $item.AttachmentCount ` + -AttachmentNames $item.AttachmentNames ` + -BodyLength $item.BodyLength ` + -MessageId $entryId ` + -OutlookMailItem $item.MailItem + } + + $script:OutlookLastPoll = (Get-Date).AddSeconds(-10) + + # Cleanup old entry IDs (keep last 24h) + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-24) + foreach ($k in @($script:SeenEntryIds.Keys)) { + if ([datetime]$script:SeenEntryIds[$k] -lt $cleanupBefore) { + $script:SeenEntryIds.Remove($k) + } + } +} + +# --------------------------------------------------------------------------- +# SMTP network connection monitoring +# --------------------------------------------------------------------------- + +function Poll-SmtpConnections { + try { + $smtpPorts = @(25, 587, 465, 2525) + $connections = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue | + Where-Object { $smtpPorts -contains $_.RemotePort } + + foreach ($conn in @($connections)) { + $processId = [int]$conn.OwningProcess + $remoteAddr = [string]$conn.RemoteAddress + $remotePort = [int]$conn.RemotePort + $fingerprint = "{0}:{1}:{2}" -f $processId, $remoteAddr, $remotePort + if ($script:SeenSmtpConnections.ContainsKey($fingerprint)) { continue } + $script:SeenSmtpConnections[$fingerprint] = (Get-Date).ToUniversalTime() + + $processName = '' + try { + $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue + $processName = [string]$proc.ProcessName + } + catch { } + + Send-EmailHeartbeat -SignalType 'smtp_connection' -Data @{ + remoteAddress = $remoteAddr + remotePort = $remotePort + processId = $processId + processName = $processName + localPort = [int]$conn.LocalPort + collectionMode = 'smtp' + } + Write-CollectorLog ("smtp_connection process={0}({1}) remote={2}:{3}" -f $processName, $processId, $remoteAddr, $remotePort) + + Evaluate-EmailRules ` + -Subject '' ` + -RecipientsJoined $remoteAddr ` + -SenderAddress $env:USERNAME ` + -AttachmentCount 0 ` + -AttachmentNames '' ` + -BodyLength 0 ` + -MessageId $fingerprint ` + -OutlookMailItem $null + } + + # Cleanup old SMTP connections (keep last 8h) + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenSmtpConnections.Keys)) { + if ([datetime]$script:SeenSmtpConnections[$k] -lt $cleanupBefore) { + $script:SeenSmtpConnections.Remove($k) + } + } + } + catch { + Write-CollectorLog ("SMTP poll error: {0}" -f $_.Exception.Message) + } +} + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 10 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("email-outbound-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenEntryIds = @{} +$script:SeenSmtpConnections = @{} +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:OutlookApp = $null +$script:OutlookNamespace = $null +$script:SentFolder = $null +$script:OutlookLastPoll = (Get-Date).AddMinutes(-5) + +Load-EmailPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("email collector started mode={0} against {1}" -f $Mode, $script:ApiBase) + +$useOutlook = ($Mode -eq 'outlook' -or $Mode -eq 'both') +$useSmtp = ($Mode -eq 'smtp' -or $Mode -eq 'both') +$outlookReady = $false + +if ($useOutlook) { + $outlookReady = Initialize-OutlookCom + if (-not $outlookReady -and $Mode -eq 'outlook') { + Write-CollectorLog "Outlook COM not available, collector will retry" + } +} + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + +while ($true) { + try { + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + if ($useOutlook) { + if (-not $outlookReady) { + $outlookReady = Initialize-OutlookCom + } + if ($outlookReady) { + try { + Poll-OutlookSentItems + } + catch { + Write-CollectorLog ("outlook poll error: {0}" -f $_.Exception.Message) + $outlookReady = $false + $script:OutlookApp = $null + $script:OutlookNamespace = $null + $script:SentFolder = $null + } + } + } + + if ($useSmtp) { + try { + Poll-SmtpConnections + } + catch { + Write-CollectorLog ("smtp poll error: {0}" -f $_.Exception.Message) + } + } + } + catch { + Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + + Send-EmailHeartbeat -SignalType 'smtp_connection' -Data @{ + remoteAddress = $remoteAddr + remotePort = $remotePort + processId = $processId + processName = $processName + localPort = [int]$conn.LocalPort + collectionMode = 'smtp' + } + Write-CollectorLog ("smtp_connection process={0}({1}) remote={2}:{3}" -f $processName, $processId, $remoteAddr, $remotePort) + + Evaluate-EmailRules ` + -Subject '' ` + -RecipientsJoined $remoteAddr ` + -SenderAddress $env:USERNAME ` + -AttachmentCount 0 ` + -AttachmentNames '' ` + -BodyLength 0 ` + -MessageId $fingerprint ` + -OutlookMailItem $null + } + + # Cleanup old SMTP connections (keep last 8h) + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenSmtpConnections.Keys)) { + if ([datetime]$script:SeenSmtpConnections[$k] -lt $cleanupBefore) { + $script:SeenSmtpConnections.Remove($k) + } + } + } + catch { + Write-CollectorLog ("SMTP poll error: {0}" -f $_.Exception.Message) + } +} + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 10 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("email-outbound-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenEntryIds = @{} +$script:SeenSmtpConnections = @{} +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:OutlookApp = $null +$script:OutlookNamespace = $null +$script:SentFolder = $null +$script:OutlookLastPoll = (Get-Date).AddMinutes(-5) + +Load-EmailPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("email collector started mode={0} against {1}" -f $Mode, $script:ApiBase) + +$useOutlook = ($Mode -eq 'outlook' -or $Mode -eq 'both') +$useSmtp = ($Mode -eq 'smtp' -or $Mode -eq 'both') +$outlookReady = $false + +if ($useOutlook) { + $outlookReady = Initialize-OutlookCom + if (-not $outlookReady -and $Mode -eq 'outlook') { + Write-CollectorLog "Outlook COM not available, collector will retry" + } +} + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + +while ($true) { + try { + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + if ($useOutlook) { + if (-not $outlookReady) { + $outlookReady = Initialize-OutlookCom + } + if ($outlookReady) { + try { + Poll-OutlookSentItems + } + catch { + Write-CollectorLog ("outlook poll error: {0}" -f $_.Exception.Message) + $outlookReady = $false + $script:OutlookApp = $null + $script:OutlookNamespace = $null + $script:SentFolder = $null + } + } + } + + if ($useSmtp) { + try { + Poll-SmtpConnections + } + catch { + Write-CollectorLog ("smtp poll error: {0}" -f $_.Exception.Message) + } + } + } + catch { + Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} diff --git a/windows/file-operations-collector.ps1 b/windows/file-operations-collector.ps1 index bc63d55..a51cffd 100644 --- a/windows/file-operations-collector.ps1 +++ b/windows/file-operations-collector.ps1 @@ -1,4 +1,46 @@ -[CmdletBinding()] +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds = 10, + [string[]]$WatchPaths = @('Desktop', 'Documents', 'Downloads') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Force TLS 1.2 and load networking types +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http + +# Bucket registry +$script:KnownBuckets = @{} +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId + +# Настройка логирования +$script:LogPath = $LogPath +$script:LocalAgentLogsEnabled = [bool]$LogPath + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-FileCollectorLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { return } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} [FileCollector] {1}' -f (Get-Date -Format s), $Message) + } catch { Write-Error [CmdletBinding()] param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$ServerHost, @@ -224,3 +266,1090 @@ finally { $w.Dispose() } } +; } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + $httpClient = $null + try { + $httpClient = New-Object System.Net.Http.HttpClient + $content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json") + $response = $httpClient.PostAsync($Uri, $content).Result + if (-not $response.IsSuccessStatusCode) { + $status = [int]$response.StatusCode + $reason = [string]$response.ReasonPhrase + $body = $response.Content.ReadAsStringAsync().Result + Write-FileCollectorLog ("POST failed: uri={0} status={1} reason={2} body={3}" -f $Uri, $status, $reason, $body) + } + } catch { + Write-FileCollectorLog "POST Error: $($_.Exception.Message)" + } finally { + if ($null -ne $httpClient) { + $httpClient.Dispose() + } + } +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds = 10, + [string[]]$WatchPaths = @('Desktop', 'Documents', 'Downloads') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Force TLS 1.2 and load networking types +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http + +# Bucket registry +$script:KnownBuckets = @{} +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId + +# Настройка логирования +$script:LogPath = $LogPath +$script:LocalAgentLogsEnabled = [bool]$LogPath + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-FileCollectorLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { return } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} [FileCollector] {1}' -f (Get-Date -Format s), $Message) + } catch {} +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + $httpClient = $null + try { + $httpClient = New-Object System.Net.Http.HttpClient + $content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json") + $response = $httpClient.PostAsync($Uri, $content).Result + if (-not $response.IsSuccessStatusCode) { + $status = [int]$response.StatusCode + $reason = [string]$response.ReasonPhrase + $body = $response.Content.ReadAsStringAsync().Result + Write-FileCollectorLog ("POST failed: uri={0} status={1} reason={2} body={3}" -f $Uri, $status, $reason, $body) + } + } catch { + Write-FileCollectorLog "POST Error: $($_.Exception.Message)" + } finally { + if ($null -ne $httpClient) { + $httpClient.Dispose() + } + } +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + catch { + Write-FileCollectorLog "Bucket create/check failed for ${BucketId}: $($_.Exception.Message)" + throw + } + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-FileOperationEvent { + param( + [string]$Operation, + [string]$FilePath, + [string]$OldFilePath = $null, + [long]$Size = 0 + ) + + $bucketId = 'aw-file-operations_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' + + $data = @{ + operation = $Operation + path = $FilePath + extension = [System.IO.Path]::GetExtension($FilePath) + username = $env:USERNAME + hostname = $script:Hostname + } + if ($OldFilePath) { $data.oldPath = $OldFilePath } + if ($Size -gt 0) { $data.size = $Size } + + # Детекция архивации (упрощенная) + if ($Operation -eq 'Created' -and $data.extension -match '\.(zip|7z|rar|tar|gz)$') { + $data.archiveHint = $true + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = $data + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=15" -Json $payload +} + +$config = Get-DeploymentConfig -Path $ConfigPath +if (-not $config) { throw "Configuration file not found: $ConfigPath" } + +$scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) { $config.server.scheme } else { 'http' } +$hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' } +$port = if ($ServerPort) { $ServerPort } elseif ($config.server.port) { $config.server.port } else { 5600 } +$script:ApiBase = "{0}://{1}:{2}/api/0" -f $scheme, $hostName, $port + +$bucketId = 'aw-file-operations_' + $script:Hostname +Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' + +# Resolve paths for monitoring +$resolvedPaths = @() +foreach ($p in $WatchPaths) { + $fullPath = $p + if (-not [System.IO.Path]::IsPathRooted($p)) { + try { + if ($p -eq 'Desktop') { $fullPath = [Environment]::GetFolderPath('Desktop') } + elseif ($p -eq 'Documents') { $fullPath = [Environment]::GetFolderPath('MyDocuments') } + elseif ($p -eq 'Downloads') { $fullPath = Join-Path $env:USERPROFILE 'Downloads' } + } catch {} + } + if ($fullPath -and (Test-Path -LiteralPath $fullPath)) { + $resolvedPaths += $fullPath + } +} + +if ($resolvedPaths.Count -eq 0) { + Write-FileCollectorLog "No valid watch paths found. Exiting." + exit 0 +} + +Write-FileCollectorLog "Starting watch on paths: $($resolvedPaths -join ', ')" + +$watchers = @() +$subscriptions = @() +foreach ($path in $resolvedPaths) { + $watcher = New-Object System.IO.FileSystemWatcher + $watcher.Path = $path + $watcher.IncludeSubdirectories = $true + $watcher.EnableRaisingEvents = $true + + $onChanged = Register-ObjectEvent $watcher "Created" -Action { + $path = $Event.SourceEventArgs.FullPath + $size = 0 + try { if (Test-Path -LiteralPath $path) { $size = (Get-Item -LiteralPath $path).Length } } catch {} + Send-FileOperationEvent -Operation 'Created' -FilePath $path -Size $size + } + $onDeleted = Register-ObjectEvent $watcher "Deleted" -Action { + Send-FileOperationEvent -Operation 'Deleted' -FilePath $Event.SourceEventArgs.FullPath + } + $onRenamed = Register-ObjectEvent $watcher "Renamed" -Action { + Send-FileOperationEvent -Operation 'Renamed' -FilePath $Event.SourceEventArgs.FullPath -OldFilePath $Event.SourceEventArgs.OldFullPath + } + + $watchers += $watcher + $subscriptions += @($onChanged, $onDeleted, $onRenamed) +} + +Write-FileCollectorLog "Collector started. Waiting for events..." + +try { + while ($true) { + Start-Sleep -Seconds $PollSeconds + } +} +finally { + Write-FileCollectorLog "Stopping collector..." + foreach ($sub in @($subscriptions)) { + try { + if ($sub -and $sub.Id) { + Unregister-Event -SubscriptionId $sub.Id -ErrorAction SilentlyContinue + Remove-Job -Id $sub.Id -Force -ErrorAction SilentlyContinue + } + } catch {} + } + foreach ($w in $watchers) { + $w.EnableRaisingEvents = $false + $w.Dispose() + } +} +; } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + catch { + Write-FileCollectorLog "Bucket create/check failed for ${BucketId}: $($_.Exception.Message)" + throw + } + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-FileOperationEvent { + param( + [string]$Operation, + [string]$FilePath, + [string]$OldFilePath = $null, + [long]$Size = 0 + ) + + $bucketId = 'aw-file-operations_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' + + $data = @{ + operation = $Operation + path = $FilePath + extension = [System.IO.Path]::GetExtension($FilePath) + username = $env:USERNAME + hostname = $script:Hostname + } + if ($OldFilePath) { $data.oldPath = $OldFilePath } + if ($Size -gt 0) { $data.size = $Size } + + # Детекция архивации (упрощенная) + if ($Operation -eq 'Created' -and $data.extension -match '\.(zip|7z|rar|tar|gz)$') { + $data.archiveHint = $true + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = $data + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=15" -Json $payload +} + +$config = Get-DeploymentConfig -Path $ConfigPath +if (-not $config) { throw "Configuration file not found: $ConfigPath" } + +$scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) { $config.server.scheme } else { 'http' } +$hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' } +$port = if ($ServerPort) { $ServerPort } elseif ($config.server.port) { $config.server.port } else { 5600 } +$script:ApiBase = "{0}://{1}:{2}/api/0" -f $scheme, $hostName, $port + +$bucketId = 'aw-file-operations_' + $script:Hostname +Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' + +# Resolve paths for monitoring +$resolvedPaths = @() +foreach ($p in $WatchPaths) { + $fullPath = $p + if (-not [System.IO.Path]::IsPathRooted($p)) { + try { + if ($p -eq 'Desktop') { $fullPath = [Environment]::GetFolderPath('Desktop') } + elseif ($p -eq 'Documents') { $fullPath = [Environment]::GetFolderPath('MyDocuments') } + elseif ($p -eq 'Downloads') { $fullPath = Join-Path $env:USERPROFILE 'Downloads' } + } catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds = 10, + [string[]]$WatchPaths = @('Desktop', 'Documents', 'Downloads') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Force TLS 1.2 and load networking types +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http + +# Bucket registry +$script:KnownBuckets = @{} +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId + +# Настройка логирования +$script:LogPath = $LogPath +$script:LocalAgentLogsEnabled = [bool]$LogPath + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-FileCollectorLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { return } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} [FileCollector] {1}' -f (Get-Date -Format s), $Message) + } catch {} +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + $httpClient = $null + try { + $httpClient = New-Object System.Net.Http.HttpClient + $content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json") + $response = $httpClient.PostAsync($Uri, $content).Result + if (-not $response.IsSuccessStatusCode) { + $status = [int]$response.StatusCode + $reason = [string]$response.ReasonPhrase + $body = $response.Content.ReadAsStringAsync().Result + Write-FileCollectorLog ("POST failed: uri={0} status={1} reason={2} body={3}" -f $Uri, $status, $reason, $body) + } + } catch { + Write-FileCollectorLog "POST Error: $($_.Exception.Message)" + } finally { + if ($null -ne $httpClient) { + $httpClient.Dispose() + } + } +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + catch { + Write-FileCollectorLog "Bucket create/check failed for ${BucketId}: $($_.Exception.Message)" + throw + } + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-FileOperationEvent { + param( + [string]$Operation, + [string]$FilePath, + [string]$OldFilePath = $null, + [long]$Size = 0 + ) + + $bucketId = 'aw-file-operations_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' + + $data = @{ + operation = $Operation + path = $FilePath + extension = [System.IO.Path]::GetExtension($FilePath) + username = $env:USERNAME + hostname = $script:Hostname + } + if ($OldFilePath) { $data.oldPath = $OldFilePath } + if ($Size -gt 0) { $data.size = $Size } + + # Детекция архивации (упрощенная) + if ($Operation -eq 'Created' -and $data.extension -match '\.(zip|7z|rar|tar|gz)$') { + $data.archiveHint = $true + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = $data + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=15" -Json $payload +} + +$config = Get-DeploymentConfig -Path $ConfigPath +if (-not $config) { throw "Configuration file not found: $ConfigPath" } + +$scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) { $config.server.scheme } else { 'http' } +$hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' } +$port = if ($ServerPort) { $ServerPort } elseif ($config.server.port) { $config.server.port } else { 5600 } +$script:ApiBase = "{0}://{1}:{2}/api/0" -f $scheme, $hostName, $port + +$bucketId = 'aw-file-operations_' + $script:Hostname +Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' + +# Resolve paths for monitoring +$resolvedPaths = @() +foreach ($p in $WatchPaths) { + $fullPath = $p + if (-not [System.IO.Path]::IsPathRooted($p)) { + try { + if ($p -eq 'Desktop') { $fullPath = [Environment]::GetFolderPath('Desktop') } + elseif ($p -eq 'Documents') { $fullPath = [Environment]::GetFolderPath('MyDocuments') } + elseif ($p -eq 'Downloads') { $fullPath = Join-Path $env:USERPROFILE 'Downloads' } + } catch {} + } + if ($fullPath -and (Test-Path -LiteralPath $fullPath)) { + $resolvedPaths += $fullPath + } +} + +if ($resolvedPaths.Count -eq 0) { + Write-FileCollectorLog "No valid watch paths found. Exiting." + exit 0 +} + +Write-FileCollectorLog "Starting watch on paths: $($resolvedPaths -join ', ')" + +$watchers = @() +$subscriptions = @() +foreach ($path in $resolvedPaths) { + $watcher = New-Object System.IO.FileSystemWatcher + $watcher.Path = $path + $watcher.IncludeSubdirectories = $true + $watcher.EnableRaisingEvents = $true + + $onChanged = Register-ObjectEvent $watcher "Created" -Action { + $path = $Event.SourceEventArgs.FullPath + $size = 0 + try { if (Test-Path -LiteralPath $path) { $size = (Get-Item -LiteralPath $path).Length } } catch {} + Send-FileOperationEvent -Operation 'Created' -FilePath $path -Size $size + } + $onDeleted = Register-ObjectEvent $watcher "Deleted" -Action { + Send-FileOperationEvent -Operation 'Deleted' -FilePath $Event.SourceEventArgs.FullPath + } + $onRenamed = Register-ObjectEvent $watcher "Renamed" -Action { + Send-FileOperationEvent -Operation 'Renamed' -FilePath $Event.SourceEventArgs.FullPath -OldFilePath $Event.SourceEventArgs.OldFullPath + } + + $watchers += $watcher + $subscriptions += @($onChanged, $onDeleted, $onRenamed) +} + +Write-FileCollectorLog "Collector started. Waiting for events..." + +try { + while ($true) { + Start-Sleep -Seconds $PollSeconds + } +} +finally { + Write-FileCollectorLog "Stopping collector..." + foreach ($sub in @($subscriptions)) { + try { + if ($sub -and $sub.Id) { + Unregister-Event -SubscriptionId $sub.Id -ErrorAction SilentlyContinue + Remove-Job -Id $sub.Id -Force -ErrorAction SilentlyContinue + } + } catch {} + } + foreach ($w in $watchers) { + $w.EnableRaisingEvents = $false + $w.Dispose() + } +} +; } + } + if ($fullPath -and (Test-Path -LiteralPath $fullPath)) { + $resolvedPaths += $fullPath + } +} + +if ($resolvedPaths.Count -eq 0) { + Write-FileCollectorLog "No valid watch paths found. Exiting." + exit 0 +} + +Write-FileCollectorLog "Starting watch on paths: $($resolvedPaths -join ', ')" + +$watchers = @() +$subscriptions = @() +foreach ($path in $resolvedPaths) { + $watcher = New-Object System.IO.FileSystemWatcher + $watcher.Path = $path + $watcher.IncludeSubdirectories = $true + $watcher.EnableRaisingEvents = $true + + $onChanged = Register-ObjectEvent $watcher "Created" -Action { + $path = $Event.SourceEventArgs.FullPath + $size = 0 + try { if (Test-Path -LiteralPath $path) { $size = (Get-Item -LiteralPath $path).Length } } catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds = 10, + [string[]]$WatchPaths = @('Desktop', 'Documents', 'Downloads') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Force TLS 1.2 and load networking types +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http + +# Bucket registry +$script:KnownBuckets = @{} +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId + +# Настройка логирования +$script:LogPath = $LogPath +$script:LocalAgentLogsEnabled = [bool]$LogPath + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-FileCollectorLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { return } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} [FileCollector] {1}' -f (Get-Date -Format s), $Message) + } catch {} +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + $httpClient = $null + try { + $httpClient = New-Object System.Net.Http.HttpClient + $content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json") + $response = $httpClient.PostAsync($Uri, $content).Result + if (-not $response.IsSuccessStatusCode) { + $status = [int]$response.StatusCode + $reason = [string]$response.ReasonPhrase + $body = $response.Content.ReadAsStringAsync().Result + Write-FileCollectorLog ("POST failed: uri={0} status={1} reason={2} body={3}" -f $Uri, $status, $reason, $body) + } + } catch { + Write-FileCollectorLog "POST Error: $($_.Exception.Message)" + } finally { + if ($null -ne $httpClient) { + $httpClient.Dispose() + } + } +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + catch { + Write-FileCollectorLog "Bucket create/check failed for ${BucketId}: $($_.Exception.Message)" + throw + } + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-FileOperationEvent { + param( + [string]$Operation, + [string]$FilePath, + [string]$OldFilePath = $null, + [long]$Size = 0 + ) + + $bucketId = 'aw-file-operations_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' + + $data = @{ + operation = $Operation + path = $FilePath + extension = [System.IO.Path]::GetExtension($FilePath) + username = $env:USERNAME + hostname = $script:Hostname + } + if ($OldFilePath) { $data.oldPath = $OldFilePath } + if ($Size -gt 0) { $data.size = $Size } + + # Детекция архивации (упрощенная) + if ($Operation -eq 'Created' -and $data.extension -match '\.(zip|7z|rar|tar|gz)$') { + $data.archiveHint = $true + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = $data + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=15" -Json $payload +} + +$config = Get-DeploymentConfig -Path $ConfigPath +if (-not $config) { throw "Configuration file not found: $ConfigPath" } + +$scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) { $config.server.scheme } else { 'http' } +$hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' } +$port = if ($ServerPort) { $ServerPort } elseif ($config.server.port) { $config.server.port } else { 5600 } +$script:ApiBase = "{0}://{1}:{2}/api/0" -f $scheme, $hostName, $port + +$bucketId = 'aw-file-operations_' + $script:Hostname +Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' + +# Resolve paths for monitoring +$resolvedPaths = @() +foreach ($p in $WatchPaths) { + $fullPath = $p + if (-not [System.IO.Path]::IsPathRooted($p)) { + try { + if ($p -eq 'Desktop') { $fullPath = [Environment]::GetFolderPath('Desktop') } + elseif ($p -eq 'Documents') { $fullPath = [Environment]::GetFolderPath('MyDocuments') } + elseif ($p -eq 'Downloads') { $fullPath = Join-Path $env:USERPROFILE 'Downloads' } + } catch {} + } + if ($fullPath -and (Test-Path -LiteralPath $fullPath)) { + $resolvedPaths += $fullPath + } +} + +if ($resolvedPaths.Count -eq 0) { + Write-FileCollectorLog "No valid watch paths found. Exiting." + exit 0 +} + +Write-FileCollectorLog "Starting watch on paths: $($resolvedPaths -join ', ')" + +$watchers = @() +$subscriptions = @() +foreach ($path in $resolvedPaths) { + $watcher = New-Object System.IO.FileSystemWatcher + $watcher.Path = $path + $watcher.IncludeSubdirectories = $true + $watcher.EnableRaisingEvents = $true + + $onChanged = Register-ObjectEvent $watcher "Created" -Action { + $path = $Event.SourceEventArgs.FullPath + $size = 0 + try { if (Test-Path -LiteralPath $path) { $size = (Get-Item -LiteralPath $path).Length } } catch {} + Send-FileOperationEvent -Operation 'Created' -FilePath $path -Size $size + } + $onDeleted = Register-ObjectEvent $watcher "Deleted" -Action { + Send-FileOperationEvent -Operation 'Deleted' -FilePath $Event.SourceEventArgs.FullPath + } + $onRenamed = Register-ObjectEvent $watcher "Renamed" -Action { + Send-FileOperationEvent -Operation 'Renamed' -FilePath $Event.SourceEventArgs.FullPath -OldFilePath $Event.SourceEventArgs.OldFullPath + } + + $watchers += $watcher + $subscriptions += @($onChanged, $onDeleted, $onRenamed) +} + +Write-FileCollectorLog "Collector started. Waiting for events..." + +try { + while ($true) { + Start-Sleep -Seconds $PollSeconds + } +} +finally { + Write-FileCollectorLog "Stopping collector..." + foreach ($sub in @($subscriptions)) { + try { + if ($sub -and $sub.Id) { + Unregister-Event -SubscriptionId $sub.Id -ErrorAction SilentlyContinue + Remove-Job -Id $sub.Id -Force -ErrorAction SilentlyContinue + } + } catch {} + } + foreach ($w in $watchers) { + $w.EnableRaisingEvents = $false + $w.Dispose() + } +} +; } + Send-FileOperationEvent -Operation 'Created' -FilePath $path -Size $size + } + $onDeleted = Register-ObjectEvent $watcher "Deleted" -Action { + Send-FileOperationEvent -Operation 'Deleted' -FilePath $Event.SourceEventArgs.FullPath + } + $onRenamed = Register-ObjectEvent $watcher "Renamed" -Action { + Send-FileOperationEvent -Operation 'Renamed' -FilePath $Event.SourceEventArgs.FullPath -OldFilePath $Event.SourceEventArgs.OldFullPath + } + + $watchers += $watcher + $subscriptions += @($onChanged, $onDeleted, $onRenamed) +} + +Write-FileCollectorLog "Collector started. Waiting for events..." + +try { + while ($true) { + Start-Sleep -Seconds $PollSeconds + } +} +finally { + Write-FileCollectorLog "Stopping collector..." + foreach ($sub in @($subscriptions)) { + try { + if ($sub -and $sub.Id) { + Unregister-Event -SubscriptionId $sub.Id -ErrorAction SilentlyContinue + Remove-Job -Id $sub.Id -Force -ErrorAction SilentlyContinue + } + } catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds = 10, + [string[]]$WatchPaths = @('Desktop', 'Documents', 'Downloads') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Force TLS 1.2 and load networking types +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 +Add-Type -AssemblyName System.Net.Http + +# Bucket registry +$script:KnownBuckets = @{} +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId + +# Настройка логирования +$script:LogPath = $LogPath +$script:LocalAgentLogsEnabled = [bool]$LogPath + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-FileCollectorLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { return } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} [FileCollector] {1}' -f (Get-Date -Format s), $Message) + } catch {} +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + $httpClient = $null + try { + $httpClient = New-Object System.Net.Http.HttpClient + $content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json") + $response = $httpClient.PostAsync($Uri, $content).Result + if (-not $response.IsSuccessStatusCode) { + $status = [int]$response.StatusCode + $reason = [string]$response.ReasonPhrase + $body = $response.Content.ReadAsStringAsync().Result + Write-FileCollectorLog ("POST failed: uri={0} status={1} reason={2} body={3}" -f $Uri, $status, $reason, $body) + } + } catch { + Write-FileCollectorLog "POST Error: $($_.Exception.Message)" + } finally { + if ($null -ne $httpClient) { + $httpClient.Dispose() + } + } +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + } + catch { + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + catch { + Write-FileCollectorLog "Bucket create/check failed for ${BucketId}: $($_.Exception.Message)" + throw + } + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-FileOperationEvent { + param( + [string]$Operation, + [string]$FilePath, + [string]$OldFilePath = $null, + [long]$Size = 0 + ) + + $bucketId = 'aw-file-operations_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' + + $data = @{ + operation = $Operation + path = $FilePath + extension = [System.IO.Path]::GetExtension($FilePath) + username = $env:USERNAME + hostname = $script:Hostname + } + if ($OldFilePath) { $data.oldPath = $OldFilePath } + if ($Size -gt 0) { $data.size = $Size } + + # Детекция архивации (упрощенная) + if ($Operation -eq 'Created' -and $data.extension -match '\.(zip|7z|rar|tar|gz)$') { + $data.archiveHint = $true + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = $data + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=15" -Json $payload +} + +$config = Get-DeploymentConfig -Path $ConfigPath +if (-not $config) { throw "Configuration file not found: $ConfigPath" } + +$scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) { $config.server.scheme } else { 'http' } +$hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' } +$port = if ($ServerPort) { $ServerPort } elseif ($config.server.port) { $config.server.port } else { 5600 } +$script:ApiBase = "{0}://{1}:{2}/api/0" -f $scheme, $hostName, $port + +$bucketId = 'aw-file-operations_' + $script:Hostname +Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' + +# Resolve paths for monitoring +$resolvedPaths = @() +foreach ($p in $WatchPaths) { + $fullPath = $p + if (-not [System.IO.Path]::IsPathRooted($p)) { + try { + if ($p -eq 'Desktop') { $fullPath = [Environment]::GetFolderPath('Desktop') } + elseif ($p -eq 'Documents') { $fullPath = [Environment]::GetFolderPath('MyDocuments') } + elseif ($p -eq 'Downloads') { $fullPath = Join-Path $env:USERPROFILE 'Downloads' } + } catch {} + } + if ($fullPath -and (Test-Path -LiteralPath $fullPath)) { + $resolvedPaths += $fullPath + } +} + +if ($resolvedPaths.Count -eq 0) { + Write-FileCollectorLog "No valid watch paths found. Exiting." + exit 0 +} + +Write-FileCollectorLog "Starting watch on paths: $($resolvedPaths -join ', ')" + +$watchers = @() +$subscriptions = @() +foreach ($path in $resolvedPaths) { + $watcher = New-Object System.IO.FileSystemWatcher + $watcher.Path = $path + $watcher.IncludeSubdirectories = $true + $watcher.EnableRaisingEvents = $true + + $onChanged = Register-ObjectEvent $watcher "Created" -Action { + $path = $Event.SourceEventArgs.FullPath + $size = 0 + try { if (Test-Path -LiteralPath $path) { $size = (Get-Item -LiteralPath $path).Length } } catch {} + Send-FileOperationEvent -Operation 'Created' -FilePath $path -Size $size + } + $onDeleted = Register-ObjectEvent $watcher "Deleted" -Action { + Send-FileOperationEvent -Operation 'Deleted' -FilePath $Event.SourceEventArgs.FullPath + } + $onRenamed = Register-ObjectEvent $watcher "Renamed" -Action { + Send-FileOperationEvent -Operation 'Renamed' -FilePath $Event.SourceEventArgs.FullPath -OldFilePath $Event.SourceEventArgs.OldFullPath + } + + $watchers += $watcher + $subscriptions += @($onChanged, $onDeleted, $onRenamed) +} + +Write-FileCollectorLog "Collector started. Waiting for events..." + +try { + while ($true) { + Start-Sleep -Seconds $PollSeconds + } +} +finally { + Write-FileCollectorLog "Stopping collector..." + foreach ($sub in @($subscriptions)) { + try { + if ($sub -and $sub.Id) { + Unregister-Event -SubscriptionId $sub.Id -ErrorAction SilentlyContinue + Remove-Job -Id $sub.Id -Force -ErrorAction SilentlyContinue + } + } catch {} + } + foreach ($w in $watchers) { + $w.EnableRaisingEvents = $false + $w.Dispose() + } +} +; } + } + foreach ($w in $watchers) { + $w.EnableRaisingEvents = $false + $w.Dispose() + } +} diff --git a/windows/hardening-recovery.ps1 b/windows/hardening-recovery.ps1 index d382f41..1c13665 100755 --- a/windows/hardening-recovery.ps1 +++ b/windows/hardening-recovery.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$ServerHost, @@ -151,6 +151,6 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $effectiveRecoveryScript -ConfigPath $effectiveConfigPath Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName -Write-Host 'Укрепление и восстановление ActivityWatch завершены.' -Write-Host "Конфигурация: $effectiveConfigPath" -Write-Host "Пользователи восстановлены: $($effectiveUsers -join ', ')" +Write-Output 'Укрепление и восстановление ActivityWatch завершены.' +Write-Output "Конфигурация: $effectiveConfigPath" +Write-Output "Пользователи восстановлены: $($effectiveUsers -join ', ')" diff --git a/windows/migrate-awatch-rus-paths.ps1 b/windows/migrate-awatch-rus-paths.ps1 index c588050..f91e6eb 100644 --- a/windows/migrate-awatch-rus-paths.ps1 +++ b/windows/migrate-awatch-rus-paths.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding(SupportsShouldProcess = $true)] +[CmdletBinding(SupportsShouldProcess = $true)] param( [string]$OldInstallRoot = 'C:\Program Files\ActivityWatch-Phase2', [string]$OldStateRoot = 'C:\ProgramData\ActivityWatch-Phase2', diff --git a/windows/validate-deployment.ps1 b/windows/validate-deployment.ps1 index 41b6bd9..7c06f5a 100644 --- a/windows/validate-deployment.ps1 +++ b/windows/validate-deployment.ps1 @@ -1,4 +1,35 @@ -[CmdletBinding()] +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1' +Import-Module $modulePath -Force + +$config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath +$installRoot = [string]$config.paths.installRoot +$stateRoot = [string]$config.paths.stateRoot +$collectorScript = [string]$config.paths.collectorScript +$endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' } +$fileCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$config.paths.fileCollectorScript } else { Join-Path $stateRoot 'file-operations-collector.ps1' } +$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' } +$rulesPath = [string]$config.paths.rulesPath +$policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$config.paths.policyPath } else { Join-Path $stateRoot 'dlp-policy.json' } +$launchScript = [string]$config.paths.launchScript +$recoveryScript = [string]$config.paths.recoveryScript + +$afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true } +$windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true } +$fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true } +$printServiceOperationalEnabled = $false +try { + $printServiceLog = Get-WinEvent -ListLog 'Microsoft-Windows-PrintService/Operational' -ErrorAction Stop + $printServiceOperationalEnabled = [bool]$printServiceLog.IsEnabled +} +catch { Write-Error [CmdletBinding()] param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json' ) @@ -144,3 +175,262 @@ $result = [ordered]@{ $result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok -and $result.printTelemetry.ok) $result +; } +$printJobTitlePolicyEnabled = $false +try { + $printPolicy = Get-ItemProperty -LiteralPath 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' -Name 'ShowJobTitleInEventLogs' -ErrorAction Stop + $printJobTitlePolicyEnabled = ([int]$printPolicy.ShowJobTitleInEventLogs -eq 1) +} +catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1' +Import-Module $modulePath -Force + +$config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath +$installRoot = [string]$config.paths.installRoot +$stateRoot = [string]$config.paths.stateRoot +$collectorScript = [string]$config.paths.collectorScript +$endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' } +$fileCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$config.paths.fileCollectorScript } else { Join-Path $stateRoot 'file-operations-collector.ps1' } +$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' } +$rulesPath = [string]$config.paths.rulesPath +$policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$config.paths.policyPath } else { Join-Path $stateRoot 'dlp-policy.json' } +$launchScript = [string]$config.paths.launchScript +$recoveryScript = [string]$config.paths.recoveryScript + +$afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true } +$windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true } +$fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true } +$printServiceOperationalEnabled = $false +try { + $printServiceLog = Get-WinEvent -ListLog 'Microsoft-Windows-PrintService/Operational' -ErrorAction Stop + $printServiceOperationalEnabled = [bool]$printServiceLog.IsEnabled +} +catch { +} +$printJobTitlePolicyEnabled = $false +try { + $printPolicy = Get-ItemProperty -LiteralPath 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' -Name 'ShowJobTitleInEventLogs' -ErrorAction Stop + $printJobTitlePolicyEnabled = ([int]$printPolicy.ShowJobTitleInEventLogs -eq 1) +} +catch { +} +$requiredFiles = @( + $collectorScript, + $endpointCollectorScript, + $sessionCollectorScript, + $rulesPath, + $policyPath, + $launchScript, + $recoveryScript, + $ConfigPath +) +if ($fileOpsExpected) { + $requiredFiles += $fileCollectorScript +} +if ($afkExpected) { + $requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe') +} +if ($windowExpected) { + $requiredFiles += (Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe') +} + +$missingFiles = @( + $requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_) } +) + +$processNames = @() +if ($afkExpected) { $processNames += 'aw-watcher-afk' } +if ($windowExpected) { $processNames += 'aw-watcher-window' } +$runningProcesses = @() +if ($processNames.Count -gt 0) { + $runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId +} +$sessionCollectorProcesses = @( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and + $_.CommandLine -match [Regex]::Escape($sessionCollectorScript) + } | + Select-Object Name, ProcessId, SessionId, CommandLine +) + +$taskNames = @() +if ($config.userTasks) { + $taskNames += @($config.userTasks | ForEach-Object { [string]$_.launchTaskName }) +} +$taskNames += [string]$config.recovery.taskName +$taskNames = $taskNames | Sort-Object -Unique + +$tasks = @( + foreach ($taskName in $taskNames) { + $task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1 + if ($task) { + [pscustomobject]@{ + taskName = $task.TaskName + state = [string]$task.State + present = $true + } + } + else { + [pscustomobject]@{ + taskName = $taskName + state = 'Отсутствует' + present = $false + } + } + } +) + +$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port +$uniqueRunningProcessNames = @($runningProcesses | Select-Object -ExpandProperty Name -Unique) +$result = [ordered]@{ + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + configPath = $ConfigPath + serverUrl = $serverUrl + installRoot = $installRoot + stateRoot = $stateRoot + files = [ordered]@{ + required = $requiredFiles + missing = $missingFiles + ok = ($missingFiles.Count -eq 0) + } + tasks = [ordered]@{ + list = $tasks + ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present })) + } + processes = [ordered]@{ + expected = $processNames + list = @($runningProcesses) + sessionCollectors = @($sessionCollectorProcesses) + ok = [bool]( + ( + ($processNames.Count -eq 0) -or + ($uniqueRunningProcessNames.Count -ge $processNames.Count) + ) -and + ($sessionCollectorProcesses.Count -ge 1) + ) + } + printTelemetry = [ordered]@{ + operationalLogEnabled = $printServiceOperationalEnabled + jobTitlePolicyEnabled = $printJobTitlePolicyEnabled + ok = [bool]($printServiceOperationalEnabled -and $printJobTitlePolicyEnabled) + } +} + +$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok -and $result.printTelemetry.ok) + +$result +; } +$requiredFiles = @( + $collectorScript, + $endpointCollectorScript, + $sessionCollectorScript, + $rulesPath, + $policyPath, + $launchScript, + $recoveryScript, + $ConfigPath +) +if ($fileOpsExpected) { + $requiredFiles += $fileCollectorScript +} +if ($afkExpected) { + $requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe') +} +if ($windowExpected) { + $requiredFiles += (Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe') +} + +$missingFiles = @( + $requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_) } +) + +$processNames = @() +if ($afkExpected) { $processNames += 'aw-watcher-afk' } +if ($windowExpected) { $processNames += 'aw-watcher-window' } +$runningProcesses = @() +if ($processNames.Count -gt 0) { + $runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId +} +$sessionCollectorProcesses = @( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and + $_.CommandLine -match [Regex]::Escape($sessionCollectorScript) + } | + Select-Object Name, ProcessId, SessionId, CommandLine +) + +$taskNames = @() +if ($config.userTasks) { + $taskNames += @($config.userTasks | ForEach-Object { [string]$_.launchTaskName }) +} +$taskNames += [string]$config.recovery.taskName +$taskNames = $taskNames | Sort-Object -Unique + +$tasks = @( + foreach ($taskName in $taskNames) { + $task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1 + if ($task) { + [pscustomobject]@{ + taskName = $task.TaskName + state = [string]$task.State + present = $true + } + } + else { + [pscustomobject]@{ + taskName = $taskName + state = 'Отсутствует' + present = $false + } + } + } +) + +$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port +$uniqueRunningProcessNames = @($runningProcesses | Select-Object -ExpandProperty Name -Unique) +$result = [ordered]@{ + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + configPath = $ConfigPath + serverUrl = $serverUrl + installRoot = $installRoot + stateRoot = $stateRoot + files = [ordered]@{ + required = $requiredFiles + missing = $missingFiles + ok = ($missingFiles.Count -eq 0) + } + tasks = [ordered]@{ + list = $tasks + ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present })) + } + processes = [ordered]@{ + expected = $processNames + list = @($runningProcesses) + sessionCollectors = @($sessionCollectorProcesses) + ok = [bool]( + ( + ($processNames.Count -eq 0) -or + ($uniqueRunningProcessNames.Count -ge $processNames.Count) + ) -and + ($sessionCollectorProcesses.Count -ge 1) + ) + } + printTelemetry = [ordered]@{ + operationalLogEnabled = $printServiceOperationalEnabled + jobTitlePolicyEnabled = $printJobTitlePolicyEnabled + ok = [bool]($printServiceOperationalEnabled -and $printJobTitlePolicyEnabled) + } +} + +$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok -and $result.printTelemetry.ok) + +$result diff --git a/windows/worktime-session-collector.ps1 b/windows/worktime-session-collector.ps1 index 7ebf555..e3021c5 100644 --- a/windows/worktime-session-collector.ps1 +++ b/windows/worktime-session-collector.ps1 @@ -1,4 +1,44 @@ -param( +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$Hostname, + [int]$PollSeconds = 30 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-Config { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация не найдена: $Path" + } + + Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [Parameter(Mandatory = $true)][string]$ApiBase, + [Parameter(Mandatory = $true)][string]$BucketId, + [Parameter(Mandatory = $true)][string]$HostnameValue + ) + + try { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + return + } + catch { Write-Error param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$Hostname, [int]$PollSeconds = 30 @@ -164,3 +204,458 @@ while ($true) { Start-Sleep -Seconds $sleepSec } +; } + + $body = @{ + client = 'aw-worktime-session-collector' + type = 'aw.worktime.session' + hostname = $HostnameValue + } | ConvertTo-Json -Compress + + try { + Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body + } + catch { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + } +} + +function Get-SessionRecords { + $records = @() + + try { + $lines = quser 2>$null + if (-not $lines) { + return @() + } + + foreach ($line in ($lines | Select-Object -Skip 1)) { + $clean = ($line -replace '^\s*>?', '').Trim() + if (-not $clean) { + continue + } + + $parts = $clean -split '\s+' + if ($parts.Count -lt 4) { + continue + } + + $sessionName = '' + $sessionIdIndex = 2 + if ($parts[1] -match '^\d+$') { + $sessionIdIndex = 1 + } + else { + $sessionName = $parts[1] + } + + $sessionId = 0 + if ($parts[$sessionIdIndex] -match '^\d+$') { + $sessionId = [int]$parts[$sessionIdIndex] + } + + $records += [pscustomobject]@{ + username = $parts[0] + sessionName = $sessionName + sessionId = $sessionId + state = $parts[$sessionIdIndex + 1] + } + } + } + catch { Write-Error param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$Hostname, + [int]$PollSeconds = 30 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-Config { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация не найдена: $Path" + } + + Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [Parameter(Mandatory = $true)][string]$ApiBase, + [Parameter(Mandatory = $true)][string]$BucketId, + [Parameter(Mandatory = $true)][string]$HostnameValue + ) + + try { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + return + } + catch { + } + + $body = @{ + client = 'aw-worktime-session-collector' + type = 'aw.worktime.session' + hostname = $HostnameValue + } | ConvertTo-Json -Compress + + try { + Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body + } + catch { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + } +} + +function Get-SessionRecords { + $records = @() + + try { + $lines = quser 2>$null + if (-not $lines) { + return @() + } + + foreach ($line in ($lines | Select-Object -Skip 1)) { + $clean = ($line -replace '^\s*>?', '').Trim() + if (-not $clean) { + continue + } + + $parts = $clean -split '\s+' + if ($parts.Count -lt 4) { + continue + } + + $sessionName = '' + $sessionIdIndex = 2 + if ($parts[1] -match '^\d+$') { + $sessionIdIndex = 1 + } + else { + $sessionName = $parts[1] + } + + $sessionId = 0 + if ($parts[$sessionIdIndex] -match '^\d+$') { + $sessionId = [int]$parts[$sessionIdIndex] + } + + $records += [pscustomobject]@{ + username = $parts[0] + sessionName = $sessionName + sessionId = $sessionId + state = $parts[$sessionIdIndex + 1] + } + } + } + catch { + } + + return $records +} + +function Test-SessionIsActive { + param([AllowNull()][string]$State) + if ([string]::IsNullOrWhiteSpace($State)) { return $false } + $s = $State.Trim().ToLowerInvariant() + return ($s -eq 'active') -or ($s -like 'актив*') +} + +$cfg = Get-Config -Path $ConfigPath +$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME } +$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port +$bucketId = 'aw-worktime-sessions_' + $hostValue +$pulse = 120 +$sleepSec = if ($PollSeconds -gt 0) { + $PollSeconds +} +elseif ($cfg.collector -and $cfg.collector.pollSeconds) { + [int]$cfg.collector.pollSeconds +} +else { + 30 +} + +Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue + +while ($true) { + $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $records = Get-SessionRecords + if (-not $records -or $records.Count -eq 0) { + $records = @([pscustomobject]@{ + username = $env:USERNAME + sessionName = '' + sessionId = (Get-Process -Id $PID).SessionId + state = 'Unknown' + }) + } + + foreach ($rec in $records) { + $payload = @{ + timestamp = $now + duration = 0 + data = @{ + username = [string]$rec.username + userId = "$($env:USERDOMAIN)\$($rec.username)" + sessionId = [int]$rec.sessionId + sessionName = [string]$rec.sessionName + state = [string]$rec.state + active = (Test-SessionIsActive -State ([string]$rec.state)) + hostname = $hostValue + source = 'worktime-session-collector' + } + } | ConvertTo-Json -Depth 6 -Compress + + try { + Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload + } + catch { + } + } + + Start-Sleep -Seconds $sleepSec +} +; } + + return $records +} + +function Test-SessionIsActive { + param([AllowNull()][string]$State) + if ([string]::IsNullOrWhiteSpace($State)) { return $false } + $s = $State.Trim().ToLowerInvariant() + return ($s -eq 'active') -or ($s -like 'актив*') +} + +$cfg = Get-Config -Path $ConfigPath +$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME } +$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port +$bucketId = 'aw-worktime-sessions_' + $hostValue +$pulse = 120 +$sleepSec = if ($PollSeconds -gt 0) { + $PollSeconds +} +elseif ($cfg.collector -and $cfg.collector.pollSeconds) { + [int]$cfg.collector.pollSeconds +} +else { + 30 +} + +Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue + +while ($true) { + $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $records = Get-SessionRecords + if (-not $records -or $records.Count -eq 0) { + $records = @([pscustomobject]@{ + username = $env:USERNAME + sessionName = '' + sessionId = (Get-Process -Id $PID).SessionId + state = 'Unknown' + }) + } + + foreach ($rec in $records) { + $payload = @{ + timestamp = $now + duration = 0 + data = @{ + username = [string]$rec.username + userId = "$($env:USERDOMAIN)\$($rec.username)" + sessionId = [int]$rec.sessionId + sessionName = [string]$rec.sessionName + state = [string]$rec.state + active = (Test-SessionIsActive -State ([string]$rec.state)) + hostname = $hostValue + source = 'worktime-session-collector' + } + } | ConvertTo-Json -Depth 6 -Compress + + try { + Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload + } + catch { Write-Error param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$Hostname, + [int]$PollSeconds = 30 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-Config { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация не найдена: $Path" + } + + Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [Parameter(Mandatory = $true)][string]$ApiBase, + [Parameter(Mandatory = $true)][string]$BucketId, + [Parameter(Mandatory = $true)][string]$HostnameValue + ) + + try { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + return + } + catch { + } + + $body = @{ + client = 'aw-worktime-session-collector' + type = 'aw.worktime.session' + hostname = $HostnameValue + } | ConvertTo-Json -Compress + + try { + Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body + } + catch { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + } +} + +function Get-SessionRecords { + $records = @() + + try { + $lines = quser 2>$null + if (-not $lines) { + return @() + } + + foreach ($line in ($lines | Select-Object -Skip 1)) { + $clean = ($line -replace '^\s*>?', '').Trim() + if (-not $clean) { + continue + } + + $parts = $clean -split '\s+' + if ($parts.Count -lt 4) { + continue + } + + $sessionName = '' + $sessionIdIndex = 2 + if ($parts[1] -match '^\d+$') { + $sessionIdIndex = 1 + } + else { + $sessionName = $parts[1] + } + + $sessionId = 0 + if ($parts[$sessionIdIndex] -match '^\d+$') { + $sessionId = [int]$parts[$sessionIdIndex] + } + + $records += [pscustomobject]@{ + username = $parts[0] + sessionName = $sessionName + sessionId = $sessionId + state = $parts[$sessionIdIndex + 1] + } + } + } + catch { + } + + return $records +} + +function Test-SessionIsActive { + param([AllowNull()][string]$State) + if ([string]::IsNullOrWhiteSpace($State)) { return $false } + $s = $State.Trim().ToLowerInvariant() + return ($s -eq 'active') -or ($s -like 'актив*') +} + +$cfg = Get-Config -Path $ConfigPath +$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME } +$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port +$bucketId = 'aw-worktime-sessions_' + $hostValue +$pulse = 120 +$sleepSec = if ($PollSeconds -gt 0) { + $PollSeconds +} +elseif ($cfg.collector -and $cfg.collector.pollSeconds) { + [int]$cfg.collector.pollSeconds +} +else { + 30 +} + +Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue + +while ($true) { + $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $records = Get-SessionRecords + if (-not $records -or $records.Count -eq 0) { + $records = @([pscustomobject]@{ + username = $env:USERNAME + sessionName = '' + sessionId = (Get-Process -Id $PID).SessionId + state = 'Unknown' + }) + } + + foreach ($rec in $records) { + $payload = @{ + timestamp = $now + duration = 0 + data = @{ + username = [string]$rec.username + userId = "$($env:USERDOMAIN)\$($rec.username)" + sessionId = [int]$rec.sessionId + sessionName = [string]$rec.sessionName + state = [string]$rec.state + active = (Test-SessionIsActive -State ([string]$rec.state)) + hostname = $hostValue + source = 'worktime-session-collector' + } + } | ConvertTo-Json -Depth 6 -Compress + + try { + Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload + } + catch { + } + } + + Start-Sleep -Seconds $sleepSec +} +; } + } + + Start-Sleep -Seconds $sleepSec +}