From bf27369b2ab3ef1a4409d278c3da67b5fbbc3e13 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Sat, 25 Apr 2026 16:26:07 +0300 Subject: [PATCH] feat(dlp-phase2): add endpoint signal collector for clipboard usb print and policy wiring --- README.md | 2 +- docs/dlp-gap-analysis.md | 9 +- docs/windows/deployment.md | 2 + docs/windows/validation.md | 1 + windows/ActivityWatch.Windows.Common.psm1 | 59 ++- windows/deploy-domain-users.ps1 | 3 + windows/deploy-single-user.ps1 | 3 + windows/dlp-endpoint-signals-collector.ps1 | 405 +++++++++++++++++++++ windows/dlp-policy.example.json | 43 ++- windows/hardening-recovery.ps1 | 4 + windows/validate-deployment.ps1 | 2 + 11 files changed, 511 insertions(+), 22 deletions(-) create mode 100644 windows/dlp-endpoint-signals-collector.ps1 diff --git a/README.md b/README.md index ae57358..f31dfa8 100755 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI. - `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT). - `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation. -- `windows/` — PowerShell toolkit + phase-1 DLP policy/incident pipeline (`aw-dlp-incidents_*`). +- `windows/` — PowerShell toolkit + phase-2 DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`). - `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок. ## Базовый сценарий diff --git a/docs/dlp-gap-analysis.md b/docs/dlp-gap-analysis.md index baa47d0..cd0525e 100644 --- a/docs/dlp-gap-analysis.md +++ b/docs/dlp-gap-analysis.md @@ -25,11 +25,12 @@ - Incident generation в отдельный AW bucket. - Incident cooldown/dedup. -### Phase 2 (следующий шаг) +### Phase 2 (внедрено частично) -- USB/print/clipboard collectors. -- File-operation telemetry (create/copy/archive/upload hints). -- Central incident aggregation/export. +- USB/print/clipboard collectors (endpoint signals) — внедрено. +- Incident pipeline расширен на endpoint события — внедрено. +- File-operation telemetry (create/copy/archive/upload hints) — в backlog. +- Central incident aggregation/export — в backlog. ### Phase 3 diff --git a/docs/windows/deployment.md b/docs/windows/deployment.md index 4239a87..731bb08 100755 --- a/docs/windows/deployment.md +++ b/docs/windows/deployment.md @@ -8,6 +8,7 @@ - `windows/hardening-recovery.ps1` — повторная регистрация задач, ACL и recovery-loop. - `windows/validate-deployment.ps1` — машинная проверка состояния и JSON-отчёт. - `windows/browser-domains-native-collector.ps1` — native collector доменов браузера с категоризацией. +- `windows/dlp-endpoint-signals-collector.ps1` — phase-2 collector (clipboard/USB/print signals). - `windows/web-category-rules.example.json` — пример кастомных правил категоризации. - `windows/dlp-policy.example.json` — пример DLP-политики (phase-1: alerting incidents). @@ -124,6 +125,7 @@ CSV-формат: колонка `User`, `Username`, `SamAccountName` или `Lo - `C:\ProgramData\ActivityWatch\launch-watchers.ps1` — per-user launcher. - `C:\ProgramData\ActivityWatch\recovery-loop.ps1` — system recovery loop. - `C:\ProgramData\ActivityWatch\browser-domains-native-collector.ps1` — runtime collector. +- `C:\ProgramData\ActivityWatch\dlp-endpoint-signals-collector.ps1` — runtime endpoint collector. - `C:\ProgramData\ActivityWatch\dlp-policy.json` — активная DLP-политика. - `C:\ProgramData\ActivityWatch\logs\` — логи collector'а. diff --git a/docs/windows/validation.md b/docs/windows/validation.md index f524b9f..f40521a 100755 --- a/docs/windows/validation.md +++ b/docs/windows/validation.md @@ -73,6 +73,7 @@ Invoke-WebRequest https://aw.example.local/api/0/info - `aw-watcher-window_` - `aw-watcher-web-edge_` или другой browser bucket - `aw-detmir-web-category_` +- `aw-dlp-endpoint-signals_` - `aw-dlp-incidents_` (при срабатывании policy rule с `action=alert|block|quarantine`) Проверка через API: diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index ea481bb..ccf8cbe 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -241,6 +241,8 @@ function Copy-ActivityWatchCollectorAssets { [Parameter(Mandatory = $true)] [string]$CollectorScriptSource, [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScriptSource, + [Parameter(Mandatory = $true)] [string]$ExampleRulesSource, [Parameter(Mandatory = $true)] [string]$ExamplePolicySource, @@ -253,12 +255,14 @@ function Copy-ActivityWatchCollectorAssets { New-ActivityWatchDirectory -Path $StateRoot $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' + $endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-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 $ExampleRulesSource -Destination $exampleRulesTarget -Force Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force @@ -276,11 +280,12 @@ function Copy-ActivityWatchCollectorAssets { } return [pscustomobject]@{ - CollectorScript = $collectorTarget - ExampleRules = $exampleRulesTarget - ActiveRules = $rulesTarget - ExamplePolicy = $examplePolicyTarget - ActivePolicy = $policyTarget + CollectorScript = $collectorTarget + EndpointCollectorScript = $endpointCollectorTarget + ExampleRules = $exampleRulesTarget + ActiveRules = $rulesTarget + ExamplePolicy = $examplePolicyTarget + ActivePolicy = $policyTarget } } @@ -301,6 +306,8 @@ function New-ActivityWatchDeploymentConfig { [Parameter(Mandatory = $true)] [string]$CollectorScript, [Parameter(Mandatory = $true)] + [string]$EndpointCollectorScript, + [Parameter(Mandatory = $true)] [string]$RulesPath, [Parameter(Mandatory = $true)] [string]$PolicyPath, @@ -332,6 +339,7 @@ function New-ActivityWatchDeploymentConfig { stateRoot = $StateRoot logsRoot = $LogsRoot collectorScript = $CollectorScript + endpointCollectorScript = $EndpointCollectorScript rulesPath = $RulesPath policyPath = $PolicyPath launchScript = $LaunchScriptPath @@ -418,11 +426,11 @@ function Test-ProcessInSession { function Test-CollectorRunning { param( - [string]`$CollectorScript, + [string]`$ScriptPath, [int]`$SessionId ) - `$escapedCollector = [Regex]::Escape(`$CollectorScript) + `$escapedCollector = [Regex]::Escape(`$ScriptPath) `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and @@ -433,10 +441,36 @@ function Test-CollectorRunning { return [bool](`$processes | Select-Object -First 1) } +function Start-CollectorScriptIfNeeded { + param( + [string]`$ScriptPath, + [string]`$ConfigPath, + [string]`$PowerShellExe, + [int]`$SessionId + ) + + if (-not (Test-Path -LiteralPath `$ScriptPath)) { + return + } + + if (Test-CollectorRunning -ScriptPath `$ScriptPath -SessionId `$SessionId) { + return + } + + Start-Process -FilePath `$PowerShellExe -ArgumentList @( + '-NoProfile', + '-WindowStyle', 'Hidden', + '-ExecutionPolicy', 'Bypass', + '-File', `$ScriptPath, + '-ConfigPath', `$ConfigPath + ) -WindowStyle Hidden +} + `$config = Get-DeploymentConfig -Path `$ConfigPath `$sessionId = (Get-Process -Id `$PID).SessionId `$installRoot = [string]`$config.paths.installRoot `$collectorScript = [string]`$config.paths.collectorScript +`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { '' } `$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) @@ -458,15 +492,8 @@ if (-not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden } -if ((Test-Path -LiteralPath `$collectorScript) -and -not (Test-CollectorRunning -CollectorScript `$collectorScript -SessionId `$sessionId)) { - Start-Process -FilePath `$powershellExe -ArgumentList @( - '-NoProfile', - '-WindowStyle', 'Hidden', - '-ExecutionPolicy', 'Bypass', - '-File', `$collectorScript, - '-ConfigPath', `$ConfigPath - ) -WindowStyle Hidden -} +Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId +Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId "@ Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 diff --git a/windows/deploy-domain-users.ps1 b/windows/deploy-domain-users.ps1 index 9dc4875..89c1727 100755 --- a/windows/deploy-domain-users.ps1 +++ b/windows/deploy-domain-users.ps1 @@ -36,6 +36,7 @@ $configPath = Join-Path $StateRoot 'deployment-config.json' $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1' $recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1' $collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1' +$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1' $exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json' $examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json' @@ -48,6 +49,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null $assetResult = Copy-ActivityWatchCollectorAssets ` -CollectorScriptSource $collectorSource ` + -EndpointCollectorScriptSource $endpointCollectorSource ` -ExampleRulesSource $exampleRulesSource ` -ExamplePolicySource $examplePolicySource ` -StateRoot $StateRoot ` @@ -66,6 +68,7 @@ $config = New-ActivityWatchDeploymentConfig ` -StateRoot $StateRoot ` -LogsRoot $logsRoot ` -CollectorScript $assetResult.CollectorScript ` + -EndpointCollectorScript $assetResult.EndpointCollectorScript ` -RulesPath $assetResult.ActiveRules ` -PolicyPath $assetResult.ActivePolicy ` -PollSeconds $PollSeconds ` diff --git a/windows/deploy-single-user.ps1 b/windows/deploy-single-user.ps1 index 150e44e..0a94b5a 100755 --- a/windows/deploy-single-user.ps1 +++ b/windows/deploy-single-user.ps1 @@ -34,6 +34,7 @@ $configPath = Join-Path $StateRoot 'deployment-config.json' $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1' $recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1' $collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1' +$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1' $exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json' $examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json' @@ -46,6 +47,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null $assetResult = Copy-ActivityWatchCollectorAssets ` -CollectorScriptSource $collectorSource ` + -EndpointCollectorScriptSource $endpointCollectorSource ` -ExampleRulesSource $exampleRulesSource ` -ExamplePolicySource $examplePolicySource ` -StateRoot $StateRoot ` @@ -64,6 +66,7 @@ $config = New-ActivityWatchDeploymentConfig ` -StateRoot $StateRoot ` -LogsRoot $logsRoot ` -CollectorScript $assetResult.CollectorScript ` + -EndpointCollectorScript $assetResult.EndpointCollectorScript ` -RulesPath $assetResult.ActiveRules ` -PolicyPath $assetResult.ActivePolicy ` -PollSeconds $PollSeconds ` diff --git a/windows/dlp-endpoint-signals-collector.ps1 b/windows/dlp-endpoint-signals-collector.ps1 new file mode 100644 index 0000000..803322f --- /dev/null +++ b/windows/dlp-endpoint-signals-collector.ps1 @@ -0,0 +1,405 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\ActivityWatch\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) + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +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-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json' -Body $body | Out-Null + $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-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -ContentType 'application/json' -Body $payload | Out-Null +} + +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' + + $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 + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -ContentType 'application/json' -Body $payload | Out-Null +} + +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 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" } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2}" -f $ruleId, $action, $severity) + } +} + +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" } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3}" -f $ruleId, $action, $severity, $DriveLetter) + } +} + +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" } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3}" -f $ruleId, $action, $severity, $PrinterName) + } +} + +$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\ActivityWatch\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\ActivityWatch\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } + +if (-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:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:LogPath = $resolvedLogPath + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-Clipboard -Raw -ErrorAction SilentlyContinue + 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 + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + 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 { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} diff --git a/windows/dlp-policy.example.json b/windows/dlp-policy.example.json index b7d4cfd..5ed8ef6 100644 --- a/windows/dlp-policy.example.json +++ b/windows/dlp-policy.example.json @@ -54,5 +54,46 @@ ] } } - ] + ], + "endpoint": { + "clipboard": [ + { + "id": "clipboard-sensitive-keywords", + "enabled": true, + "cooldownSeconds": 300, + "action": "alert", + "severity": "high", + "message": "В буфере обнаружены чувствительные ключевые слова", + "minLength": 20, + "regexPatterns": [ + "(?i)парол(ь|и)", + "(?i)password", + "(?i)secret", + "(?i)cvv", + "(?i)паспорт" + ] + } + ], + "usb": [ + { + "id": "usb-media-connected", + "enabled": true, + "cooldownSeconds": 300, + "action": "alert", + "severity": "medium", + "message": "Подключен съемный носитель" + } + ], + "print": [ + { + "id": "print-sensitive-docs", + "enabled": true, + "cooldownSeconds": 300, + "action": "alert", + "severity": "high", + "message": "Печать документа с признаками чувствительных данных", + "documentRegex": "(?i)(salary|зарплат|passport|паспорт|договор|contract)" + } + ] + } } diff --git a/windows/hardening-recovery.ps1 b/windows/hardening-recovery.ps1 index e7887e8..38d9d08 100755 --- a/windows/hardening-recovery.ps1 +++ b/windows/hardening-recovery.ps1 @@ -45,6 +45,7 @@ $effectiveConfigPath = if ($ConfigPath) { $ConfigPath } else { Join-Path $effect $effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1' $effectiveRecoveryScript = Join-Path $effectiveStateRoot 'recovery-loop.ps1' $effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-collector.ps1' +$effectiveEndpointCollector = Join-Path $effectiveStateRoot 'dlp-endpoint-signals-collector.ps1' $effectiveRules = Join-Path $effectiveStateRoot 'web-category-rules.json' $effectivePolicy = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$existingConfig.paths.policyPath } else { Join-Path $effectiveStateRoot 'dlp-policy.json' } @@ -80,6 +81,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $effectiveInstallRoot | Out-Null $assetResult = Copy-ActivityWatchCollectorAssets ` -CollectorScriptSource (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') ` + -EndpointCollectorScriptSource (Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1') ` -ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') ` -ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') ` -StateRoot $effectiveStateRoot ` @@ -87,6 +89,7 @@ $assetResult = Copy-ActivityWatchCollectorAssets ` -CustomPolicySource $CustomPolicyPath $effectivePolicy = [string]$assetResult.ActivePolicy +$effectiveEndpointCollector = [string]$assetResult.EndpointCollectorScript $taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users $effectiveUsers Write-ActivityWatchLaunchScript -Path $effectiveLaunchScript -ConfigPath $effectiveConfigPath @@ -100,6 +103,7 @@ $config = New-ActivityWatchDeploymentConfig ` -StateRoot $effectiveStateRoot ` -LogsRoot $effectiveLogsRoot ` -CollectorScript $effectiveCollector ` + -EndpointCollectorScript $effectiveEndpointCollector ` -RulesPath $effectiveRules ` -PolicyPath $effectivePolicy ` -PollSeconds $effectivePollSeconds ` diff --git a/windows/validate-deployment.ps1 b/windows/validate-deployment.ps1 index f36cb55..9a405f1 100644 --- a/windows/validate-deployment.ps1 +++ b/windows/validate-deployment.ps1 @@ -13,6 +13,7 @@ $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' } $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 @@ -22,6 +23,7 @@ $requiredFiles = @( (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe'), (Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe'), $collectorScript, + $endpointCollectorScript, $rulesPath, $policyPath, $launchScript,