diff --git a/install-kit-awindows-20260427-211240.tar.gz b/install-kit-awindows-20260427-211240.tar.gz new file mode 100644 index 0000000..203655f Binary files /dev/null and b/install-kit-awindows-20260427-211240.tar.gz differ diff --git a/install-kit-awindows-20260427-211240.zip b/install-kit-awindows-20260427-211240.zip new file mode 100644 index 0000000..ac0b5be Binary files /dev/null and b/install-kit-awindows-20260427-211240.zip differ diff --git a/install-kit-awindows-20260427-211240/MANIFEST.txt b/install-kit-awindows-20260427-211240/MANIFEST.txt new file mode 100644 index 0000000..0e6157c --- /dev/null +++ b/install-kit-awindows-20260427-211240/MANIFEST.txt @@ -0,0 +1,27 @@ +KIT_DIR=install-kit-awindows-20260427-211240 +CREATED_AT=2026-04-27T21:14:11+03:00 + +FILES: +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/MANIFEST.txt +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/ansible/README.md +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows_phase2.yml +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/ansible/windows.example.yml +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-admin.deployment-config.json +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-u2u5.deployment-config.json +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-user1.deployment-config.json +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psd1 +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 +/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/web-category-rules.example.json + +SHA256: +f48965a47781c329c08e261461ac4808031e35c5fe24fb84a2f1eaa798cd042d /home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240.tar.gz +e1ef90f566d821b196f16dfa0810f86530cc0b92d13dc83c9ae8ab0427c37c0b /home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240.zip diff --git a/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt b/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt new file mode 100644 index 0000000..d09d6bb --- /dev/null +++ b/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt @@ -0,0 +1,10 @@ +ActivityWatch DetMir Windows Install Kit + +Includes: +- windows/* (deploy scripts, collectors, common module, configs/examples) +- ansible/deploy_aw_windows_phase2.yml +- ansible/group_vars/windows.example.yml +- ansible/README.md + +Source: +- Local project snapshot at build time. diff --git a/install-kit-awindows-20260427-211240/ansible/README.md b/install-kit-awindows-20260427-211240/ansible/README.md new file mode 100644 index 0000000..b8a82d7 --- /dev/null +++ b/install-kit-awindows-20260427-211240/ansible/README.md @@ -0,0 +1,119 @@ +# Ansible ensemble for AWatch-rus + +Эта директория содержит Ansible-ensemble для двух сценариев: + +- деплой на уже существующий Debian host/CT; +- полный цикл с нуля в Proxmox: создание CT + bootstrap + установка ActivityWatch + RU patch. +- централизованный деплой Windows phase-2 collectors по WinRM. +- deployment внешнего pfSense poller'а на Debian/Ubuntu utility VM. + +## Файлы + +- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_server.yml` — основной playbook. +- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_and_deploy_aw.yml` — full-stack playbook для Proxmox. +- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый full-stack playbook (несколько CT). +- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_windows_phase2.yml` — WinRM playbook для развёртывания phase-2 Windows collector'ов. +- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_pfsense_poller.yml` — deployment pfSense poller'а. +- `/home/igor/tmp/AWatch-rus/ansible/inventory.example.ini` — шаблон inventory. +- `/home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml` — шаблон переменных. +- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml` — шаблон переменных CT в Proxmox. +- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml` — шаблон матрицы CT. +- `/home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml` — шаблон переменных Windows phase-2. +- `/home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml` — шаблон переменных pfSense poller'а. + +## Быстрый запуск + +1. Скопируйте шаблоны: + - `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini` + - `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/all.yml` +2. Заполните значения в `inventory.ini` и `group_vars/all.yml`. +3. Запустите: + +```bash +cd /home/igor/tmp/AWatch-rus/ansible +ansible-playbook -i inventory.ini deploy_aw_server.yml +``` + +## Полный запуск с нуля в Proxmox + +1. Подготовьте inventory и vars: + - `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini` + - `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/all.yml` + - `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.yml` +2. Заполните `group_vars/proxmox.yml` и `group_vars/all.yml`. +3. Запустите playbook: + +```bash +cd /home/igor/tmp/AWatch-rus/ansible +ansible-playbook -i inventory.ini provision_proxmox_ct_and_deploy_aw.yml +``` + +## Массовый запуск (матрица CT) + +1. Подготовьте матрицу: + - `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.yml` +2. Заполните `proxmox-matrix.yml`. +3. Запустите: + +```bash +cd /home/igor/tmp/AWatch-rus/ansible +ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml +``` + +## Windows phase-2 rollout (WinRM) + +1. Подготовьте inventory и vars: + - `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini` + - `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.yml` +2. Заполните `inventory.ini` (секция `[aw_windows]`) и `group_vars/windows.yml`. +3. Запустите: + +```bash +cd /home/igor/tmp/AWatch-rus/ansible +ansible-playbook -i inventory.ini deploy_aw_windows_phase2.yml +``` + +Playbook: + +- выгружает `windows/*` toolkit на целевой хост в `C:\Deploy\AWatch-rus\windows`; +- выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с phase-2 policy/rules; +- запускает `validate-deployment.ps1`; +- забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation` по умолчанию). + +Дополнительные флаги: + +- `aw_windows_afk_enabled: false` — не запускать `aw-watcher-afk`; +- `aw_windows_window_enabled: false` — не запускать `aw-watcher-window`; +- `aw_windows_incident_capture_enabled: false` — отключить блок incidentCapture; +- `aw_windows_incident_screenshot_enabled: false` — не делать скриншот при DLP-инциденте; +- `aw_windows_incident_artifacts_root: 'C:\...\incident-artifacts'` — переопределить путь артефактов; +- `aw_windows_skip_hardening: true` — пропустить `hardening-recovery.ps1` внутри ensemble-скрипта. + +## pfSense poller rollout + +1. Подготовьте vars: + - `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.yml` +2. Добавьте inventory group `[aw_pfsense_pollers]`. +3. Запустите: + +```bash +cd /home/igor/tmp/AWatch-rus/ansible +ansible-playbook -i inventory.ini deploy_aw_pfsense_poller.yml +``` + +Playbook: + +- ставит `python3`; +- копирует `pfsense-aw-poller.py`; +- пишет `/etc/aw-pfsense/poller.json`; +- поднимает `aw-pfsense-poller.service`. + +## Результат + +- Установлен ActivityWatch Server. +- Создан systemd-unit `activitywatch-server.service`. +- Установлен RU Web UI patch. +- Для Web UI используется checksum-based cache-bust для `ru-patch-v5.js` и `sw-cleanup.js`, чтобы браузер не держал старую DLP/русскую статику после деплоя. +- На `#/home` Web UI делит хосты на `Windows RDP` и `Virtual servers + Proxmox`. +- Выполнена валидация API `http://127.0.0.1:5600/api/0/info`. +- Для full-stack сценария CT создаётся автоматически через `pct create`. diff --git a/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows_phase2.yml b/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows_phase2.yml new file mode 100644 index 0000000..8cf0e9f --- /dev/null +++ b/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows_phase2.yml @@ -0,0 +1,133 @@ +--- +- name: Deploy AWatch-rus Windows phase2 collectors + hosts: aw_windows + gather_facts: false + + vars: + aw_windows_repo_root: "/home/igor/tmp/AWatch-rus" + aw_windows_deploy_root: "C:\\Deploy\\AWatch-rus" + aw_windows_server_host: "10.10.10.13" + aw_windows_server_port: 5600 + aw_windows_domain: "SHARKON2025" + aw_windows_users: + - user1 + - user2 + - user3 + - user4 + - user5 + aw_windows_extra_users: [] + aw_windows_users_effective: "{{ (aw_windows_users + aw_windows_extra_users) | unique }}" + aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2" + aw_windows_state_root: "C:\\ProgramData\\ActivityWatch-Phase2" + aw_windows_afk_enabled: true + aw_windows_window_enabled: true + aw_windows_local_agent_logs_enabled: false + aw_windows_incident_capture_enabled: true + aw_windows_incident_screenshot_enabled: true + aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts" + aw_windows_logon_marker_enabled: true + aw_windows_skip_hardening: false + aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" + aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json" + aw_windows_validation_remote_path: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json" + aw_windows_validation_local_dir: "/tmp/aw-rus-validation" + + tasks: + - name: Validate required variables + ansible.builtin.assert: + that: + - aw_windows_server_host is defined + - aw_windows_server_port is defined + - aw_windows_domain is defined + - aw_windows_users_effective | length > 0 + - aw_windows_install_root is defined + - aw_windows_state_root is defined + fail_msg: "Missing required Windows deployment variables." + + - name: Ensure deploy directories exist + ansible.windows.win_file: + path: "{{ item }}" + state: directory + loop: + - "{{ aw_windows_deploy_root }}" + - "{{ aw_windows_deploy_root }}\\windows" + + - name: Upload Windows deployment toolkit + ansible.windows.win_copy: + src: "{{ aw_windows_repo_root }}/windows/{{ item }}" + dest: "{{ aw_windows_deploy_root }}\\windows\\{{ item }}" + loop: + - ActivityWatch.Windows.Common.psd1 + - ActivityWatch.Windows.Common.psm1 + - browser-domains-native-collector.ps1 + - dlp-endpoint-signals-collector.ps1 + - deploy-domain-users.ps1 + - deploy-ensemble.ps1 + - hardening-recovery.ps1 + - validate-deployment.ps1 + - web-category-rules.example.json + - dlp-policy.example.json + + - name: Upload user list for domain deploy + ansible.windows.win_copy: + dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt" + content: | + {% for user in aw_windows_users -%} + {{ user }} + {% endfor -%} + {% for user in aw_windows_extra_users -%} + {{ user }} + {% endfor -%} + + - name: Run phase2 ensemble deployment + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $params = @{ + ServerHost = "{{ aw_windows_server_host }}" + ServerPort = {{ aw_windows_server_port }} + Domain = "{{ aw_windows_domain }}" + UserListPath = "{{ aw_windows_deploy_root }}\windows\users.txt" + InstallRoot = "{{ aw_windows_install_root }}" + StateRoot = "{{ aw_windows_state_root }}" + AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }} + WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }} + LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }} + IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_enabled | bool) else '$false' }} + IncidentScreenshotEnabled = {{ '$true' if (aw_windows_incident_screenshot_enabled | bool) else '$false' }} + IncidentArtifactsRoot = "{{ aw_windows_incident_artifacts_root }}" + LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }} + CustomRulesPath = "{{ aw_windows_rules_path }}" + CustomPolicyPath = "{{ aw_windows_policy_path }}" + } + {% if aw_windows_skip_hardening | bool %} + $params.SkipHardening = $true + {% endif %} + & "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params + + - name: Run validation and store report on target + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $report = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" ` + -ConfigPath "{{ aw_windows_state_root }}\deployment-config.json" + $report | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8 + + - name: Ensure local validation directory exists + ansible.builtin.file: + path: "{{ aw_windows_validation_local_dir }}" + state: directory + mode: "0755" + delegate_to: localhost + + - name: Fetch validation report + ansible.builtin.fetch: + src: "{{ aw_windows_validation_remote_path }}" + dest: "{{ aw_windows_validation_local_dir }}/" + flat: false + + - name: Show report location + ansible.builtin.debug: + msg: + - "Windows phase2 deploy completed on {{ inventory_hostname }}." + - "Validation report: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}/C$/Windows/Temp/aw_validate_phase2_ansible.json" diff --git a/install-kit-awindows-20260427-211240/ansible/windows.example.yml b/install-kit-awindows-20260427-211240/ansible/windows.example.yml new file mode 100644 index 0000000..09e91d3 --- /dev/null +++ b/install-kit-awindows-20260427-211240/ansible/windows.example.yml @@ -0,0 +1,33 @@ +aw_windows_repo_root: "/home/igor/tmp/AWatch-rus" +aw_windows_deploy_root: "C:\\Deploy\\AWatch-rus" +aw_windows_server_host: "10.10.10.13" +aw_windows_server_port: 5600 +aw_windows_domain: "SHARKON2025" +aw_windows_users: + - user1 + - user2 + - user3 + - user4 + - user5 +aw_windows_extra_users: [] +# Например: +# aw_windows_extra_users: +# - Администратор + +# Рекомендуемый изолированный профиль для фазового раската. +aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2" +aw_windows_state_root: "C:\\ProgramData\\ActivityWatch-Phase2" +aw_windows_afk_enabled: true +aw_windows_window_enabled: true +aw_windows_local_agent_logs_enabled: false +aw_windows_incident_capture_enabled: true +aw_windows_incident_screenshot_enabled: true +aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts" +aw_windows_logon_marker_enabled: true +aw_windows_skip_hardening: false + +aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" +aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json" + +aw_windows_validation_remote_path: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json" +aw_windows_validation_local_dir: "/tmp/aw-rus-validation" diff --git a/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-admin.deployment-config.json b/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-admin.deployment-config.json new file mode 100644 index 0000000..2b6d20c --- /dev/null +++ b/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-admin.deployment-config.json @@ -0,0 +1,57 @@ +{ + "version": 1, + "generatedAtUtc": "2026-04-27T01:14:21.9184268Z", + "server": { + "host": "10.10.10.13", + "port": 5600, + "scheme": "http" + }, + "paths": { + "installRoot": "C:\\Program Files\\ActivityWatch-Phase2-admin", + "stateRoot": "C:\\ProgramData\\ActivityWatch\\phase2-admin", + "logsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\logs", + "collectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\browser-domains-native-collector.ps1", + "endpointCollectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\dlp-endpoint-signals-collector.ps1", + "rulesPath": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\web-category-rules.json", + "policyPath": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\dlp-policy.json", + "launchScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\launch-watchers.ps1", + "recoveryScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\recovery-loop.ps1" + }, + "collector": { + "pollSeconds": 5, + "pulseSeconds": 30 + }, + "collectors": { + "afkEnabled": true, + "windowEnabled": true + }, + "logging": { + "localAgentLogsEnabled": false + }, + "incidentCapture": { + "enabled": true, + "screenshotEnabled": true, + "artifactsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\incident-artifacts" + }, + "sessionEvents": { + "logonEnabled": true, + "bucketPrefix": "aw-session-events" + }, + "recovery": { + "intervalSeconds": 180, + "taskName": "ActivityWatch Recovery" + }, + "dlp": { + "incidentBucketPrefix": "aw-dlp-incidents", + "enabled": true + }, + "package": { + "version": "v0.13.2" + }, + "userTasks": [ + { + "UserId": "SHARKON2025\\Администратор", + "LaunchTaskName": "ActivityWatch Launch [SHARKON2025_РђРґРјРёРЅРёСЃС_СЂР_С_РѕСЂ]" + } + ] +} diff --git a/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-u2u5.deployment-config.json b/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-u2u5.deployment-config.json new file mode 100644 index 0000000..a0f344f --- /dev/null +++ b/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-u2u5.deployment-config.json @@ -0,0 +1,69 @@ +{ + "version": 1, + "generatedAtUtc": "2026-04-27T01:09:42.4193209Z", + "server": { + "host": "10.10.10.13", + "port": 5600, + "scheme": "http" + }, + "paths": { + "installRoot": "C:\\Program Files\\ActivityWatch-Phase2-u2u5", + "stateRoot": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5", + "logsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\logs", + "collectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\browser-domains-native-collector.ps1", + "endpointCollectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\dlp-endpoint-signals-collector.ps1", + "rulesPath": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\web-category-rules.json", + "policyPath": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\dlp-policy.json", + "launchScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\launch-watchers.ps1", + "recoveryScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\recovery-loop.ps1" + }, + "collector": { + "pollSeconds": 5, + "pulseSeconds": 30 + }, + "collectors": { + "afkEnabled": true, + "windowEnabled": true + }, + "logging": { + "localAgentLogsEnabled": false + }, + "incidentCapture": { + "enabled": true, + "screenshotEnabled": true, + "artifactsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\incident-artifacts" + }, + "sessionEvents": { + "logonEnabled": true, + "bucketPrefix": "aw-session-events" + }, + "recovery": { + "intervalSeconds": 180, + "taskName": "ActivityWatch Recovery" + }, + "dlp": { + "incidentBucketPrefix": "aw-dlp-incidents", + "enabled": true + }, + "package": { + "version": "v0.13.2" + }, + "userTasks": [ + { + "UserId": "SHARKON2025\\user2", + "LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user2]" + }, + { + "UserId": "SHARKON2025\\user3", + "LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user3]" + }, + { + "UserId": "SHARKON2025\\user4", + "LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user4]" + }, + { + "UserId": "SHARKON2025\\user5", + "LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user5]" + } + ] +} diff --git a/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-user1.deployment-config.json b/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-user1.deployment-config.json new file mode 100644 index 0000000..0eecca9 --- /dev/null +++ b/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-user1.deployment-config.json @@ -0,0 +1,57 @@ +{ + "version": 1, + "generatedAtUtc": "2026-04-27T01:09:38.9519788Z", + "server": { + "host": "10.10.10.13", + "port": 5600, + "scheme": "http" + }, + "paths": { + "installRoot": "C:\\Program Files\\ActivityWatch-Phase2", + "stateRoot": "C:\\ProgramData\\ActivityWatch\\phase2-user1", + "logsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\logs", + "collectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\browser-domains-native-collector.ps1", + "endpointCollectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\dlp-endpoint-signals-collector.ps1", + "rulesPath": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\web-category-rules.json", + "policyPath": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\dlp-policy.json", + "launchScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\launch-watchers.ps1", + "recoveryScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\recovery-loop.ps1" + }, + "collector": { + "pollSeconds": 5, + "pulseSeconds": 30 + }, + "collectors": { + "afkEnabled": true, + "windowEnabled": true + }, + "logging": { + "localAgentLogsEnabled": false + }, + "incidentCapture": { + "enabled": true, + "screenshotEnabled": true, + "artifactsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\incident-artifacts" + }, + "sessionEvents": { + "logonEnabled": true, + "bucketPrefix": "aw-session-events" + }, + "recovery": { + "intervalSeconds": 180, + "taskName": "ActivityWatch Recovery" + }, + "dlp": { + "incidentBucketPrefix": "aw-dlp-incidents", + "enabled": true + }, + "package": { + "version": "v0.13.2" + }, + "userTasks": [ + { + "UserId": "SHARKON2025\\user1", + "LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user1]" + } + ] +} diff --git a/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psd1 b/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psd1 new file mode 100644 index 0000000..9bc5fa8 --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psd1 @@ -0,0 +1,25 @@ +@{ + RootModule = 'ActivityWatch.Windows.Common.psm1' + ModuleVersion = '1.0.0' + GUID = '90b3fcf6-df9f-4f9b-9ee0-8a7de4dc0ee2' + Author = 'igor04091968' + CompanyName = 'Private' + Description = 'Common PowerShell functions for ActivityWatch Windows deployment, hardening and recovery.' + PowerShellVersion = '5.1' + FunctionsToExport = @( + '*-ActivityWatch*', + 'Assert-Administrator', + 'Normalize-ActivityWatchUsers', + 'Get-ActivityWatchPackageUrl', + 'Remove-LegacyActivityWatchEntries' + ) + CmdletsToExport = @() + VariablesToExport = '*' + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @('ActivityWatch', 'Windows', 'Deployment', 'Recovery') + ProjectUri = 'https://github.com/igor04091968/AWatch-rus' + } + } +} diff --git a/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 b/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 new file mode 100755 index 0000000..4bca474 --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 @@ -0,0 +1,982 @@ +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 'Run this script from an elevated PowerShell session.' + } +} + +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 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 + $archivePath = Join-Path $WorkingRoot ("activitywatch-{0}.zip" -f $Version.TrimStart('v')) + 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 "Cannot find aw-watcher-afk.exe under $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 + + $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 "Missing required ActivityWatch binary: $($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 'No target users resolved. Provide -Users or -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]$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' + $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 + + if ($CustomRulesSource) { + $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedRules.Path -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 + 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]$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]$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 + rulesPath = $RulesPath + policyPath = $PolicyPath + launchScript = $LaunchScriptPath + recoveryScript = $RecoveryScriptPath + } + collector = [pscustomobject]@{ + pollSeconds = $PollSeconds + pulseSeconds = $PulseSeconds + } + collectors = [pscustomobject]@{ + afkEnabled = $AfkEnabled + windowEnabled = $WindowEnabled + } + 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 "Deployment config not found: $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' + +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 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 + } + + 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 { + 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 'ActivityWatch-Phase2\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-phase2' + } + } | 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 (-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 +`$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 { '' } +`$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 } + +if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) { + throw "Missing aw-watcher-afk.exe: `$afkExe" +} + +if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) { + throw "Missing 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 +"@ + + 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) +} + +while (`$true) { + `$sleepSeconds = 180 + try { + `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath + foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { + Start-ScheduledTask -TaskName `$taskName -ErrorAction SilentlyContinue + } + + `$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 +} +"@ + + 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 failed for $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 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 + + 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 failed for $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 failed for $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 failed for $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/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 b/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 new file mode 100755 index 0000000..220961e --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 @@ -0,0 +1,822 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\ActivityWatch\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 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' } +$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\ActivityWatch\web-category-rules.json' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [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 } +$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\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 '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 +} + +$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 + $host = $uri.Host.ToLowerInvariant() + if ($host.StartsWith('www.')) { + return $host.Substring(4) + } + + return $host + } + 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 ("custom rules loaded: {0}" -f $rules.Count) + } + } + catch { + Write-CollectorLog ("custom rules load failed: {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 policy not found, disabled: {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 matched: $($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 policy loaded: enabled={0}, rules={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count) + } + catch { + Write-CollectorLog ("dlp policy parse failed: {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 | 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) { + 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 + } + + $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-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 | 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 | Out-Null +} + +Load-CustomCategoryRules -Path $resolvedRulesPath +Load-DlpPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("collector started against {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 ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} diff --git a/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 new file mode 100755 index 0000000..936ce3c --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 @@ -0,0 +1,107 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ServerHost, + [string[]]$Users, + [string]$UserListPath, + [string]$Domain, + [int]$ServerPort = 5600, + [ValidateSet('http', 'https')] + [string]$ServerScheme = 'http', + [string]$Version = 'v0.13.2', + [string]$PackageUrl, + [string]$PackageZipPath, + [string]$InstallRoot = 'C:\Program Files\ActivityWatch', + [string]$StateRoot = 'C:\ProgramData\ActivityWatch', + [int]$PollSeconds = 5, + [int]$PulseSeconds = 30, + [int]$RecoveryIntervalSeconds = 180, + [bool]$AfkEnabled = $true, + [bool]$WindowEnabled = $true, + [bool]$LocalAgentLogsEnabled = $false, + [bool]$IncidentCaptureEnabled = $true, + [bool]$IncidentScreenshotEnabled = $true, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled = $true, + [string]$CustomRulesPath, + [string]$CustomPolicyPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1' +Import-Module $modulePath -Force + +Assert-Administrator + +$targetUsers = Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain +$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy' +$backupRoot = Join-Path $StateRoot 'backups' +$logsRoot = Join-Path $StateRoot 'logs' +$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' + +New-ActivityWatchDirectory -Path $StateRoot +New-ActivityWatchDirectory -Path $logsRoot + +$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $Version -WorkingRoot $workingRoot +Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $InstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null +Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null + +$assetResult = Copy-ActivityWatchCollectorAssets ` + -CollectorScriptSource $collectorSource ` + -EndpointCollectorScriptSource $endpointCollectorSource ` + -ExampleRulesSource $exampleRulesSource ` + -ExamplePolicySource $examplePolicySource ` + -StateRoot $StateRoot ` + -CustomRulesSource $CustomRulesPath ` + -CustomPolicySource $CustomPolicyPath +$taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users $targetUsers + +Write-ActivityWatchLaunchScript -Path $launchScriptPath -ConfigPath $configPath +Write-ActivityWatchRecoveryScript -Path $recoveryScriptPath -ConfigPath $configPath + +$config = New-ActivityWatchDeploymentConfig ` + -ServerHost $ServerHost ` + -ServerPort $ServerPort ` + -ServerScheme $ServerScheme ` + -InstallRoot $InstallRoot ` + -StateRoot $StateRoot ` + -LogsRoot $logsRoot ` + -CollectorScript $assetResult.CollectorScript ` + -EndpointCollectorScript $assetResult.EndpointCollectorScript ` + -RulesPath $assetResult.ActiveRules ` + -PolicyPath $assetResult.ActivePolicy ` + -PollSeconds $PollSeconds ` + -PulseSeconds $PulseSeconds ` + -RecoveryIntervalSeconds $RecoveryIntervalSeconds ` + -AfkEnabled $AfkEnabled ` + -WindowEnabled $WindowEnabled ` + -LocalAgentLogsEnabled $LocalAgentLogsEnabled ` + -IncidentCaptureEnabled $IncidentCaptureEnabled ` + -IncidentScreenshotEnabled $IncidentScreenshotEnabled ` + -IncidentArtifactsRoot $IncidentArtifactsRoot ` + -LogonMarkerEnabled $LogonMarkerEnabled ` + -LaunchScriptPath $launchScriptPath ` + -RecoveryScriptPath $recoveryScriptPath ` + -UserTasks $taskDefinitions ` + -PackageVersion $Version + +Write-ActivityWatchDeploymentConfig -Config $config -Path $configPath +Remove-LegacyActivityWatchEntries +Set-ActivityWatchAcl -InstallRoot $InstallRoot -StateRoot $StateRoot -LogsRoot $logsRoot +Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $launchScriptPath -ConfigPath $configPath +Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath +Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName + +Write-Host 'ActivityWatch deployed for users:' +$targetUsers | ForEach-Object { Write-Host " - $_" } +Write-Host "Server: ${ServerScheme}://$ServerHost`:$ServerPort" +Write-Host "State root: $StateRoot" +Write-Host "Policy file: $($assetResult.ActivePolicy)" diff --git a/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 new file mode 100644 index 0000000..129d226 --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 @@ -0,0 +1,137 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ServerHost, + [string[]]$Users, + [string]$UserListPath, + [string]$Domain, + [int]$ServerPort = 5600, + [ValidateSet('http', 'https')] + [string]$ServerScheme = 'http', + [string]$Version = 'v0.13.2', + [string]$PackageUrl, + [string]$PackageZipPath, + [string]$InstallRoot = 'C:\Program Files\ActivityWatch', + [string]$StateRoot = 'C:\ProgramData\ActivityWatch', + [int]$PollSeconds = 5, + [int]$PulseSeconds = 30, + [int]$RecoveryIntervalSeconds = 180, + [bool]$AfkEnabled = $true, + [bool]$WindowEnabled = $true, + [bool]$LocalAgentLogsEnabled = $false, + [bool]$IncidentCaptureEnabled = $true, + [bool]$IncidentScreenshotEnabled = $true, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled = $true, + [string]$CustomRulesPath, + [string]$CustomPolicyPath, + [string]$ReportPath, + [switch]$SkipHardening, + [switch]$ValidateAfterDeploy +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1' +Import-Module $modulePath -Force + +Assert-Administrator + +$resolvedUsers = Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$effectiveReportPath = if ($ReportPath) { $ReportPath } else { Join-Path $StateRoot "ensemble-report-$timestamp.json" } +$deployScript = Join-Path $PSScriptRoot 'deploy-domain-users.ps1' +$hardeningScript = Join-Path $PSScriptRoot 'hardening-recovery.ps1' +$validationScript = Join-Path $PSScriptRoot 'validate-deployment.ps1' + +if (-not (Test-Path -LiteralPath $deployScript)) { + throw "Missing script: $deployScript" +} + +& $deployScript ` + -ServerHost $ServerHost ` + -Users $resolvedUsers ` + -ServerPort $ServerPort ` + -ServerScheme $ServerScheme ` + -Version $Version ` + -PackageUrl $PackageUrl ` + -PackageZipPath $PackageZipPath ` + -InstallRoot $InstallRoot ` + -StateRoot $StateRoot ` + -PollSeconds $PollSeconds ` + -PulseSeconds $PulseSeconds ` + -RecoveryIntervalSeconds $RecoveryIntervalSeconds ` + -AfkEnabled $AfkEnabled ` + -WindowEnabled $WindowEnabled ` + -LocalAgentLogsEnabled $LocalAgentLogsEnabled ` + -IncidentCaptureEnabled $IncidentCaptureEnabled ` + -IncidentScreenshotEnabled $IncidentScreenshotEnabled ` + -IncidentArtifactsRoot $IncidentArtifactsRoot ` + -LogonMarkerEnabled $LogonMarkerEnabled ` + -CustomRulesPath $CustomRulesPath ` + -CustomPolicyPath $CustomPolicyPath + +if (-not $SkipHardening) { + & $hardeningScript ` + -ConfigPath (Join-Path $StateRoot 'deployment-config.json') ` + -ServerHost $ServerHost ` + -ServerPort $ServerPort ` + -ServerScheme $ServerScheme ` + -Users $resolvedUsers ` + -InstallRoot $InstallRoot ` + -StateRoot $StateRoot ` + -PollSeconds $PollSeconds ` + -PulseSeconds $PulseSeconds ` + -RecoveryIntervalSeconds $RecoveryIntervalSeconds ` + -AfkEnabled $AfkEnabled ` + -WindowEnabled $WindowEnabled ` + -LocalAgentLogsEnabled $LocalAgentLogsEnabled ` + -IncidentCaptureEnabled $IncidentCaptureEnabled ` + -IncidentScreenshotEnabled $IncidentScreenshotEnabled ` + -IncidentArtifactsRoot $IncidentArtifactsRoot ` + -LogonMarkerEnabled $LogonMarkerEnabled ` + -CustomRulesPath $CustomRulesPath ` + -CustomPolicyPath $CustomPolicyPath +} + +$report = [ordered]@{ + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + server = [ordered]@{ + host = $ServerHost + port = $ServerPort + scheme = $ServerScheme + } + packageVersion = $Version + users = @($resolvedUsers) + paths = [ordered]@{ + installRoot = $InstallRoot + stateRoot = $StateRoot + configPath = Join-Path $StateRoot 'deployment-config.json' + } + collectors = [ordered]@{ + afkEnabled = $AfkEnabled + windowEnabled = $WindowEnabled + } + hardeningApplied = (-not $SkipHardening) +} + +if ($ValidateAfterDeploy) { + if (-not (Test-Path -LiteralPath $validationScript)) { + throw "Missing script: $validationScript" + } + + $validation = & $validationScript -ConfigPath (Join-Path $StateRoot 'deployment-config.json') + $report.validation = $validation +} + +$reportDirectory = Split-Path -Path $effectiveReportPath -Parent +if ($reportDirectory) { + New-ActivityWatchDirectory -Path $reportDirectory +} + +$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $effectiveReportPath -Encoding UTF8 + +Write-Host 'ActivityWatch ensemble deploy completed.' +Write-Host "Users: $($resolvedUsers -join ', ')" +Write-Host "Report: $effectiveReportPath" diff --git a/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 new file mode 100755 index 0000000..d3eafef --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 @@ -0,0 +1,106 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ServerHost, + [Parameter(Mandatory = $true)] + [string]$TargetUser, + [int]$ServerPort = 5600, + [ValidateSet('http', 'https')] + [string]$ServerScheme = 'http', + [string]$Version = 'v0.13.2', + [string]$PackageUrl, + [string]$PackageZipPath, + [string]$InstallRoot = 'C:\Program Files\ActivityWatch', + [string]$StateRoot = 'C:\ProgramData\ActivityWatch', + [int]$PollSeconds = 5, + [int]$PulseSeconds = 30, + [int]$RecoveryIntervalSeconds = 180, + [bool]$AfkEnabled = $true, + [bool]$WindowEnabled = $true, + [bool]$LocalAgentLogsEnabled = $false, + [bool]$IncidentCaptureEnabled = $true, + [bool]$IncidentScreenshotEnabled = $true, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled = $true, + [string]$CustomRulesPath, + [string]$CustomPolicyPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1' +Import-Module $modulePath -Force + +Assert-Administrator + +$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy' +$backupRoot = Join-Path $StateRoot 'backups' +$logsRoot = Join-Path $StateRoot 'logs' +$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' + +New-ActivityWatchDirectory -Path $StateRoot +New-ActivityWatchDirectory -Path $logsRoot + +$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $Version -WorkingRoot $workingRoot +Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $InstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null +Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null + +$assetResult = Copy-ActivityWatchCollectorAssets ` + -CollectorScriptSource $collectorSource ` + -EndpointCollectorScriptSource $endpointCollectorSource ` + -ExampleRulesSource $exampleRulesSource ` + -ExamplePolicySource $examplePolicySource ` + -StateRoot $StateRoot ` + -CustomRulesSource $CustomRulesPath ` + -CustomPolicySource $CustomPolicyPath +$taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users @($TargetUser) + +Write-ActivityWatchLaunchScript -Path $launchScriptPath -ConfigPath $configPath +Write-ActivityWatchRecoveryScript -Path $recoveryScriptPath -ConfigPath $configPath + +$config = New-ActivityWatchDeploymentConfig ` + -ServerHost $ServerHost ` + -ServerPort $ServerPort ` + -ServerScheme $ServerScheme ` + -InstallRoot $InstallRoot ` + -StateRoot $StateRoot ` + -LogsRoot $logsRoot ` + -CollectorScript $assetResult.CollectorScript ` + -EndpointCollectorScript $assetResult.EndpointCollectorScript ` + -RulesPath $assetResult.ActiveRules ` + -PolicyPath $assetResult.ActivePolicy ` + -PollSeconds $PollSeconds ` + -PulseSeconds $PulseSeconds ` + -RecoveryIntervalSeconds $RecoveryIntervalSeconds ` + -AfkEnabled $AfkEnabled ` + -WindowEnabled $WindowEnabled ` + -LocalAgentLogsEnabled $LocalAgentLogsEnabled ` + -IncidentCaptureEnabled $IncidentCaptureEnabled ` + -IncidentScreenshotEnabled $IncidentScreenshotEnabled ` + -IncidentArtifactsRoot $IncidentArtifactsRoot ` + -LogonMarkerEnabled $LogonMarkerEnabled ` + -LaunchScriptPath $launchScriptPath ` + -RecoveryScriptPath $recoveryScriptPath ` + -UserTasks $taskDefinitions ` + -PackageVersion $Version + +Write-ActivityWatchDeploymentConfig -Config $config -Path $configPath +Remove-LegacyActivityWatchEntries +Set-ActivityWatchAcl -InstallRoot $InstallRoot -StateRoot $StateRoot -LogsRoot $logsRoot +Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $launchScriptPath -ConfigPath $configPath +Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath +Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName + +Write-Host "ActivityWatch deployed for $TargetUser" +Write-Host "Server: ${ServerScheme}://$ServerHost`:$ServerPort" +Write-Host "Install root: $InstallRoot" +Write-Host "State root: $StateRoot" +Write-Host "Rules file: $($assetResult.ActiveRules)" +Write-Host "Policy file: $($assetResult.ActivePolicy)" diff --git a/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 b/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 new file mode 100644 index 0000000..8ee44f5 --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 @@ -0,0 +1,796 @@ +[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) + 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 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 @{} + } +} + +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) + } +} + +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\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) } +$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 +} + +$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: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 { + 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 + $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 +} diff --git a/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json b/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json new file mode 100644 index 0000000..5ed8ef6 --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json @@ -0,0 +1,99 @@ +{ + "version": 1, + "defaults": { + "enabled": true, + "cooldownSeconds": 300, + "action": "log", + "severity": "low" + }, + "rules": [ + { + "id": "personal-web-during-workhours", + "enabled": true, + "cooldownSeconds": 600, + "action": "alert", + "severity": "medium", + "message": "Личные ресурсы в рабочее время", + "when": { + "categoryGroups": ["personal"], + "hourFrom": 9, + "hourTo": 19 + } + }, + { + "id": "high-risk-cloud-storage", + "enabled": true, + "cooldownSeconds": 900, + "action": "alert", + "severity": "high", + "message": "Подозрительный доступ к облачному хранилищу", + "when": { + "domains": [ + "dropbox.com", + "drive.google.com", + "mega.nz", + "onedrive.live.com", + "disk.yandex.ru" + ] + } + }, + { + "id": "anonymizer-and-vpn-web", + "enabled": true, + "cooldownSeconds": 900, + "action": "alert", + "severity": "high", + "message": "Использование веб-анонимайзеров / VPN-сервисов", + "when": { + "domains": [ + "hidemy.name", + "2ip.ru", + "whoer.net", + "protonvpn.com", + "nordvpn.com" + ] + } + } + ], + "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/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 b/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 new file mode 100755 index 0000000..c908b2f --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 @@ -0,0 +1,144 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string[]]$Users, + [string]$UserListPath, + [string]$Domain, + [string]$InstallRoot, + [string]$StateRoot, + [int]$PollSeconds, + [int]$PulseSeconds, + [int]$RecoveryIntervalSeconds, + [bool]$AfkEnabled, + [bool]$WindowEnabled, + [bool]$LocalAgentLogsEnabled, + [bool]$IncidentCaptureEnabled, + [bool]$IncidentScreenshotEnabled, + [string]$IncidentArtifactsRoot, + [bool]$LogonMarkerEnabled, + [string]$CustomRulesPath, + [string]$CustomPolicyPath, + [switch]$RepairPackage, + [string]$Version, + [string]$PackageUrl, + [string]$PackageZipPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1' +Import-Module $modulePath -Force + +Assert-Administrator + +$existingConfig = $null +if (Test-Path -LiteralPath $ConfigPath) { + $existingConfig = Read-ActivityWatchDeploymentConfig -Path $ConfigPath +} + +if (-not $existingConfig -and (-not $ServerHost)) { + throw 'deployment-config.json is missing. Provide -ServerHost and user parameters, or run a deploy script first.' +} + +$effectiveStateRoot = if ($StateRoot) { $StateRoot } elseif ($existingConfig) { [string]$existingConfig.paths.stateRoot } else { 'C:\ProgramData\ActivityWatch' } +$effectiveInstallRoot = if ($InstallRoot) { $InstallRoot } elseif ($existingConfig) { [string]$existingConfig.paths.installRoot } else { 'C:\Program Files\ActivityWatch' } +$effectiveLogsRoot = if ($existingConfig) { [string]$existingConfig.paths.logsRoot } else { Join-Path $effectiveStateRoot 'logs' } +$effectiveConfigPath = if ($ConfigPath) { $ConfigPath } else { Join-Path $effectiveStateRoot 'deployment-config.json' } +$effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1' +$effectiveRecoveryScript = Join-Path $effectiveStateRoot 'recovery-loop.ps1' +$effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-collector.ps1' +$effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$existingConfig.paths.endpointCollectorScript } else { 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' } + +$effectiveServerHost = if ($ServerHost) { $ServerHost } elseif ($existingConfig) { [string]$existingConfig.server.host } else { $null } +$effectiveServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($existingConfig) { [int]$existingConfig.server.port } else { 5600 } +$effectiveServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($existingConfig) { [string]$existingConfig.server.scheme } else { 'http' } +$effectivePollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($existingConfig) { [int]$existingConfig.collector.pollSeconds } else { 5 } +$effectivePulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($existingConfig) { [int]$existingConfig.collector.pulseSeconds } else { 30 } +$effectiveRecoveryInterval = if ($PSBoundParameters.ContainsKey('RecoveryIntervalSeconds')) { $RecoveryIntervalSeconds } elseif ($existingConfig) { [int]$existingConfig.recovery.intervalSeconds } else { 180 } +$effectiveAfkEnabled = if ($PSBoundParameters.ContainsKey('AfkEnabled')) { [bool]$AfkEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$existingConfig.collectors.afkEnabled } else { $true } +$effectiveWindowEnabled = if ($PSBoundParameters.ContainsKey('WindowEnabled')) { [bool]$WindowEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$existingConfig.collectors.windowEnabled } else { $true } +$effectiveLocalAgentLogsEnabled = if ($PSBoundParameters.ContainsKey('LocalAgentLogsEnabled')) { [bool]$LocalAgentLogsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'logging' -and $existingConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$existingConfig.logging.localAgentLogsEnabled } else { $false } +$effectiveIncidentCaptureEnabled = if ($PSBoundParameters.ContainsKey('IncidentCaptureEnabled')) { [bool]$IncidentCaptureEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'enabled') { [bool]$existingConfig.incidentCapture.enabled } else { $true } +$effectiveIncidentScreenshotEnabled = if ($PSBoundParameters.ContainsKey('IncidentScreenshotEnabled')) { [bool]$IncidentScreenshotEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$existingConfig.incidentCapture.screenshotEnabled } else { $true } +$effectiveIncidentArtifactsRoot = if ($PSBoundParameters.ContainsKey('IncidentArtifactsRoot') -and $IncidentArtifactsRoot) { $IncidentArtifactsRoot } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$existingConfig.incidentCapture.artifactsRoot } else { Join-Path $effectiveStateRoot 'incident-artifacts' } +$effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true } +$effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' } + +$effectiveUsers = if ($Users -or $UserListPath) { + Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain +} +elseif ($existingConfig) { + @($existingConfig.userTasks | ForEach-Object { [string]$_.userId }) +} +else { + throw 'Target users are missing.' +} + +New-ActivityWatchDirectory -Path $effectiveStateRoot +New-ActivityWatchDirectory -Path $effectiveLogsRoot + +if ($RepairPackage) { + $workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy' + $backupRoot = Join-Path $effectiveStateRoot 'backups' + $archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $effectiveVersion -WorkingRoot $workingRoot + Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $effectiveInstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null +} + +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 ` + -CustomRulesSource $CustomRulesPath ` + -CustomPolicySource $CustomPolicyPath + +$taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users $effectiveUsers +Write-ActivityWatchLaunchScript -Path $effectiveLaunchScript -ConfigPath $effectiveConfigPath +Write-ActivityWatchRecoveryScript -Path $effectiveRecoveryScript -ConfigPath $effectiveConfigPath + +$config = New-ActivityWatchDeploymentConfig ` + -ServerHost $effectiveServerHost ` + -ServerPort $effectiveServerPort ` + -ServerScheme $effectiveServerScheme ` + -InstallRoot $effectiveInstallRoot ` + -StateRoot $effectiveStateRoot ` + -LogsRoot $effectiveLogsRoot ` + -CollectorScript $effectiveCollector ` + -EndpointCollectorScript $effectiveEndpointCollector ` + -RulesPath $effectiveRules ` + -PolicyPath $effectivePolicy ` + -PollSeconds $effectivePollSeconds ` + -PulseSeconds $effectivePulseSeconds ` + -RecoveryIntervalSeconds $effectiveRecoveryInterval ` + -AfkEnabled $effectiveAfkEnabled ` + -WindowEnabled $effectiveWindowEnabled ` + -LocalAgentLogsEnabled $effectiveLocalAgentLogsEnabled ` + -IncidentCaptureEnabled $effectiveIncidentCaptureEnabled ` + -IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled ` + -IncidentArtifactsRoot $effectiveIncidentArtifactsRoot ` + -LogonMarkerEnabled $effectiveLogonMarkerEnabled ` + -LaunchScriptPath $effectiveLaunchScript ` + -RecoveryScriptPath $effectiveRecoveryScript ` + -UserTasks $taskDefinitions ` + -PackageVersion $effectiveVersion + +Write-ActivityWatchDeploymentConfig -Config $config -Path $effectiveConfigPath +Remove-LegacyActivityWatchEntries +Set-ActivityWatchAcl -InstallRoot $effectiveInstallRoot -StateRoot $effectiveStateRoot -LogsRoot $effectiveLogsRoot +Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $effectiveLaunchScript -ConfigPath $effectiveConfigPath +Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $effectiveRecoveryScript -ConfigPath $effectiveConfigPath +Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName + +Write-Host 'ActivityWatch hardening/recovery completed.' +Write-Host "Config: $effectiveConfigPath" +Write-Host "Users repaired: $($effectiveUsers -join ', ')" diff --git a/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 b/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 new file mode 100644 index 0000000..71e93a0 --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 @@ -0,0 +1,90 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\ActivityWatch\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' } +$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 + +$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, + $recoveryScript, + $ConfigPath +) + +$missingFiles = @( + $requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_) } +) + +$processNames = @('aw-watcher-afk', 'aw-watcher-window') +$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId + +$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 = 'Missing' + present = $false + } + } +} + +$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port +$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]@{ + list = @($runningProcesses) + ok = [bool](($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge 2) + } +} + +$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok) + +$result diff --git a/install-kit-awindows-20260427-211240/windows/web-category-rules.example.json b/install-kit-awindows-20260427-211240/windows/web-category-rules.example.json new file mode 100755 index 0000000..ab51f5a --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/web-category-rules.example.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "description": "Override or extend built-in ActivityWatch web categorization rules.", + "rules": [ + { + "name": "work_crm", + "group": "work", + "domains": [ + "crm.example.com", + "portal.example.org" + ] + }, + { + "name": "work_erp", + "group": "work", + "domains": [ + "erp.example.com", + "bi.example.com" + ] + }, + { + "name": "neutral_training", + "group": "neutral", + "domains": [ + "wiki.example.net", + "kb.example.net" + ] + }, + { + "name": "personal_social", + "group": "personal", + "domains": [ + "social.example.net" + ] + } + ] +} diff --git a/windows/dlp-endpoint-signals-collector.ps1 b/windows/dlp-endpoint-signals-collector.ps1 index 7624c90..8ee44f5 100644 --- a/windows/dlp-endpoint-signals-collector.ps1 +++ b/windows/dlp-endpoint-signals-collector.ps1 @@ -402,6 +402,64 @@ function Test-LooksLikeMojibakeQuestionMarks { 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) @@ -515,32 +573,40 @@ function Get-BetterDocumentNameFromPrintServiceEvents { StartTime = $startTime } -MaxEvents 200 -ErrorAction Stop - foreach ($event in @($events)) { - $summary = Get-PrintServiceEventSummary -Event $event - $matchReason = 'scan' - $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName - if ($Owner -and $summary.Owner -and ($summary.Owner -notlike "*$Owner*")) { - $matchReason = 'owner-mismatch' - Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason $matchReason -ResolvedDocument $resolvedDocument - continue + $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 } - - if ($PrinterName -and $summary.PrinterName -and ($summary.PrinterName -notlike "*$PrinterName*")) { - $matchReason = 'printer-mismatch' - Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason $matchReason -ResolvedDocument $resolvedDocument - continue - } - - if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { - $matchReason = 'no-document-candidate' - Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason $matchReason -ResolvedDocument '' - continue - } - - $matchReason = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } - Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason $matchReason -ResolvedDocument $resolvedDocument - return $resolvedDocument } } catch {