diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index 31884b2..4180964 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -360,6 +360,11 @@ function New-ActivityWatchDeploymentConfig { logging = [pscustomobject]@{ localAgentLogsEnabled = $LocalAgentLogsEnabled } + incidentCapture = [pscustomobject]@{ + enabled = $true + screenshotEnabled = $true + artifactsRoot = (Join-Path $StateRoot 'incident-artifacts') + } sessionEvents = [pscustomobject]@{ logonEnabled = $LogonMarkerEnabled bucketPrefix = 'aw-session-events' diff --git a/windows/browser-domains-native-collector.ps1 b/windows/browser-domains-native-collector.ps1 index bd28f71..220961e 100755 --- a/windows/browser-domains-native-collector.ps1 +++ b/windows/browser-domains-native-collector.ps1 @@ -60,6 +60,8 @@ $resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.log $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 'ActivityWatch-Phase2\\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 @@ -72,6 +74,9 @@ $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]@{ @@ -504,6 +509,15 @@ function Send-DlpIncidentHeartbeat { $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 @@ -524,12 +538,98 @@ function Send-DlpIncidentHeartbeat { 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 | 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 ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + function Get-ForegroundWindowContext { $handle = [NativeAwMethods]::GetForegroundWindow() if ($handle -eq [IntPtr]::Zero) { diff --git a/windows/dlp-endpoint-signals-collector.ps1 b/windows/dlp-endpoint-signals-collector.ps1 index 6c122f8..7624c90 100644 --- a/windows/dlp-endpoint-signals-collector.ps1 +++ b/windows/dlp-endpoint-signals-collector.ps1 @@ -101,6 +101,15 @@ function Send-DlpIncidentHeartbeat { $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 @@ -114,12 +123,98 @@ function Send-DlpIncidentHeartbeat { sessionId = $script:SessionId hostname = $script:Hostname source = 'endpoint-signals-phase2' - } + $Data + } + $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 @{} + } +} + function Get-StringHash { param([AllowNull()][string]$Value) if ($null -eq $Value) { return $null } @@ -463,6 +558,8 @@ $resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $Pol $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) } $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 'ActivityWatch-Phase2\\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 @@ -480,6 +577,9 @@ $script:LastClipboardHash = $null $script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) $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)