diff --git a/install-kit-awindows-20260427-211240/ansible/README.md b/install-kit-awindows-20260427-211240/ansible/README.md index b22fc6c..3c9d42a 100644 --- a/install-kit-awindows-20260427-211240/ansible/README.md +++ b/install-kit-awindows-20260427-211240/ansible/README.md @@ -31,6 +31,15 @@ cd ansible ansible-playbook -i inventory.ini deploy_aw_server.yml ``` +## Секреты (пароли) безопасно + +Рекомендуемый способ не хранить пароли в репозитории — перед запуском экспортировать их в переменные окружения: + +- Linux `aw_server` (SSH пароль root): `AW_SSH_PASSWORD` +- Windows `aw_windows` (WinRM пароль): `AW_WINRM_PASSWORD` + +В `group_vars/aw_server.yml` и `group_vars/windows.yml` они читаются через `lookup('env', ...)`. + ## Полный установочный playbook (всё за один запуск) Если нужно прогнать полный цикл одной командой: @@ -149,3 +158,13 @@ Playbook: - Для полного сценария CT создаётся автоматически через `pct create`. - На Windows/RDP host развёрнуты AFK/window watchers, browser domain collector, DLP endpoint collector и worktime session collector. - Проверочный JSON-отчёт Windows playbook должен иметь `overallOk=true`. + +## Prod rollout одной командой + +Для ручного запуска с dry-run и логированием используйте: + +```bash +bash scripts/prod_rollout.sh +``` + +Скрипт попросит `AW_SSH_PASSWORD` и `AW_WINRM_PASSWORD` интерактивно (ввод скрыт) и сложит логи в `.rollout-logs/`. diff --git a/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml b/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml index b136df7..996eb4b 100644 --- a/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml +++ b/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml @@ -54,6 +54,11 @@ - "{{ aw_server_webui_dir }}" - "{{ aw_server_webui_dir }}/js" - "{{ aw_server_data_dir }}" + - "{{ aw_server_db_path | dirname }}" + - "{{ aw_server_data_dir }}/.config" + - "{{ aw_server_data_dir }}/.config/activitywatch" + - "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust" + - "{{ aw_server_data_dir }}/backups" - "{{ aw_server_log_dir }}" - /etc/activitywatch - "{{ aw_bootstrap_dir }}" @@ -74,100 +79,147 @@ - "{{ aw_server_webui_dir }}" - "{{ aw_server_webui_dir }}/js" - "{{ aw_server_data_dir }}" + - "{{ aw_server_db_path | dirname }}" + - "{{ aw_server_data_dir }}/.config" + - "{{ aw_server_data_dir }}/.config/activitywatch" + - "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust" + - "{{ aw_server_data_dir }}/backups" - "{{ aw_server_log_dir }}" - - name: Скачать архив релиза ActivityWatch - ansible.builtin.get_url: - url: "{{ aw_server_download_url }}" - dest: "{{ aw_archive_path }}" - mode: "0644" + - name: (Check mode) Пропустить установку релиза ActivityWatch + ansible.builtin.debug: + msg: "ansible_check_mode=true: download/unarchive/install of ActivityWatch release is skipped." + when: ansible_check_mode - - name: Распаковать релиз ActivityWatch - ansible.builtin.unarchive: - src: "{{ aw_archive_path }}" - dest: "{{ aw_release_dir }}" - remote_src: true - extra_opts: ["-o"] + - name: Установить релиз ActivityWatch (download/unarchive/install) + when: not ansible_check_mode + block: + - name: Скачать архив релиза ActivityWatch + ansible.builtin.get_url: + url: "{{ aw_server_download_url }}" + dest: "{{ aw_archive_path }}" + mode: "0644" - - name: Найти распакованный каталог ActivityWatch - ansible.builtin.find: - paths: "{{ aw_release_dir }}" - file_type: directory - patterns: "activitywatch*" - register: aw_release_find + - name: Распаковать релиз ActivityWatch + ansible.builtin.unarchive: + src: "{{ aw_archive_path }}" + dest: "{{ aw_release_dir }}" + remote_src: true + extra_opts: ["-o"] - - name: Найти бинарный файл AW server - ansible.builtin.find: - paths: "{{ aw_release_dir }}" - file_type: file - patterns: - - aw-server-rust - - aw-server - register: aw_server_binary_find + - name: Найти распакованный каталог ActivityWatch + ansible.builtin.find: + paths: "{{ aw_release_dir }}" + recurse: true + file_type: directory + patterns: "activitywatch*" + register: aw_release_find - - name: Найти каталог WebUI - ansible.builtin.find: - paths: "{{ aw_release_dir }}" - file_type: directory - patterns: - - aw-webui - - webui - register: aw_webui_dir_find + - name: Найти бинарный файл AW server + ansible.builtin.find: + paths: "{{ aw_release_dir }}" + recurse: true + file_type: file + patterns: + - aw-server-rust + - aw-server + register: aw_server_binary_find - - name: Сохранить пути распакованного релиза - ansible.builtin.set_fact: - aw_release_extracted: "{{ (aw_release_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}" - aw_server_binary_path: "{{ (aw_server_binary_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}" - aw_webui_source_path: "{{ (aw_webui_dir_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}" + - name: Найти index.html WebUI + ansible.builtin.find: + paths: "{{ aw_release_dir }}" + recurse: true + file_type: file + patterns: + - index.html + register: aw_webui_index_find - - name: Проверить, что компоненты релиза найдены - ansible.builtin.assert: - that: - - aw_release_extracted is defined - - aw_release_extracted | length > 0 - - aw_server_binary_path is defined - - aw_server_binary_path | length > 0 - - aw_webui_source_path is defined - - aw_webui_source_path | length > 0 - fail_msg: "Не удалось найти бинарный файл или WebUI в распакованном релизе ActivityWatch." + - name: Сохранить пути распакованного релиза (binary + webui index) + ansible.builtin.set_fact: + aw_release_extracted: "{{ (aw_release_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}" + aw_server_binary_path: >- + {{ + ( + ( + (aw_server_binary_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list) + | select('match', '.*/aw-server-rust$') | list | first + ) + | default( + ( + (aw_server_binary_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) + ), + true + ) + ) | default('') + }} + aw_webui_index_path: >- + {{ + ( + ( + (aw_webui_index_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list) + | select('search', '/static/index\\.html$') | list | first + ) + | default( + ( + (aw_webui_index_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) + ), + true + ) + ) | default('') + }} - - name: Создать каталог установленного релиза - ansible.builtin.file: - path: "{{ aw_release_install_dir }}" - state: directory - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - mode: "0755" + - name: Сохранить каталог WebUI (dirname index.html) + ansible.builtin.set_fact: + aw_webui_source_path: "{{ aw_webui_index_path | dirname }}" - - name: Установить бинарный файл AW server - ansible.builtin.copy: - remote_src: true - src: "{{ aw_server_binary_path }}" - dest: "{{ aw_release_install_dir }}/aw-server-rust" - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - mode: "0755" + - name: Проверить, что компоненты релиза найдены + ansible.builtin.assert: + that: + - aw_release_extracted is defined + - aw_release_extracted | length > 0 + - aw_server_binary_path is defined + - aw_server_binary_path | length > 0 + - aw_webui_source_path is defined + - aw_webui_source_path | length > 0 + fail_msg: "Не удалось найти бинарный файл или WebUI в распакованном релизе ActivityWatch." - - name: Создать ссылку на активный бинарный файл AW server - ansible.builtin.file: - src: "{{ aw_release_install_dir }}/aw-server-rust" - dest: /opt/activitywatch/bin/aw-server-rust - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - state: link - force: true + - name: Создать каталог установленного релиза + ansible.builtin.file: + path: "{{ aw_release_install_dir }}" + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" - - name: Синхронизировать WebUI в RU каталог - ansible.builtin.command: - cmd: "rsync -a {{ aw_webui_source_path }}/ {{ aw_server_webui_dir }}/" + - name: Установить бинарный файл AW server + ansible.builtin.copy: + remote_src: true + src: "{{ aw_server_binary_path }}" + dest: "{{ aw_release_install_dir }}/aw-server-rust" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" - - name: Настроить владельца файлов /opt/activitywatch - ansible.builtin.file: - path: /opt/activitywatch - state: directory - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - recurse: true + - name: Создать ссылку на активный бинарный файл AW server + ansible.builtin.file: + src: "{{ aw_release_install_dir }}/aw-server-rust" + dest: /opt/activitywatch/bin/aw-server-rust + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + state: link + force: true + + - name: Синхронизировать WebUI в RU каталог + ansible.builtin.command: + cmd: "rsync -a {{ aw_webui_source_path }}/ {{ aw_server_webui_dir }}/" + + - name: Настроить владельца файлов /opt/activitywatch + ansible.builtin.file: + path: /opt/activitywatch + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + recurse: true - name: Установить systemd service из шаблона репозитория ansible.builtin.copy: @@ -184,143 +236,401 @@ - Перезагрузить systemd - Перезапустить activitywatch - - name: Скопировать RU patch файлы WebUI из репозитория - ansible.builtin.copy: - src: "{{ item.src }}" - dest: "{{ item.dest }}" - mode: "{{ item.mode }}" - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - loop: - - { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" } - - { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" } - - { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "{{ aw_server_webui_dir }}/js/aw-worktime-panel.js", mode: "0644" } - - { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" } - - - name: Создать каталог /root/bootstrap для apply_webui_ru_patch.sh - ansible.builtin.file: - path: /root/bootstrap - state: directory - mode: "0755" - - - name: Скопировать RU patch файлы для apply_webui_ru_patch.sh (хотфиксы compiled JS чанков) - ansible.builtin.copy: - src: "{{ item.src }}" - dest: "{{ item.dest }}" - mode: "{{ item.mode }}" - loop: - - { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/root/bootstrap/aw-ru-patch.js", mode: "0644" } - - { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/root/bootstrap/aw-sw-cleanup.js", mode: "0644" } - - { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "/root/bootstrap/aw-worktime-panel.js", mode: "0644" } - - { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/root/bootstrap/aw-host-groups.json", mode: "0644" } - - - name: Скопировать apply_webui_ru_patch.sh скрипт - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/apply_webui_ru_patch.sh" - dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh - mode: "0755" - - - name: Записать /etc/activitywatch/aw-server.env перед хотфиксами - ansible.builtin.copy: - dest: /etc/activitywatch/aw-server.env - mode: "0640" - owner: root - group: root - content: | - AW_SERVER_BIND_HOST={{ aw_server_bind_host }} - AW_SERVER_PORT={{ aw_server_port }} - AW_SERVER_DATA_DIR={{ aw_server_data_dir }} - AW_SERVER_DB_PATH={{ aw_server_db_path }} - AW_SERVER_LOG_DIR={{ aw_server_log_dir }} - AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }} - AW_SERVER_USER={{ aw_server_user }} - AW_SERVER_GROUP={{ aw_server_group }} - AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }} - AW_WORKTIME_TZ={{ aw_worktime_timezone }} - XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share - XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config - - - name: Установить скрипт AW worktime API - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py" - dest: /usr/local/bin/aw-worktime-api.py - owner: root - group: root - mode: "0755" - - - name: Установить systemd unit AW worktime API - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.service" - dest: /etc/systemd/system/aw-worktime-api.service - owner: root - group: root - mode: "0644" - - - name: Перезагрузить systemd после установки AW worktime API - ansible.builtin.systemd: - daemon_reload: true - - - name: Включить и перезапустить AW worktime API - ansible.builtin.systemd: - name: aw-worktime-api.service - enabled: true - state: restarted - - - name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper) - ansible.builtin.command: - cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh" - register: apply_ru_patch_result - failed_when: false - - - name: Вывести результат применения хотфиксов + - name: (Check mode) Пропустить WebUI patch и запуск сервиса ansible.builtin.debug: - msg: "apply_webui_ru_patch.sh: {{ apply_ru_patch_result.stdout }}" + msg: "ansible_check_mode=true: WebUI patch + service start + API checks are skipped." + when: ansible_check_mode - - name: Проверить наличие index.html после копирования - ansible.builtin.stat: - path: "{{ aw_server_webui_dir }}/index.html" - register: aw_webui_ru_index + - name: Применить WebUI RU patch и запустить сервис + when: not ansible_check_mode + block: + - name: Скопировать RU patch файлы WebUI из репозитория + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "{{ item.dest }}" + mode: "{{ item.mode }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + loop: + - { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "{{ aw_server_webui_dir }}/js/aw-worktime-panel.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" } - - name: Проверить, что index.html доступен для RU patch - ansible.builtin.assert: - that: - - aw_webui_ru_index.stat.exists - fail_msg: "Не найден index.html WebUI для применения RU patch." + - name: Создать каталог /root/bootstrap для apply_webui_ru_patch.sh + ansible.builtin.file: + path: /root/bootstrap + state: directory + mode: "0755" - - name: Удалить старые теги RU patch из index.html - ansible.builtin.replace: - path: "{{ aw_server_webui_dir }}/index.html" - regexp: ']+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js)[^>]*>' - replace: '' + - name: Скопировать RU patch файлы для apply_webui_ru_patch.sh (хотфиксы compiled JS чанков) + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "{{ item.dest }}" + mode: "{{ item.mode }}" + loop: + - { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/root/bootstrap/aw-ru-patch.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/root/bootstrap/aw-sw-cleanup.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-worktime-panel.js", dest: "/root/bootstrap/aw-worktime-panel.js", mode: "0644" } + - { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/root/bootstrap/aw-host-groups.json", mode: "0644" } - - name: Добавить cleanup script RU patch в index.html - ansible.builtin.replace: - path: "{{ aw_server_webui_dir }}/index.html" - regexp: '' - replace: '' + - name: Скопировать apply_webui_ru_patch.sh скрипт + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/apply_webui_ru_patch.sh" + dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh + mode: "0755" - - name: Добавить загрузчик RU patch перед закрытием body - ansible.builtin.replace: - path: "{{ aw_server_webui_dir }}/index.html" - regexp: '' - replace: '' + - name: Записать /etc/activitywatch/aw-server.env перед хотфиксами + ansible.builtin.copy: + dest: /etc/activitywatch/aw-server.env + mode: "0640" + owner: root + group: root + content: | + AW_SERVER_BIND_HOST={{ aw_server_bind_host }} + AW_SERVER_PORT={{ aw_server_port }} + AW_SERVER_DATA_DIR={{ aw_server_data_dir }} + AW_SERVER_DB_PATH={{ aw_server_db_path }} + AW_SERVER_LOG_DIR={{ aw_server_log_dir }} + AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }} + AW_SERVER_USER={{ aw_server_user }} + AW_SERVER_GROUP={{ aw_server_group }} + AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }} + AW_WORKTIME_TZ={{ aw_worktime_timezone }} + XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share + XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config - - name: Включить и запустить сервис - ansible.builtin.systemd: - name: activitywatch-server.service - enabled: true - state: restarted - daemon_reload: true + - name: Установить скрипт AW worktime API + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py" + dest: /usr/local/bin/aw-worktime-api.py + owner: root + group: root + mode: "0755" - - name: Дождаться ответа API + - name: Установить systemd unit AW worktime API + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.service" + dest: /etc/systemd/system/aw-worktime-api.service + owner: root + group: root + mode: "0644" + + - name: Установить скрипт AW worktime UI bridge + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.py" + dest: /usr/local/bin/aw-worktime-ui-bridge.py + owner: root + group: root + mode: "0755" + + - name: Установить systemd unit AW worktime UI bridge + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.service" + dest: /etc/systemd/system/aw-worktime-ui-bridge.service + owner: root + group: root + mode: "0644" + + - name: Установить systemd timer AW worktime UI bridge + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.timer" + dest: /etc/systemd/system/aw-worktime-ui-bridge.timer + owner: root + group: root + mode: "0644" + + - name: Перезагрузить systemd после установки AW worktime API + ansible.builtin.systemd: + daemon_reload: true + + - name: Включить и перезапустить AW worktime API + ansible.builtin.systemd: + name: aw-worktime-api.service + enabled: true + state: restarted + + - name: Отключить legacy timer aw-worktime-afk-bridge (если есть) + ansible.builtin.systemd: + name: aw-worktime-afk-bridge.timer + enabled: false + state: stopped + failed_when: false + + - name: Включить и перезапустить AW worktime UI bridge timer + ansible.builtin.systemd: + name: aw-worktime-ui-bridge.timer + enabled: true + state: restarted + + - name: Выполнить разовый прогон AW worktime UI bridge + ansible.builtin.systemd: + name: aw-worktime-ui-bridge.service + state: started + failed_when: false + + - name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper) + ansible.builtin.command: + cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh" + register: apply_ru_patch_result + failed_when: false + + - name: Вывести результат применения хотфиксов + ansible.builtin.debug: + msg: "apply_webui_ru_patch.sh: {{ apply_ru_patch_result.stdout }}" + + - name: Проверить наличие index.html после копирования + ansible.builtin.stat: + path: "{{ aw_server_webui_dir }}/index.html" + register: aw_webui_ru_index + + - name: Проверить, что index.html доступен для RU patch + ansible.builtin.assert: + that: + - aw_webui_ru_index.stat.exists + fail_msg: "Не найден index.html WebUI для применения RU patch." + + - name: Удалить старые теги RU patch из index.html + ansible.builtin.replace: + path: "{{ aw_server_webui_dir }}/index.html" + regexp: ']+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js)[^>]*>' + replace: '' + + - name: Добавить cleanup script RU patch в index.html + ansible.builtin.replace: + path: "{{ aw_server_webui_dir }}/index.html" + regexp: '' + replace: '' + + - name: Добавить загрузчик RU patch перед закрытием body + ansible.builtin.replace: + path: "{{ aw_server_webui_dir }}/index.html" + regexp: '' + replace: '' + + - name: Скопировать merge script AW DB на сервер + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/merge_aw_server_dbs.py" + dest: /usr/local/bin/merge_aw_server_dbs.py + owner: root + group: root + mode: "0755" + + - name: Проверить наличие legacy root DB + ansible.builtin.stat: + path: /root/.local/share/activitywatch/aw-server-rust/sqlite.db + register: aw_legacy_root_db + + - name: Проверить наличие target DB + ansible.builtin.stat: + path: "{{ aw_server_db_path }}" + register: aw_target_db + + - name: Остановить сервис перед merge server DB + ansible.builtin.systemd: + name: activitywatch-server.service + state: stopped + when: aw_legacy_root_db.stat.exists | default(false) + + - name: Создать backup каталоги server DB + ansible.builtin.file: + path: "{{ aw_server_data_dir }}/backups/db" + state: directory + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0755" + when: aw_legacy_root_db.stat.exists | default(false) + + - name: Backup target DB перед merge + ansible.builtin.copy: + remote_src: true + src: "{{ aw_server_db_path }}" + dest: "{{ aw_server_data_dir }}/backups/db/target-before-merge-{{ ansible_date_time.iso8601_basic_short }}.sqlite.db" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + when: + - aw_legacy_root_db.stat.exists | default(false) + - aw_target_db.stat.exists | default(false) + + - name: Backup legacy root DB перед merge + ansible.builtin.copy: + remote_src: true + src: /root/.local/share/activitywatch/aw-server-rust/sqlite.db + dest: "{{ aw_server_data_dir }}/backups/db/legacy-root-{{ ansible_date_time.iso8601_basic_short }}.sqlite.db" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + when: aw_legacy_root_db.stat.exists | default(false) + + - name: Merge legacy root DB в target DB + ansible.builtin.command: + argv: + - python3 + - /usr/local/bin/merge_aw_server_dbs.py + - --base + - /root/.local/share/activitywatch/aw-server-rust/sqlite.db + - --overlay + - "{{ aw_server_db_path }}" + - --output + - "{{ aw_server_db_path }}.merged" + when: + - aw_legacy_root_db.stat.exists | default(false) + - aw_target_db.stat.exists | default(false) + + - name: Install merged DB as active target DB + ansible.builtin.copy: + remote_src: true + src: "{{ aw_server_db_path }}.merged" + dest: "{{ aw_server_db_path }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + when: + - aw_legacy_root_db.stat.exists | default(false) + - aw_target_db.stat.exists | default(false) + + - name: Скопировать legacy root DB в target DB если target ещё не существует + ansible.builtin.copy: + remote_src: true + src: /root/.local/share/activitywatch/aw-server-rust/sqlite.db + dest: "{{ aw_server_db_path }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + when: + - aw_legacy_root_db.stat.exists | default(false) + - not (aw_target_db.stat.exists | default(false)) + + - name: Записать aw-server-rust config.toml с разрешёнными CORS origin + ansible.builtin.copy: + dest: "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust/config.toml" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + content: | + cors = [ + {% for origin in aw_server_cors_origins | default([]) %} + "{{ origin }}"{% if not loop.last %},{% endif %} + {% endfor %} + ] + + - name: Включить и запустить сервис + ansible.builtin.systemd: + name: activitywatch-server.service + enabled: true + state: restarted + daemon_reload: true + + - name: Дождаться ответа API + ansible.builtin.uri: + url: "http://127.0.0.1:{{ aw_server_port }}/api/0/info" + method: GET + status_code: 200 + register: aw_api + retries: 10 + delay: 3 + until: aw_api.status == 200 + + - name: Считать текущие server-side settings ansible.builtin.uri: - url: "http://127.0.0.1:{{ aw_server_port }}/api/0/info" + url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/" method: GET status_code: 200 - register: aw_api - retries: 10 - delay: 3 - until: aw_api.status == 200 + register: aw_settings_current + when: aw_apply_worktime_settings | default(false) | bool + + - name: Считать текущие server-side views + ansible.builtin.uri: + url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/views" + method: GET + status_code: 200 + register: aw_views_current + when: aw_apply_worktime_settings | default(false) | bool + + - name: Считать текущие server-side classes + ansible.builtin.uri: + url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/classes" + method: GET + status_code: 200 + register: aw_classes_current + when: aw_apply_worktime_settings | default(false) | bool + + - name: Создать backup текущих server-side settings/views/classes + ansible.builtin.copy: + dest: "{{ aw_server_data_dir }}/backups/{{ item.name }}-{{ ansible_date_time.iso8601_basic_short }}.json" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + content: "{{ item.payload | to_nice_json }}" + loop: + - name: settings + payload: "{{ aw_settings_current.json | default({}) }}" + - name: views + payload: "{{ aw_views_current.json | default(none) }}" + - name: classes + payload: "{{ aw_classes_current.json | default(none) }}" + when: aw_apply_worktime_settings | default(false) | bool + + - name: Настроить DLP Aggregator (Phase 2) + block: + - name: Создать каталог для скриптов + ansible.builtin.file: + path: "/opt/activitywatch/scripts" + state: directory + owner: root + group: root + mode: "0755" + + - name: Скопировать агрегатор событий DLP + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/aggregate_dlp_events.py" + dest: "/opt/activitywatch/scripts/aggregate_dlp_events.py" + owner: root + group: root + mode: "0755" + + - name: Установить systemd unit для агрегатора + ansible.builtin.copy: + dest: /etc/systemd/system/activitywatch-dlp-aggregator.service + content: | + [Unit] + Description=ActivityWatch DLP Event Aggregator + After=activitywatch-server.service + + [Service] + Type=oneshot + User={{ aw_server_user }} + WorkingDirectory={{ aw_server_data_dir }} + ExecStart=/usr/bin/python3 /opt/activitywatch/scripts/aggregate_dlp_events.py \ + --aw-url http://127.0.0.1:{{ aw_server_port }}/api/0 \ + --sqlite-path {{ aw_server_data_dir }}/dlp_warehouse.sqlite \ + --state-path {{ aw_server_data_dir }}/dlp-aggregator-state.json + + [Install] + WantedBy=multi-user.target + + - name: Установить systemd timer для агрегатора + ansible.builtin.copy: + dest: /etc/systemd/system/activitywatch-dlp-aggregator.timer + content: | + [Unit] + Description=Run ActivityWatch DLP Aggregator every 5 minutes + + [Timer] + OnBootSec=1min + OnUnitActiveSec=5min + AccuracySec=1s + + [Install] + WantedBy=timers.target + + - name: Включить и запустить таймер агрегатора + ansible.builtin.systemd: + name: activitywatch-dlp-aggregator.timer + enabled: true + state: started + daemon_reload: true - name: Применить базовые worktime settings (classes) ansible.builtin.uri: @@ -342,16 +652,12 @@ - name: Вычислить worktime durationDefault из aw_worktime_from/to ansible.builtin.set_fact: - aw_worktime_from_h: "{{ (aw_worktime_from | default('08:00')).split(':')[0] | int }}" - aw_worktime_from_m: "{{ (aw_worktime_from | default('08:00')).split(':')[1] | int }}" - aw_worktime_to_h: "{{ (aw_worktime_to | default('17:00')).split(':')[0] | int }}" - aw_worktime_to_m: "{{ (aw_worktime_to | default('17:00')).split(':')[1] | int }}" aw_worktime_duration_default_derived: >- {{ ( ( - ((aw_worktime_to_h | int) * 60 + (aw_worktime_to_m | int)) - - ((aw_worktime_from_h | int) * 60 + (aw_worktime_from_m | int)) + (((aw_worktime_to | default('17:00')).split(':')[0] | int) * 60 + ((aw_worktime_to | default('17:00')).split(':')[1] | int)) - + (((aw_worktime_from | default('08:00')).split(':')[0] | int) * 60 + ((aw_worktime_from | default('08:00')).split(':')[1] | int)) ) * 60 ) }} @@ -379,20 +685,46 @@ ansible.builtin.uri: url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/startOfDay" method: POST - body: "{{ aw_worktime_start_of_day }}" - body_format: json - status_code: 200 + body: "\"{{ aw_worktime_start_of_day }}\"" + headers: + Content-Type: application/json + status_code: [200, 201] when: aw_apply_worktime_settings | default(false) | bool - name: Применить базовый период worktime (durationDefault seconds) ansible.builtin.uri: url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/durationDefault" method: POST - body: "{{ aw_worktime_duration_default_effective }}" - body_format: json - status_code: 200 + body: "{{ aw_worktime_duration_default_effective | string }}" + headers: + Content-Type: application/json + status_code: [200, 201] when: aw_apply_worktime_settings | default(false) | bool + - name: Применить always_active_pattern для fallback без AFK + ansible.builtin.uri: + url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/always_active_pattern" + method: POST + body: "\"{{ aw_server_always_active_pattern }}\"" + headers: + Content-Type: application/json + status_code: [200, 201] + when: + - aw_apply_worktime_settings | default(false) | bool + - (aw_server_always_active_pattern | default('') | string | length) > 0 + + - name: Применить landingpage профиля + ansible.builtin.uri: + url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/landingpage" + method: POST + body: "\"{{ aw_server_landingpage }}\"" + headers: + Content-Type: application/json + status_code: [200, 201] + when: + - aw_apply_worktime_settings | default(false) | bool + - (aw_server_landingpage | default('') | string | length) > 0 + handlers: - name: Перезагрузить systemd ansible.builtin.systemd: diff --git a/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml b/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml index c78830c..d7384ff 100644 --- a/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml +++ b/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml @@ -25,6 +25,7 @@ aw_windows_state_root: "C:\\ProgramData\\AWatch-rus" aw_windows_afk_enabled: true aw_windows_window_enabled: true + aw_windows_file_ops_enabled: true aw_windows_local_agent_logs_enabled: false aw_windows_incident_capture_enabled: true aw_windows_incident_screenshot_enabled: true @@ -78,6 +79,7 @@ - browser-domains-native-collector.ps1 - dlp-endpoint-signals-collector.ps1 - email-outbound-collector.ps1 + - file-operations-collector.ps1 - worktime-session-collector.ps1 - migrate-awatch-rus-paths.ps1 - deploy-domain-users.ps1 @@ -87,6 +89,18 @@ - web-category-rules.example.json - dlp-policy.example.json + - name: Нормализовать кодировку PowerShell файлов (UTF-8 BOM для Windows PowerShell) + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $toolkitDir = "{{ aw_windows_deploy_root }}\windows" + $encIn = New-Object System.Text.UTF8Encoding($false) + $encOut = New-Object System.Text.UTF8Encoding($true) + Get-ChildItem -LiteralPath $toolkitDir -File -Include *.ps1,*.psm1,*.psd1 | ForEach-Object { + $text = [System.IO.File]::ReadAllText($_.FullName, $encIn) + [System.IO.File]::WriteAllText($_.FullName, $text, $encOut) + } + - name: Загрузить список пользователей для доменного развёртывания ansible.windows.win_copy: dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt" @@ -131,6 +145,7 @@ 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' }} + FileOpsEnabled = {{ '$true' if (aw_windows_file_ops_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' }} @@ -150,6 +165,19 @@ {% endif %} & "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params + - name: Удалить лишние ActivityWatch Launch tasks вне текущего deployment-config + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json + $desired = @($config.userTasks | ForEach-Object { [string]$_.LaunchTaskName }) + foreach ($task in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch *' })) { + if ($desired -notcontains [string]$task.TaskName) { + Unregister-ScheduledTask -TaskName $task.TaskName -Confirm:$false -ErrorAction SilentlyContinue + & cmd.exe /c "schtasks /Delete /TN `"$($task.TaskName)`" /F >nul 2>&1" | Out-Null + } + } + - name: Принудительно запустить ActivityWatch recovery и launch tasks when: aw_windows_force_task_restart | bool ansible.windows.win_powershell: @@ -172,6 +200,7 @@ when: - aw_windows_api_smoke_check_enabled | bool - aw_windows_afk_enabled | bool + - aw_windows_hostname_result.stdout is defined ansible.builtin.set_fact: aw_windows_api_smoke_check_bucket_effective: >- {{ @@ -180,54 +209,51 @@ else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim) }} - - name: Дождаться свежих AFK событий на AW server + - name: Выполнить AW API smoke-check (проверка наличия свежих событий в AFK бакете) when: - aw_windows_api_smoke_check_enabled | bool - aw_windows_afk_enabled | bool - delegate_to: localhost ansible.builtin.uri: url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}" method: GET - return_content: true - register: aw_windows_api_smoke - until: > - aw_windows_api_smoke.status == 200 and - (aw_windows_api_smoke.json | length) > 0 and - ( - aw_windows_api_smoke.json - | selectattr('data.status', 'equalto', 'not-afk') - | list - | length - ) > 0 - retries: 10 - delay: 6 + status_code: 200 + register: aw_windows_api_smoke_result + until: aw_windows_api_smoke_result.json | length > 0 + retries: 5 + delay: 5 + ignore_errors: true - - name: Выполнить валидацию и сохранить отчёт на целевом Windows host + - name: Валидировать развёртывание на эндпоинте ansible.windows.win_powershell: script: | $ErrorActionPreference = 'Stop' - $report = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" ` + $result = & "{{ 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 - if ({{ '$true' if (aw_windows_fail_on_validation_error | bool) else '$false' }} -and -not [bool]$report.overallOk) { - throw "Проверка развёртывания ActivityWatch завершилась ошибкой. Отчёт: {{ aw_windows_validation_remote_path }}" - } + $result | ConvertTo-Json -Depth 8 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8 + return $result - - name: Создать локальный каталог для validation reports + - name: Создать локальную директорию для отчётов валидации ansible.builtin.file: path: "{{ aw_windows_validation_local_dir }}" state: directory mode: "0755" delegate_to: localhost - - name: Забрать validation report + - name: Стянуть отчёт валидации с эндпоинта ansible.builtin.fetch: src: "{{ aw_windows_validation_remote_path }}" dest: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json" flat: true - - name: Показать путь к отчёту - ansible.builtin.debug: - msg: - - "Windows/RDP развёртывание завершено на {{ inventory_hostname }}." - - "Отчёт проверки: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json" + - name: Проверить статус валидации + ansible.builtin.shell: | + python3 - <<'PY' + import json, sys + with open('{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json', 'r') as f: + data = json.load(f) + if not data.get('overallOk', False): + print(f"Validation failed for {{ inventory_hostname }}: {data.get('summary', 'Unknown error')}") + sys.exit(1) + PY + delegate_to: localhost + when: aw_windows_fail_on_validation_error | bool diff --git a/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml b/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml index be28ad3..171b218 100644 --- a/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml +++ b/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml @@ -4,6 +4,7 @@ aw_server_bind_host: "0.0.0.0" aw_server_port: 5600 aw_server_webui_dir: "/opt/activitywatch/webui-ru" aw_server_data_dir: "/var/lib/activitywatch" +aw_server_db_path: "/var/lib/activitywatch/.local/share/activitywatch/aw-server-rust/sqlite.db" aw_server_log_dir: "/var/log/activitywatch" aw_server_user: "activitywatch" aw_server_group: "activitywatch" @@ -12,9 +13,17 @@ aw_worktime_timezone: "Europe/Moscow" aw_repo_root: "{{ playbook_dir | dirname }}" -# Опционально: применить базовые категории и views для рабочего времени через AW settings API. -# Внимание: это перезаписывает существующие server-side settings/classes/views. -aw_apply_worktime_settings: false +# Применить базовые категории и views для рабочего времени через AW settings API. +# При прод-обновлениях это нужно оставлять включённым, иначе UI остаётся без views/classes. +aw_apply_worktime_settings: true + +# Дополнительные origin для aw-server-rust CORS. +# Обязательно включите тот origin, с которого реально открывается Web UI. +aw_server_cors_origins: + - "http://127.0.0.1:5600" + - "http://localhost:5600" + - "http://10.10.10.13:5600" + - "http://aw-server:5600" # Опциональные значения периода рабочего времени в Web UI. # startOfDay задаёт границу дня и стартовое время окна отчёта. @@ -24,3 +33,5 @@ aw_apply_worktime_settings: false aw_worktime_from: "08:00" aw_worktime_to: "17:00" aw_worktime_start_of_day: "{{ aw_worktime_from }}" +aw_server_always_active_pattern: "aw-watcher-window" +aw_server_landingpage: "/activity/SHARKON2025/view/" diff --git a/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml b/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml index 30c20c3..707b658 100644 --- a/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml +++ b/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml @@ -23,6 +23,7 @@ aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin" aw_windows_state_root: "C:\\ProgramData\\AWatch-rus" aw_windows_afk_enabled: true aw_windows_window_enabled: true +aw_windows_file_ops_enabled: true aw_windows_local_agent_logs_enabled: false aw_windows_incident_capture_enabled: true aw_windows_incident_screenshot_enabled: true diff --git a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml index 40c6382..1ac786e 100644 --- a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml +++ b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml @@ -11,6 +11,9 @@ - activitywatch-server.service - aw-worktime-api.py - aw-worktime-api.service + - aw-worktime-ui-bridge.py + - aw-worktime-ui-bridge.service + - aw-worktime-ui-bridge.timer - aw-worktime-panel.js - aw-server.env.example - aw-ru-patch.js diff --git a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml index 5452935..e0ff9bc 100644 --- a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml +++ b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml @@ -11,6 +11,9 @@ - activitywatch-server.service - aw-worktime-api.py - aw-worktime-api.service + - aw-worktime-ui-bridge.py + - aw-worktime-ui-bridge.service + - aw-worktime-ui-bridge.timer - aw-worktime-panel.js - aw-server.env.example - aw-ru-patch.js diff --git a/install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service b/install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service index e8f26e3..89d31e3 100755 --- a/install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service +++ b/install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service @@ -9,7 +9,7 @@ EnvironmentFile=/etc/activitywatch/aw-server.env User=__AW_SERVER_USER__ Group=__AW_SERVER_GROUP__ WorkingDirectory=__AW_SERVER_DATA_DIR__ -ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT"' +ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT" --dbpath "$AW_SERVER_DB_PATH" --webpath "$AW_SERVER_WEBUI_DIR"' Restart=on-failure RestartSec=5s StateDirectory=activitywatch @@ -17,7 +17,7 @@ LogsDirectory=activitywatch NoNewPrivileges=true PrivateTmp=true ProtectSystem=full -ProtectHome=true +ProtectHome=read-only LimitNOFILE=65535 [Install] diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json b/install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json index f3d0118..7432f60 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json +++ b/install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json @@ -29,6 +29,21 @@ { "label": "DLP", "type": "bucket", "bucket_prefix": "aw-dlp-endpoint-signals_" } ] }, + { + "id": "linux-remote", + "name": "Linux remote workers", + "description": "Linux-хосты удалённых сотрудников: GUI активность, SSH/console и browser admin UI.", + "patterns": [ + "^(LINUX-WS|LINUX-DESKTOP|LX-|DESKTOP-|ADMIN-|WORKSTATION-|DEVBOX-)" + ], + "links": [ + { "label": "Активность", "type": "activity" }, + { "label": "SSH сессии", "type": "bucket", "bucket_prefix": "aw-ssh-sessions_" }, + { "label": "Команды shell", "type": "bucket", "bucket_prefix": "aw-console-commands_" }, + { "label": "Web категории", "type": "bucket", "bucket_prefix": "aw-detmir-web-category_" }, + { "label": "Все бакеты", "type": "buckets" } + ] + }, { "id": "virtual-infra", "name": "Virtual servers + Proxmox", diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js b/install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js index 28145bd..3f06563 100755 --- a/install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js +++ b/install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js @@ -370,6 +370,16 @@ return /^pve[-_]/i.test(String(host || "")); } + function isLikelyClientHost(host) { + const value = String(host || "").trim(); + if (!value) return false; + if (/^(?:unknown|undefined|null)$/i.test(value)) return false; + if (/^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1)$/i.test(value)) return false; + if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) return false; + if (value.indexOf(":") !== -1 && /^[0-9a-f:\[\]]+$/i.test(value)) return false; + return true; + } + function enforceSafeActivityViewForPveHost() { const hash = window.location.hash || ""; const match = hash.match(/^#\/activity\/([^/]+)\/day\/([^/]+)\/view\/([^/?#]+)/i); @@ -386,9 +396,9 @@ function getDlpHostFromSettings(settings) { const routeHost = getCurrentHostFromHash(); - if (routeHost) return routeHost; + if (isLikelyClientHost(routeHost)) return routeHost; const bucketHost = getDlpHostFromBucketId(getDlpBucketIdFromHash()); - if (bucketHost) return bucketHost; + if (isLikelyClientHost(bucketHost)) return bucketHost; return getTrendsHostFromSettings(settings); } @@ -680,6 +690,19 @@ { label: "DLP", type: "bucket", bucket_prefix: "aw-dlp-endpoint-signals_" } ] }, + { + id: "linux-remote", + name: "Linux remote workers", + description: "Linux-хосты удалённых сотрудников: GUI активность, SSH/console и browser admin UI.", + patterns: ["^(LINUX-WS|LINUX-DESKTOP|LX-|DESKTOP-|ADMIN-|WORKSTATION-|DEVBOX-)"], + links: [ + { label: "Активность", type: "activity" }, + { label: "SSH сессии", type: "bucket", bucket_prefix: "aw-ssh-sessions_" }, + { label: "Команды shell", type: "bucket", bucket_prefix: "aw-console-commands_" }, + { label: "Web категории", type: "bucket", bucket_prefix: "aw-detmir-web-category_" }, + { label: "Все бакеты", type: "buckets" } + ] + }, { id: "virtual-infra", name: "Virtual servers + Proxmox", @@ -740,7 +763,15 @@ const prefixes = [ "aw-watcher-window_", "aw-watcher-afk_", + "aw-console-commands_", + "aw-ssh-sessions_", + "aw-linux-web-context_", + "aw-detmir-web-category_", "aw-dlp-endpoint-signals_", + "aw-session-events_", + "aw-worktime-sessions_", + "aw-pve-webadmin-events_", + "aw-pve-task-events_", "aw-dlp-incidents_", "aw-pfsense-health_", "aw-pfsense-gateways_", @@ -770,7 +801,27 @@ return result; } - function matchHostGroup(host, groups) { + function hostHasBucketPrefix(hostBuckets, prefix) { + return (hostBuckets || []).some(function (bucketId) { + return String(bucketId || "").indexOf(prefix) === 0; + }); + } + + function matchHostGroup(host, groups, hostBuckets) { + const bucketList = hostBuckets || []; + if (hostHasBucketPrefix(bucketList, "aw-dlp-endpoint-signals_") || hostHasBucketPrefix(bucketList, "aw-session-events_")) { + return "windows-rdp"; + } + if ( + hostHasBucketPrefix(bucketList, "aw-console-commands_") || + hostHasBucketPrefix(bucketList, "aw-ssh-sessions_") || + hostHasBucketPrefix(bucketList, "aw-linux-web-context_") || + hostHasBucketPrefix(bucketList, "aw-detmir-web-category_") + ) { + if (!hostHasBucketPrefix(bucketList, "aw-pve-webadmin-events_") && !hostHasBucketPrefix(bucketList, "aw-pve-task-events_")) { + return "linux-remote"; + } + } for (const group of groups) { const patterns = Array.isArray(group.patterns) ? group.patterns : []; for (const pattern of patterns) { @@ -813,7 +864,7 @@ grouped.set("__ungrouped__", []); Array.from(hostBuckets.keys()).sort().forEach(function (host) { - const groupId = matchHostGroup(host, groups) || "__ungrouped__"; + const groupId = matchHostGroup(host, groups, hostBuckets.get(host) || []) || "__ungrouped__"; grouped.get(groupId).push(host); }); @@ -869,7 +920,7 @@ center.setAttribute("data-aw-ru-host-groups", "1"); center.innerHTML = '

Разделы хостов

' + - '

Здесь хосты разделены на пользовательские Windows RDP и инфраструктурные виртуальные серверы/Proxmox.

' + + '

Здесь хосты разделены на Windows RDP, Linux remote workers и инфраструктурные узлы.

' + '

Загрузка...

'; heading.parentElement.insertBefore(center, heading.nextSibling); } @@ -1425,7 +1476,8 @@ if (!settings || typeof settings !== "object") return ""; const landingpage = typeof settings.landingpage === "string" ? settings.landingpage : ""; const match = landingpage.match(/\/activity\/([^/]+)/); - return match && match[1] ? match[1] : ""; + const host = match && match[1] ? decodeURIComponent(match[1]) : ""; + return isLikelyClientHost(host) ? host : ""; } function getTrendsPath(hash) { @@ -1492,8 +1544,7 @@ .map(function (bucketId) { return bucketId.replace(/^aw-watcher-window_/i, ""); }) .filter(Boolean) .filter(function (host) { return !/^unknown$/i.test(host); }); - if (settingsHost && hosts.indexOf(settingsHost) >= 0) return settingsHost; - if (settingsHost) return settingsHost; + if (isLikelyClientHost(settingsHost) && hosts.indexOf(settingsHost) >= 0) return settingsHost; hosts.sort(); return hosts[0] || ""; } @@ -1533,7 +1584,8 @@ window.fetch = function (input, init) { try { const url = typeof input === "string" ? input : String(input && input.url || ""); - if (/\/api\/0\/query\/?$/i.test(url) && init && typeof init.body === "string") { + const isCategoryBuilderRoute = /^#\/settings\/category-builder(?:[/?#]|$)/i.test(window.location.hash || ""); + if (isCategoryBuilderRoute && /\/api\/0\/query\/?$/i.test(url) && init && typeof init.body === "string") { init = Object.assign({}, init, { body: rewriteUnknownCategoryBuilderQueryBody(init.body) }); @@ -1557,7 +1609,8 @@ proto.send = function (body) { try { const url = String(this.__awRuUrl || ""); - if (/\/api\/0\/query\/?$/i.test(url) && typeof body === "string") { + const isCategoryBuilderRoute = /^#\/settings\/category-builder(?:[/?#]|$)/i.test(window.location.hash || ""); + if (isCategoryBuilderRoute && /\/api\/0\/query\/?$/i.test(url) && typeof body === "string") { body = rewriteUnknownCategoryBuilderQueryBody(body); } } catch (error) { diff --git a/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh b/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh index 50ba3e7..4ac7a30 100755 --- a/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh +++ b/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh @@ -26,6 +26,9 @@ VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json" CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json" WORKTIME_API_SRC="$BOOTSTRAP_DIR/aw-worktime-api.py" WORKTIME_API_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-api.service" +WORKTIME_UI_BRIDGE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.py" +WORKTIME_UI_BRIDGE_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.service" +WORKTIME_UI_BRIDGE_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.timer" for var_name in "${required_vars[@]}"; do if [[ -z "${!var_name:-}" ]]; then @@ -103,6 +106,24 @@ if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then systemctl --no-pager --full status aw-worktime-api.service || true fi +if [[ -f "$WORKTIME_UI_BRIDGE_SRC" ]]; then + install -m 0755 "$WORKTIME_UI_BRIDGE_SRC" /usr/local/bin/aw-worktime-ui-bridge.py +fi + +if [[ -f "$WORKTIME_UI_BRIDGE_SERVICE_SRC" ]]; then + install -m 0644 "$WORKTIME_UI_BRIDGE_SERVICE_SRC" /etc/systemd/system/aw-worktime-ui-bridge.service +fi + +if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then + install -m 0644 "$WORKTIME_UI_BRIDGE_TIMER_SRC" /etc/systemd/system/aw-worktime-ui-bridge.timer + systemctl daemon-reload + systemctl disable --now aw-worktime-afk-bridge.timer >/dev/null 2>&1 || true + systemctl enable aw-worktime-ui-bridge.timer + systemctl restart aw-worktime-ui-bridge.timer + systemctl start aw-worktime-ui-bridge.service || true + systemctl --no-pager --full status aw-worktime-ui-bridge.timer || true +fi + for _ in $(seq 1 20); do if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then break diff --git a/install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json b/install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json index b6361f4..11ee810 100644 --- a/install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json +++ b/install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json @@ -20,7 +20,7 @@ "name": ["Работа", "Документы"], "rule": { "type": "regex", - "regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64)\\.exe\\b|Adobe Reader|Acrobat", + "regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64|libreoffice|writer|calc)\\.exe\\b|LibreOffice|OnlyOffice|Adobe Reader|Acrobat", "ignore_case": true }, "data": { "color": "#2E7D32" } @@ -40,7 +40,7 @@ "name": ["Работа", "Администрирование"], "rule": { "type": "regex", - "regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd)\\.exe\\b", + "regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd|gnome-terminal|gnome-terminal-server|xfce4-terminal|konsole|tilix|alacritty|xterm|remmina|virt-manager)\\.exe\\b|\\b(gnome-terminal|gnome-terminal-server|xfce4-terminal|konsole|tilix|alacritty|xterm|remmina|virt-manager)\\b|Proxmox Virtual Environment|\\bpfSense\\b|\\bGrafana\\b|\\bKibana\\b|\\bPortainer\\b", "ignore_case": true }, "data": { "color": "#6D4C41" } @@ -56,7 +56,7 @@ "name": ["Интернет", "Браузер"], "rule": { "type": "regex", - "regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser)\\.exe\\b", + "regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser|chromium)\\.exe\\b|\\b(chrome|chromium|firefox|opera|brave|vivaldi)\\b", "ignore_case": true }, "data": { "color": "#00897B" } @@ -82,7 +82,7 @@ "name": ["ActivityWatch"], "rule": { "type": "regex", - "regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b", + "regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b|\\baw-(watcher|qt)\\b", "ignore_case": true }, "data": {} diff --git a/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 b/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 index e3a30b5..5f1e7fb 100755 --- a/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 +++ b/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 @@ -306,6 +306,9 @@ function Copy-ActivityWatchCollectorAssets { $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force } + else { + Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force + } if ($CustomPolicySource) { $resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop @@ -534,11 +537,7 @@ function Get-CollectorPowerShellProcessCount { function New-LaunchLock { param([string]`$StateRoot, [int]`$SessionId) - if (-not (Test-Path -LiteralPath `$StateRoot)) { - New-Item -Path `$StateRoot -ItemType Directory -Force | Out-Null - } - - `$lockPath = Join-Path `$StateRoot ("launch-watchers-session-{0}.lock" -f `$SessionId) + `$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId) if (Test-Path -LiteralPath `$lockPath) { try { `$lockData = Get-Content -LiteralPath `$lockPath -Raw | ConvertFrom-Json @@ -731,13 +730,11 @@ function Start-CollectorScriptIfNeeded { return } - Start-Process -FilePath `$PowerShellExe -ArgumentList @( - '-NoProfile', - '-WindowStyle', 'Hidden', - '-ExecutionPolicy', 'Bypass', - '-File', `$ScriptPath, - '-ConfigPath', `$ConfigPath - ) -WindowStyle Hidden + `$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null } + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass') + if (`$staParam) { `$argumentList += `$staParam } + `$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden } `$config = Get-DeploymentConfig -Path `$ConfigPath 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 index d0c809f..e7418d0 100755 --- a/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 +++ b/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 @@ -1,4 +1,122 @@ -[CmdletBinding()] +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$RulesPath, + [string]$PolicyPath, + [string]$LogPath, + [string]$IncidentLogPath, + [int]$PollSeconds, + [int]$PulseSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class NativeAwMethods { + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern int GetWindowTextLength(IntPtr hWnd); +} +"@ + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\AWatch-rus\web-category-rules.json' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) } +$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentLogPath = $resolvedIncidentLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false +$script:IncidentState = @{} +$script:DlpRules = @() +$script:DlpDefaults = [ordered]@{ + enabled = $false + cooldownSeconds = 300 + action = 'log' + severity = 'low' +} +$script:BrowserMap = @{ + msedge = 'edge' + chrome = 'chrome' + brave = 'brave' + vivaldi = 'vivaldi' + opera = 'opera' + firefox = 'firefox' +} +$script:CategoryRules = @( + @{ Name = 'work_business_systems'; Group = 'work'; Domains = @('bitrix24.ru', '1c.ru', 'sbis.ru', 'kontur.ru', 'diadoc.ru', 'nalog.gov.ru', 'gosuslugi.ru') } + @{ Name = 'work_docs_collab'; Group = 'work'; Domains = @('office.com', 'sharepoint.com', 'docs.google.com', 'drive.google.com', 'notion.so', 'miro.com') } + @{ Name = 'work_dev'; Group = 'work'; Domains = @('github.com', 'gitlab.com', 'bitbucket.org', 'youtrack.cloud', 'atlassian.net') } + @{ Name = 'work_communication'; Group = 'work'; Domains = @('teams.microsoft.com', 'outlook.office.com', 'web.telegram.org', 'slack.com', 'zoom.us') } + @{ Name = 'neutral_search_reference'; Group = 'neutral'; Domains = @('google.com', 'google.ru', 'yandex.ru', 'bing.com', 'duckduckgo.com', 'wikipedia.org') } + @{ Name = 'neutral_news'; Group = 'neutral'; Domains = @('rbc.ru', 'tass.ru', 'ria.ru', 'kommersant.ru', 'vedomosti.ru') } + @{ Name = 'personal_social'; Group = 'personal'; Domains = @('vk.com', 'ok.ru', 'facebook.com', 'instagram.com', 'tiktok.com', 'x.com', 'twitter.com') } + @{ Name = 'personal_video'; Group = 'personal'; Domains = @('youtube.com', 'youtu.be', 'rutube.ru', 'twitch.tv', 'kinopoisk.ru') } + @{ Name = 'personal_marketplace'; Group = 'personal'; Domains = @('ozon.ru', 'wildberries.ru', 'avito.ru', 'aliexpress.com', 'market.yandex.ru') } + @{ Name = 'personal_entertainment'; Group = 'personal'; Domains = @('dzen.ru', 'pikabu.ru', 'dtf.ru', 'playground.ru') } +) + +function Write-CollectorLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { Write-Error [CmdletBinding()] param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$ServerHost, @@ -541,7 +659,7 @@ function Send-DlpIncidentHeartbeat { } + $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 + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null } function Get-FileSha256Hex { @@ -701,13 +819,26 @@ function Ensure-Bucket { 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 - Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json' -Body $body | Out-Null + try { + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null + } + catch { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } $script:KnownBuckets[$BucketId] = $true } @@ -733,7 +864,7 @@ function Send-Heartbeat { } } | ConvertTo-Json -Depth 4 -Compress - Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null } function Send-CategoryHeartbeat { @@ -770,7 +901,3225 @@ function Send-CategoryHeartbeat { } } | ConvertTo-Json -Depth 4 -Compress - Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +Load-CustomCategoryRules -Path $resolvedRulesPath +Load-DlpPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("коллектор запущен для {0}" -f $script:ApiBase) + +while ($true) { + try { + $context = Get-ForegroundWindowContext + if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) { + $url = Get-BrowserUrlFromWindow -Handle $context.Handle + if ($url) { + $browserKey = $script:BrowserMap[$context.ProcessName] + $domain = Get-HostFromUrl -Url $url + if (-not $domain) { + $domain = 'unknown' + } + + $rootDomain = Get-RootDomain -DomainHost $domain + if (-not $rootDomain) { + $rootDomain = $domain + } + + $category = Get-WebCategory -DomainHost $domain + $bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey) + Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName + Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule + + $decision = Get-DlpDecision -Domain $domain -RootDomain $rootDomain -Url $url -Title $context.Title -BrowserKey $browserKey -Category $category.Name -CategoryGroup $category.Group + if ($decision) { + $fingerprint = '{0}|{1}|{2}|{3}' -f $decision.id, $browserKey, $rootDomain, $env:USERNAME + $cooldown = [Math]::Max([int]$decision.cooldownSeconds, 30) + if (Should-EmitIncident -Fingerprint $fingerprint -CooldownSeconds $cooldown) { + Write-DlpIncidentLog ("{0} {1} {2} {3}" -f $decision.severity, $decision.action, $decision.id, $url) + if (@('alert', 'block', 'quarantine') -contains ([string]$decision.action).ToLowerInvariant()) { + Send-DlpIncidentHeartbeat -Decision $decision -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group + } + } + } + } + } + } + catch { + Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } +} + +function Write-DlpIncidentLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:IncidentLogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$RulesPath, + [string]$PolicyPath, + [string]$LogPath, + [string]$IncidentLogPath, + [int]$PollSeconds, + [int]$PulseSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class NativeAwMethods { + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern int GetWindowTextLength(IntPtr hWnd); +} +"@ + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\AWatch-rus\web-category-rules.json' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) } +$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentLogPath = $resolvedIncidentLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false +$script:IncidentState = @{} +$script:DlpRules = @() +$script:DlpDefaults = [ordered]@{ + enabled = $false + cooldownSeconds = 300 + action = 'log' + severity = 'low' +} +$script:BrowserMap = @{ + msedge = 'edge' + chrome = 'chrome' + brave = 'brave' + vivaldi = 'vivaldi' + opera = 'opera' + firefox = 'firefox' +} +$script:CategoryRules = @( + @{ Name = 'work_business_systems'; Group = 'work'; Domains = @('bitrix24.ru', '1c.ru', 'sbis.ru', 'kontur.ru', 'diadoc.ru', 'nalog.gov.ru', 'gosuslugi.ru') } + @{ Name = 'work_docs_collab'; Group = 'work'; Domains = @('office.com', 'sharepoint.com', 'docs.google.com', 'drive.google.com', 'notion.so', 'miro.com') } + @{ Name = 'work_dev'; Group = 'work'; Domains = @('github.com', 'gitlab.com', 'bitbucket.org', 'youtrack.cloud', 'atlassian.net') } + @{ Name = 'work_communication'; Group = 'work'; Domains = @('teams.microsoft.com', 'outlook.office.com', 'web.telegram.org', 'slack.com', 'zoom.us') } + @{ Name = 'neutral_search_reference'; Group = 'neutral'; Domains = @('google.com', 'google.ru', 'yandex.ru', 'bing.com', 'duckduckgo.com', 'wikipedia.org') } + @{ Name = 'neutral_news'; Group = 'neutral'; Domains = @('rbc.ru', 'tass.ru', 'ria.ru', 'kommersant.ru', 'vedomosti.ru') } + @{ Name = 'personal_social'; Group = 'personal'; Domains = @('vk.com', 'ok.ru', 'facebook.com', 'instagram.com', 'tiktok.com', 'x.com', 'twitter.com') } + @{ Name = 'personal_video'; Group = 'personal'; Domains = @('youtube.com', 'youtu.be', 'rutube.ru', 'twitch.tv', 'kinopoisk.ru') } + @{ Name = 'personal_marketplace'; Group = 'personal'; Domains = @('ozon.ru', 'wildberries.ru', 'avito.ru', 'aliexpress.com', 'market.yandex.ru') } + @{ Name = 'personal_entertainment'; Group = 'personal'; Domains = @('dzen.ru', 'pikabu.ru', 'dtf.ru', 'playground.ru') } +) + +function Write-CollectorLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Write-DlpIncidentLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:IncidentLogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Test-DomainMatch { + param( + [string]$DomainHost, + [string]$RuleDomain + ) + + if ([string]::IsNullOrWhiteSpace($DomainHost) -or [string]::IsNullOrWhiteSpace($RuleDomain)) { + return $false + } + + $left = $DomainHost.ToLowerInvariant() + $right = $RuleDomain.ToLowerInvariant() + return $left -eq $right -or $left.EndsWith('.' + $right) +} + +function Get-HostFromUrl { + param([string]$Url) + + if ([string]::IsNullOrWhiteSpace($Url)) { + return $null + } + + try { + $uri = [Uri]$Url + $uriHost = $uri.Host.ToLowerInvariant() + if ($uriHost.StartsWith('www.')) { + return $uriHost.Substring(4) + } + + return $uriHost + } + catch { + return $null + } +} + +function Get-RootDomain { + param([string]$DomainHost) + + if ([string]::IsNullOrWhiteSpace($DomainHost)) { + return $null + } + + $parts = $DomainHost.Split('.') + if ($parts.Count -le 2) { + return $DomainHost + } + + $suffix = ('{0}.{1}' -f $parts[$parts.Count - 2], $parts[$parts.Count - 1]).ToLowerInvariant() + $compoundTlds = @('co.uk', 'com.au', 'co.jp', 'com.br', 'co.in', 'com.tr', 'com.cn') + if (($compoundTlds -contains $suffix) -and $parts.Count -ge 3) { + return ('{0}.{1}' -f $parts[$parts.Count - 3], $suffix).ToLowerInvariant() + } + + return $suffix +} + +function ConvertTo-NormalizedUrl { + param([AllowNull()][string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + $candidate = $Value.Trim() + if ($candidate.Length -lt 4) { + return $null + } + + if ($candidate -match '^(?i)(search|find|address and search|search with|новая вкладка|new tab)') { + return $null + } + + if ($candidate -match '^(?i)(https?|file|ftp|chrome|edge|about|view-source)://') { + return $candidate + } + + if ($candidate -match '^(?i)localhost([/:]|$)') { + return "http://$candidate" + } + + if ($candidate -match '^[a-z0-9.-]+\.[a-z]{2,}([/:?#].*)?$') { + return "https://$candidate" + } + + return $null +} + +function Load-CustomCategoryRules { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $rules = @() + + if ($parsed.rules) { + $sourceRules = @($parsed.rules) + } + elseif ($parsed -is [System.Collections.IEnumerable]) { + $sourceRules = @($parsed) + } + else { + $sourceRules = @() + } + + foreach ($rule in $sourceRules) { + if (-not $rule) { + continue + } + + $name = [string]$rule.name + $group = [string]$rule.group + $domains = @($rule.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) + + if ($name -and $group -and $domains.Count -gt 0) { + $rules += @{ + Name = $name + Group = $group + Domains = $domains + } + } + } + + if ($rules.Count -gt 0) { + $script:CategoryRules = @($rules) + @($script:CategoryRules) + Write-CollectorLog ("пользовательские правила загружены: {0}" -f $rules.Count) + } + } + catch { + Write-CollectorLog ("не удалось загрузить пользовательские правила: {0}" -f $_.Exception.Message) + } +} + +function Get-WebCategory { + param([string]$DomainHost) + + foreach ($rule in $script:CategoryRules) { + foreach ($domain in $rule.Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return [pscustomobject]@{ + Name = [string]$rule.Name + Group = [string]$rule.Group + Rule = [string]$domain + } + } + } + } + + return [pscustomobject]@{ + Name = 'uncategorized' + Group = 'neutral' + Rule = 'none' + } +} + +function Test-DomainListMatch { + param( + [string]$DomainHost, + [string[]]$Domains + ) + + if (-not $Domains -or $Domains.Count -eq 0) { + return $false + } + + foreach ($domain in $Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return $true + } + } + + return $false +} + +function Test-DlpRuleTimeWindow { + param( + [int]$CurrentHour, + [AllowNull()][int]$HourFrom, + [AllowNull()][int]$HourTo + ) + + if ($null -eq $HourFrom -or $null -eq $HourTo) { + return $true + } + + if ($HourFrom -eq $HourTo) { + return $true + } + + if ($HourFrom -lt $HourTo) { + return ($CurrentHour -ge $HourFrom -and $CurrentHour -lt $HourTo) + } + + return ($CurrentHour -ge $HourFrom -or $CurrentHour -lt $HourTo) +} + +function Load-DlpPolicy { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("DLP-политика не найдена, DLP отключен: {0}" -f $Path) + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $defaults = $parsed.defaults + if ($defaults) { + if ($defaults.PSObject.Properties.Name -contains 'enabled') { + $script:DlpDefaults.enabled = [bool]$defaults.enabled + } + if ($defaults.cooldownSeconds) { + $script:DlpDefaults.cooldownSeconds = [int]$defaults.cooldownSeconds + } + if ($defaults.action) { + $script:DlpDefaults.action = [string]$defaults.action + } + if ($defaults.severity) { + $script:DlpDefaults.severity = [string]$defaults.severity + } + } + + $loaded = @() + foreach ($rule in @($parsed.rules)) { + if (-not $rule) { continue } + $when = $rule.when + if (-not $when) { + $when = [pscustomobject]@{} + } + $loaded += [pscustomobject]@{ + id = [string]$rule.id + enabled = if ($rule.PSObject.Properties.Name -contains 'enabled') { [bool]$rule.enabled } else { $true } + action = if ($rule.action) { [string]$rule.action } else { [string]$script:DlpDefaults.action } + severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:DlpDefaults.severity } + message = if ($rule.message) { [string]$rule.message } else { "Сработало DLP-правило: $($rule.id)" } + cooldownSeconds = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:DlpDefaults.cooldownSeconds } + when = [pscustomobject]@{ + domains = if ($when.PSObject.Properties.Name -contains 'domains') { @($when.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categoryGroups = if ($when.PSObject.Properties.Name -contains 'categoryGroups') { @($when.categoryGroups | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categories = if ($when.PSObject.Properties.Name -contains 'categories') { @($when.categories | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + browsers = if ($when.PSObject.Properties.Name -contains 'browsers') { @($when.browsers | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + urlRegex = if ($when.PSObject.Properties.Name -contains 'urlRegex' -and $when.urlRegex) { [string]$when.urlRegex } else { $null } + titleRegex = if ($when.PSObject.Properties.Name -contains 'titleRegex' -and $when.titleRegex) { [string]$when.titleRegex } else { $null } + hourFrom = if ($when.PSObject.Properties.Name -contains 'hourFrom') { [int]$when.hourFrom } else { $null } + hourTo = if ($when.PSObject.Properties.Name -contains 'hourTo') { [int]$when.hourTo } else { $null } + } + } + } + + $script:DlpRules = @($loaded) + Write-CollectorLog ("DLP-политика загружена: включена={0}, правил={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count) + } + catch { + Write-CollectorLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message) + } +} + +function Test-DlpRuleMatch { + param( + [pscustomobject]$Rule, + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $Rule.enabled) { + return $false + } + + $when = $Rule.when + $currentHour = (Get-Date).Hour + if (-not (Test-DlpRuleTimeWindow -CurrentHour $currentHour -HourFrom $when.hourFrom -HourTo $when.hourTo)) { + return $false + } + + if ($when.domains.Count -gt 0) { + $domainMatched = (Test-DomainListMatch -DomainHost $Domain -Domains $when.domains) -or (Test-DomainListMatch -DomainHost $RootDomain -Domains $when.domains) + if (-not $domainMatched) { + return $false + } + } + + if ($when.categoryGroups.Count -gt 0 -and ($when.categoryGroups -notcontains $CategoryGroup.ToLowerInvariant())) { + return $false + } + + if ($when.categories.Count -gt 0 -and ($when.categories -notcontains $Category.ToLowerInvariant())) { + return $false + } + + if ($when.browsers.Count -gt 0 -and ($when.browsers -notcontains $BrowserKey.ToLowerInvariant())) { + return $false + } + + if ($when.urlRegex) { + if (-not ($Url -match $when.urlRegex)) { + return $false + } + } + + if ($when.titleRegex) { + if (-not ($Title -match $when.titleRegex)) { + return $false + } + } + + return $true +} + +function Get-DlpDecision { + param( + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $script:DlpDefaults.enabled) { + return $null + } + + foreach ($rule in $script:DlpRules) { + if (Test-DlpRuleMatch -Rule $rule -Domain $Domain -RootDomain $RootDomain -Url $Url -Title $Title -BrowserKey $BrowserKey -Category $Category -CategoryGroup $CategoryGroup) { + return $rule + } + } + + return $null +} + +function Should-EmitIncident { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:IncidentState.ContainsKey($Fingerprint)) { + $last = [datetime]$script:IncidentState[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:IncidentState[$Fingerprint] = $now + return $true +} + +function Send-DlpIncidentHeartbeat { + param( + [pscustomobject]$Decision, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId ([string]$Decision.id) -SignalType 'web' + } + catch { + } + } + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = [string]$Decision.id + action = [string]$Decision.action + severity = [string]$Decision.severity + message = [string]$Decision.message + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + username = $env:USERNAME + hostname = $script:Hostname + sessionId = $script:SessionId + source = 'uia-native-dlp' + } + $captureData + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-CollectorLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message) + return @{} + } +} + +function Get-ForegroundWindowContext { + $handle = [NativeAwMethods]::GetForegroundWindow() + if ($handle -eq [IntPtr]::Zero) { + return $null + } + + $processId = [uint32]0 + [void][NativeAwMethods]::GetWindowThreadProcessId($handle, [ref]$processId) + if (-not $processId) { + return $null + } + + $process = Get-Process -Id ([int]$processId) -ErrorAction SilentlyContinue + if (-not $process) { + return $null + } + + $textLength = [NativeAwMethods]::GetWindowTextLength($handle) + $builder = [Text.StringBuilder]::new([Math]::Max($textLength + 1, 260)) + [void][NativeAwMethods]::GetWindowText($handle, $builder, $builder.Capacity) + + return [pscustomobject]@{ + Handle = $handle + ProcessName = $process.ProcessName.ToLowerInvariant() + Title = $builder.ToString() + } +} + +function Get-BrowserUrlFromWindow { + param([IntPtr]$Handle) + + $root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle) + if (-not $root) { + return $null + } + + $editCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Edit + ) + + $edits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $editCondition) + foreach ($edit in $edits) { + $valuePattern = $null + if ($edit.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$valuePattern)) { + $candidate = ConvertTo-NormalizedUrl -Value $valuePattern.Current.Value + if ($candidate) { + return $candidate + } + } + + $candidateFromName = ConvertTo-NormalizedUrl -Value $edit.Current.Name + if ($candidateFromName) { + return $candidateFromName + } + } + + return $null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType = 'web.tab.current' + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null + } + catch { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-Heartbeat { + param( + [string]$BucketId, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName + ) + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Send-CategoryHeartbeat { + param( + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup, + [string]$CategoryRule + ) + + $bucketId = 'aw-detmir-web-category_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-detmir-web-category' -BucketType 'aw.web.category' + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + categoryRule = $CategoryRule + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +Load-CustomCategoryRules -Path $resolvedRulesPath +Load-DlpPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("коллектор запущен для {0}" -f $script:ApiBase) + +while ($true) { + try { + $context = Get-ForegroundWindowContext + if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) { + $url = Get-BrowserUrlFromWindow -Handle $context.Handle + if ($url) { + $browserKey = $script:BrowserMap[$context.ProcessName] + $domain = Get-HostFromUrl -Url $url + if (-not $domain) { + $domain = 'unknown' + } + + $rootDomain = Get-RootDomain -DomainHost $domain + if (-not $rootDomain) { + $rootDomain = $domain + } + + $category = Get-WebCategory -DomainHost $domain + $bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey) + Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName + Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule + + $decision = Get-DlpDecision -Domain $domain -RootDomain $rootDomain -Url $url -Title $context.Title -BrowserKey $browserKey -Category $category.Name -CategoryGroup $category.Group + if ($decision) { + $fingerprint = '{0}|{1}|{2}|{3}' -f $decision.id, $browserKey, $rootDomain, $env:USERNAME + $cooldown = [Math]::Max([int]$decision.cooldownSeconds, 30) + if (Should-EmitIncident -Fingerprint $fingerprint -CooldownSeconds $cooldown) { + Write-DlpIncidentLog ("{0} {1} {2} {3}" -f $decision.severity, $decision.action, $decision.id, $url) + if (@('alert', 'block', 'quarantine') -contains ([string]$decision.action).ToLowerInvariant()) { + Send-DlpIncidentHeartbeat -Decision $decision -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group + } + } + } + } + } + } + catch { + Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } +} + +function Test-DomainMatch { + param( + [string]$DomainHost, + [string]$RuleDomain + ) + + if ([string]::IsNullOrWhiteSpace($DomainHost) -or [string]::IsNullOrWhiteSpace($RuleDomain)) { + return $false + } + + $left = $DomainHost.ToLowerInvariant() + $right = $RuleDomain.ToLowerInvariant() + return $left -eq $right -or $left.EndsWith('.' + $right) +} + +function Get-HostFromUrl { + param([string]$Url) + + if ([string]::IsNullOrWhiteSpace($Url)) { + return $null + } + + try { + $uri = [Uri]$Url + $uriHost = $uri.Host.ToLowerInvariant() + if ($uriHost.StartsWith('www.')) { + return $uriHost.Substring(4) + } + + return $uriHost + } + catch { + return $null + } +} + +function Get-RootDomain { + param([string]$DomainHost) + + if ([string]::IsNullOrWhiteSpace($DomainHost)) { + return $null + } + + $parts = $DomainHost.Split('.') + if ($parts.Count -le 2) { + return $DomainHost + } + + $suffix = ('{0}.{1}' -f $parts[$parts.Count - 2], $parts[$parts.Count - 1]).ToLowerInvariant() + $compoundTlds = @('co.uk', 'com.au', 'co.jp', 'com.br', 'co.in', 'com.tr', 'com.cn') + if (($compoundTlds -contains $suffix) -and $parts.Count -ge 3) { + return ('{0}.{1}' -f $parts[$parts.Count - 3], $suffix).ToLowerInvariant() + } + + return $suffix +} + +function ConvertTo-NormalizedUrl { + param([AllowNull()][string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + $candidate = $Value.Trim() + if ($candidate.Length -lt 4) { + return $null + } + + if ($candidate -match '^(?i)(search|find|address and search|search with|новая вкладка|new tab)') { + return $null + } + + if ($candidate -match '^(?i)(https?|file|ftp|chrome|edge|about|view-source)://') { + return $candidate + } + + if ($candidate -match '^(?i)localhost([/:]|$)') { + return "http://$candidate" + } + + if ($candidate -match '^[a-z0-9.-]+\.[a-z]{2,}([/:?#].*)?$') { + return "https://$candidate" + } + + return $null +} + +function Load-CustomCategoryRules { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $rules = @() + + if ($parsed.rules) { + $sourceRules = @($parsed.rules) + } + elseif ($parsed -is [System.Collections.IEnumerable]) { + $sourceRules = @($parsed) + } + else { + $sourceRules = @() + } + + foreach ($rule in $sourceRules) { + if (-not $rule) { + continue + } + + $name = [string]$rule.name + $group = [string]$rule.group + $domains = @($rule.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) + + if ($name -and $group -and $domains.Count -gt 0) { + $rules += @{ + Name = $name + Group = $group + Domains = $domains + } + } + } + + if ($rules.Count -gt 0) { + $script:CategoryRules = @($rules) + @($script:CategoryRules) + Write-CollectorLog ("пользовательские правила загружены: {0}" -f $rules.Count) + } + } + catch { + Write-CollectorLog ("не удалось загрузить пользовательские правила: {0}" -f $_.Exception.Message) + } +} + +function Get-WebCategory { + param([string]$DomainHost) + + foreach ($rule in $script:CategoryRules) { + foreach ($domain in $rule.Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return [pscustomobject]@{ + Name = [string]$rule.Name + Group = [string]$rule.Group + Rule = [string]$domain + } + } + } + } + + return [pscustomobject]@{ + Name = 'uncategorized' + Group = 'neutral' + Rule = 'none' + } +} + +function Test-DomainListMatch { + param( + [string]$DomainHost, + [string[]]$Domains + ) + + if (-not $Domains -or $Domains.Count -eq 0) { + return $false + } + + foreach ($domain in $Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return $true + } + } + + return $false +} + +function Test-DlpRuleTimeWindow { + param( + [int]$CurrentHour, + [AllowNull()][int]$HourFrom, + [AllowNull()][int]$HourTo + ) + + if ($null -eq $HourFrom -or $null -eq $HourTo) { + return $true + } + + if ($HourFrom -eq $HourTo) { + return $true + } + + if ($HourFrom -lt $HourTo) { + return ($CurrentHour -ge $HourFrom -and $CurrentHour -lt $HourTo) + } + + return ($CurrentHour -ge $HourFrom -or $CurrentHour -lt $HourTo) +} + +function Load-DlpPolicy { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("DLP-политика не найдена, DLP отключен: {0}" -f $Path) + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $defaults = $parsed.defaults + if ($defaults) { + if ($defaults.PSObject.Properties.Name -contains 'enabled') { + $script:DlpDefaults.enabled = [bool]$defaults.enabled + } + if ($defaults.cooldownSeconds) { + $script:DlpDefaults.cooldownSeconds = [int]$defaults.cooldownSeconds + } + if ($defaults.action) { + $script:DlpDefaults.action = [string]$defaults.action + } + if ($defaults.severity) { + $script:DlpDefaults.severity = [string]$defaults.severity + } + } + + $loaded = @() + foreach ($rule in @($parsed.rules)) { + if (-not $rule) { continue } + $when = $rule.when + if (-not $when) { + $when = [pscustomobject]@{} + } + $loaded += [pscustomobject]@{ + id = [string]$rule.id + enabled = if ($rule.PSObject.Properties.Name -contains 'enabled') { [bool]$rule.enabled } else { $true } + action = if ($rule.action) { [string]$rule.action } else { [string]$script:DlpDefaults.action } + severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:DlpDefaults.severity } + message = if ($rule.message) { [string]$rule.message } else { "Сработало DLP-правило: $($rule.id)" } + cooldownSeconds = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:DlpDefaults.cooldownSeconds } + when = [pscustomobject]@{ + domains = if ($when.PSObject.Properties.Name -contains 'domains') { @($when.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categoryGroups = if ($when.PSObject.Properties.Name -contains 'categoryGroups') { @($when.categoryGroups | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categories = if ($when.PSObject.Properties.Name -contains 'categories') { @($when.categories | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + browsers = if ($when.PSObject.Properties.Name -contains 'browsers') { @($when.browsers | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + urlRegex = if ($when.PSObject.Properties.Name -contains 'urlRegex' -and $when.urlRegex) { [string]$when.urlRegex } else { $null } + titleRegex = if ($when.PSObject.Properties.Name -contains 'titleRegex' -and $when.titleRegex) { [string]$when.titleRegex } else { $null } + hourFrom = if ($when.PSObject.Properties.Name -contains 'hourFrom') { [int]$when.hourFrom } else { $null } + hourTo = if ($when.PSObject.Properties.Name -contains 'hourTo') { [int]$when.hourTo } else { $null } + } + } + } + + $script:DlpRules = @($loaded) + Write-CollectorLog ("DLP-политика загружена: включена={0}, правил={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count) + } + catch { + Write-CollectorLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message) + } +} + +function Test-DlpRuleMatch { + param( + [pscustomobject]$Rule, + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $Rule.enabled) { + return $false + } + + $when = $Rule.when + $currentHour = (Get-Date).Hour + if (-not (Test-DlpRuleTimeWindow -CurrentHour $currentHour -HourFrom $when.hourFrom -HourTo $when.hourTo)) { + return $false + } + + if ($when.domains.Count -gt 0) { + $domainMatched = (Test-DomainListMatch -DomainHost $Domain -Domains $when.domains) -or (Test-DomainListMatch -DomainHost $RootDomain -Domains $when.domains) + if (-not $domainMatched) { + return $false + } + } + + if ($when.categoryGroups.Count -gt 0 -and ($when.categoryGroups -notcontains $CategoryGroup.ToLowerInvariant())) { + return $false + } + + if ($when.categories.Count -gt 0 -and ($when.categories -notcontains $Category.ToLowerInvariant())) { + return $false + } + + if ($when.browsers.Count -gt 0 -and ($when.browsers -notcontains $BrowserKey.ToLowerInvariant())) { + return $false + } + + if ($when.urlRegex) { + if (-not ($Url -match $when.urlRegex)) { + return $false + } + } + + if ($when.titleRegex) { + if (-not ($Title -match $when.titleRegex)) { + return $false + } + } + + return $true +} + +function Get-DlpDecision { + param( + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $script:DlpDefaults.enabled) { + return $null + } + + foreach ($rule in $script:DlpRules) { + if (Test-DlpRuleMatch -Rule $rule -Domain $Domain -RootDomain $RootDomain -Url $Url -Title $Title -BrowserKey $BrowserKey -Category $Category -CategoryGroup $CategoryGroup) { + return $rule + } + } + + return $null +} + +function Should-EmitIncident { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:IncidentState.ContainsKey($Fingerprint)) { + $last = [datetime]$script:IncidentState[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:IncidentState[$Fingerprint] = $now + return $true +} + +function Send-DlpIncidentHeartbeat { + param( + [pscustomobject]$Decision, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId ([string]$Decision.id) -SignalType 'web' + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$RulesPath, + [string]$PolicyPath, + [string]$LogPath, + [string]$IncidentLogPath, + [int]$PollSeconds, + [int]$PulseSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class NativeAwMethods { + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern int GetWindowTextLength(IntPtr hWnd); +} +"@ + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\AWatch-rus\web-category-rules.json' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) } +$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentLogPath = $resolvedIncidentLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false +$script:IncidentState = @{} +$script:DlpRules = @() +$script:DlpDefaults = [ordered]@{ + enabled = $false + cooldownSeconds = 300 + action = 'log' + severity = 'low' +} +$script:BrowserMap = @{ + msedge = 'edge' + chrome = 'chrome' + brave = 'brave' + vivaldi = 'vivaldi' + opera = 'opera' + firefox = 'firefox' +} +$script:CategoryRules = @( + @{ Name = 'work_business_systems'; Group = 'work'; Domains = @('bitrix24.ru', '1c.ru', 'sbis.ru', 'kontur.ru', 'diadoc.ru', 'nalog.gov.ru', 'gosuslugi.ru') } + @{ Name = 'work_docs_collab'; Group = 'work'; Domains = @('office.com', 'sharepoint.com', 'docs.google.com', 'drive.google.com', 'notion.so', 'miro.com') } + @{ Name = 'work_dev'; Group = 'work'; Domains = @('github.com', 'gitlab.com', 'bitbucket.org', 'youtrack.cloud', 'atlassian.net') } + @{ Name = 'work_communication'; Group = 'work'; Domains = @('teams.microsoft.com', 'outlook.office.com', 'web.telegram.org', 'slack.com', 'zoom.us') } + @{ Name = 'neutral_search_reference'; Group = 'neutral'; Domains = @('google.com', 'google.ru', 'yandex.ru', 'bing.com', 'duckduckgo.com', 'wikipedia.org') } + @{ Name = 'neutral_news'; Group = 'neutral'; Domains = @('rbc.ru', 'tass.ru', 'ria.ru', 'kommersant.ru', 'vedomosti.ru') } + @{ Name = 'personal_social'; Group = 'personal'; Domains = @('vk.com', 'ok.ru', 'facebook.com', 'instagram.com', 'tiktok.com', 'x.com', 'twitter.com') } + @{ Name = 'personal_video'; Group = 'personal'; Domains = @('youtube.com', 'youtu.be', 'rutube.ru', 'twitch.tv', 'kinopoisk.ru') } + @{ Name = 'personal_marketplace'; Group = 'personal'; Domains = @('ozon.ru', 'wildberries.ru', 'avito.ru', 'aliexpress.com', 'market.yandex.ru') } + @{ Name = 'personal_entertainment'; Group = 'personal'; Domains = @('dzen.ru', 'pikabu.ru', 'dtf.ru', 'playground.ru') } +) + +function Write-CollectorLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Write-DlpIncidentLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:IncidentLogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Test-DomainMatch { + param( + [string]$DomainHost, + [string]$RuleDomain + ) + + if ([string]::IsNullOrWhiteSpace($DomainHost) -or [string]::IsNullOrWhiteSpace($RuleDomain)) { + return $false + } + + $left = $DomainHost.ToLowerInvariant() + $right = $RuleDomain.ToLowerInvariant() + return $left -eq $right -or $left.EndsWith('.' + $right) +} + +function Get-HostFromUrl { + param([string]$Url) + + if ([string]::IsNullOrWhiteSpace($Url)) { + return $null + } + + try { + $uri = [Uri]$Url + $uriHost = $uri.Host.ToLowerInvariant() + if ($uriHost.StartsWith('www.')) { + return $uriHost.Substring(4) + } + + return $uriHost + } + catch { + return $null + } +} + +function Get-RootDomain { + param([string]$DomainHost) + + if ([string]::IsNullOrWhiteSpace($DomainHost)) { + return $null + } + + $parts = $DomainHost.Split('.') + if ($parts.Count -le 2) { + return $DomainHost + } + + $suffix = ('{0}.{1}' -f $parts[$parts.Count - 2], $parts[$parts.Count - 1]).ToLowerInvariant() + $compoundTlds = @('co.uk', 'com.au', 'co.jp', 'com.br', 'co.in', 'com.tr', 'com.cn') + if (($compoundTlds -contains $suffix) -and $parts.Count -ge 3) { + return ('{0}.{1}' -f $parts[$parts.Count - 3], $suffix).ToLowerInvariant() + } + + return $suffix +} + +function ConvertTo-NormalizedUrl { + param([AllowNull()][string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + $candidate = $Value.Trim() + if ($candidate.Length -lt 4) { + return $null + } + + if ($candidate -match '^(?i)(search|find|address and search|search with|новая вкладка|new tab)') { + return $null + } + + if ($candidate -match '^(?i)(https?|file|ftp|chrome|edge|about|view-source)://') { + return $candidate + } + + if ($candidate -match '^(?i)localhost([/:]|$)') { + return "http://$candidate" + } + + if ($candidate -match '^[a-z0-9.-]+\.[a-z]{2,}([/:?#].*)?$') { + return "https://$candidate" + } + + return $null +} + +function Load-CustomCategoryRules { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $rules = @() + + if ($parsed.rules) { + $sourceRules = @($parsed.rules) + } + elseif ($parsed -is [System.Collections.IEnumerable]) { + $sourceRules = @($parsed) + } + else { + $sourceRules = @() + } + + foreach ($rule in $sourceRules) { + if (-not $rule) { + continue + } + + $name = [string]$rule.name + $group = [string]$rule.group + $domains = @($rule.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) + + if ($name -and $group -and $domains.Count -gt 0) { + $rules += @{ + Name = $name + Group = $group + Domains = $domains + } + } + } + + if ($rules.Count -gt 0) { + $script:CategoryRules = @($rules) + @($script:CategoryRules) + Write-CollectorLog ("пользовательские правила загружены: {0}" -f $rules.Count) + } + } + catch { + Write-CollectorLog ("не удалось загрузить пользовательские правила: {0}" -f $_.Exception.Message) + } +} + +function Get-WebCategory { + param([string]$DomainHost) + + foreach ($rule in $script:CategoryRules) { + foreach ($domain in $rule.Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return [pscustomobject]@{ + Name = [string]$rule.Name + Group = [string]$rule.Group + Rule = [string]$domain + } + } + } + } + + return [pscustomobject]@{ + Name = 'uncategorized' + Group = 'neutral' + Rule = 'none' + } +} + +function Test-DomainListMatch { + param( + [string]$DomainHost, + [string[]]$Domains + ) + + if (-not $Domains -or $Domains.Count -eq 0) { + return $false + } + + foreach ($domain in $Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return $true + } + } + + return $false +} + +function Test-DlpRuleTimeWindow { + param( + [int]$CurrentHour, + [AllowNull()][int]$HourFrom, + [AllowNull()][int]$HourTo + ) + + if ($null -eq $HourFrom -or $null -eq $HourTo) { + return $true + } + + if ($HourFrom -eq $HourTo) { + return $true + } + + if ($HourFrom -lt $HourTo) { + return ($CurrentHour -ge $HourFrom -and $CurrentHour -lt $HourTo) + } + + return ($CurrentHour -ge $HourFrom -or $CurrentHour -lt $HourTo) +} + +function Load-DlpPolicy { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("DLP-политика не найдена, DLP отключен: {0}" -f $Path) + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $defaults = $parsed.defaults + if ($defaults) { + if ($defaults.PSObject.Properties.Name -contains 'enabled') { + $script:DlpDefaults.enabled = [bool]$defaults.enabled + } + if ($defaults.cooldownSeconds) { + $script:DlpDefaults.cooldownSeconds = [int]$defaults.cooldownSeconds + } + if ($defaults.action) { + $script:DlpDefaults.action = [string]$defaults.action + } + if ($defaults.severity) { + $script:DlpDefaults.severity = [string]$defaults.severity + } + } + + $loaded = @() + foreach ($rule in @($parsed.rules)) { + if (-not $rule) { continue } + $when = $rule.when + if (-not $when) { + $when = [pscustomobject]@{} + } + $loaded += [pscustomobject]@{ + id = [string]$rule.id + enabled = if ($rule.PSObject.Properties.Name -contains 'enabled') { [bool]$rule.enabled } else { $true } + action = if ($rule.action) { [string]$rule.action } else { [string]$script:DlpDefaults.action } + severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:DlpDefaults.severity } + message = if ($rule.message) { [string]$rule.message } else { "Сработало DLP-правило: $($rule.id)" } + cooldownSeconds = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:DlpDefaults.cooldownSeconds } + when = [pscustomobject]@{ + domains = if ($when.PSObject.Properties.Name -contains 'domains') { @($when.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categoryGroups = if ($when.PSObject.Properties.Name -contains 'categoryGroups') { @($when.categoryGroups | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categories = if ($when.PSObject.Properties.Name -contains 'categories') { @($when.categories | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + browsers = if ($when.PSObject.Properties.Name -contains 'browsers') { @($when.browsers | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + urlRegex = if ($when.PSObject.Properties.Name -contains 'urlRegex' -and $when.urlRegex) { [string]$when.urlRegex } else { $null } + titleRegex = if ($when.PSObject.Properties.Name -contains 'titleRegex' -and $when.titleRegex) { [string]$when.titleRegex } else { $null } + hourFrom = if ($when.PSObject.Properties.Name -contains 'hourFrom') { [int]$when.hourFrom } else { $null } + hourTo = if ($when.PSObject.Properties.Name -contains 'hourTo') { [int]$when.hourTo } else { $null } + } + } + } + + $script:DlpRules = @($loaded) + Write-CollectorLog ("DLP-политика загружена: включена={0}, правил={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count) + } + catch { + Write-CollectorLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message) + } +} + +function Test-DlpRuleMatch { + param( + [pscustomobject]$Rule, + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $Rule.enabled) { + return $false + } + + $when = $Rule.when + $currentHour = (Get-Date).Hour + if (-not (Test-DlpRuleTimeWindow -CurrentHour $currentHour -HourFrom $when.hourFrom -HourTo $when.hourTo)) { + return $false + } + + if ($when.domains.Count -gt 0) { + $domainMatched = (Test-DomainListMatch -DomainHost $Domain -Domains $when.domains) -or (Test-DomainListMatch -DomainHost $RootDomain -Domains $when.domains) + if (-not $domainMatched) { + return $false + } + } + + if ($when.categoryGroups.Count -gt 0 -and ($when.categoryGroups -notcontains $CategoryGroup.ToLowerInvariant())) { + return $false + } + + if ($when.categories.Count -gt 0 -and ($when.categories -notcontains $Category.ToLowerInvariant())) { + return $false + } + + if ($when.browsers.Count -gt 0 -and ($when.browsers -notcontains $BrowserKey.ToLowerInvariant())) { + return $false + } + + if ($when.urlRegex) { + if (-not ($Url -match $when.urlRegex)) { + return $false + } + } + + if ($when.titleRegex) { + if (-not ($Title -match $when.titleRegex)) { + return $false + } + } + + return $true +} + +function Get-DlpDecision { + param( + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $script:DlpDefaults.enabled) { + return $null + } + + foreach ($rule in $script:DlpRules) { + if (Test-DlpRuleMatch -Rule $rule -Domain $Domain -RootDomain $RootDomain -Url $Url -Title $Title -BrowserKey $BrowserKey -Category $Category -CategoryGroup $CategoryGroup) { + return $rule + } + } + + return $null +} + +function Should-EmitIncident { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:IncidentState.ContainsKey($Fingerprint)) { + $last = [datetime]$script:IncidentState[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:IncidentState[$Fingerprint] = $now + return $true +} + +function Send-DlpIncidentHeartbeat { + param( + [pscustomobject]$Decision, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId ([string]$Decision.id) -SignalType 'web' + } + catch { + } + } + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = [string]$Decision.id + action = [string]$Decision.action + severity = [string]$Decision.severity + message = [string]$Decision.message + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + username = $env:USERNAME + hostname = $script:Hostname + sessionId = $script:SessionId + source = 'uia-native-dlp' + } + $captureData + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-CollectorLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message) + return @{} + } +} + +function Get-ForegroundWindowContext { + $handle = [NativeAwMethods]::GetForegroundWindow() + if ($handle -eq [IntPtr]::Zero) { + return $null + } + + $processId = [uint32]0 + [void][NativeAwMethods]::GetWindowThreadProcessId($handle, [ref]$processId) + if (-not $processId) { + return $null + } + + $process = Get-Process -Id ([int]$processId) -ErrorAction SilentlyContinue + if (-not $process) { + return $null + } + + $textLength = [NativeAwMethods]::GetWindowTextLength($handle) + $builder = [Text.StringBuilder]::new([Math]::Max($textLength + 1, 260)) + [void][NativeAwMethods]::GetWindowText($handle, $builder, $builder.Capacity) + + return [pscustomobject]@{ + Handle = $handle + ProcessName = $process.ProcessName.ToLowerInvariant() + Title = $builder.ToString() + } +} + +function Get-BrowserUrlFromWindow { + param([IntPtr]$Handle) + + $root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle) + if (-not $root) { + return $null + } + + $editCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Edit + ) + + $edits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $editCondition) + foreach ($edit in $edits) { + $valuePattern = $null + if ($edit.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$valuePattern)) { + $candidate = ConvertTo-NormalizedUrl -Value $valuePattern.Current.Value + if ($candidate) { + return $candidate + } + } + + $candidateFromName = ConvertTo-NormalizedUrl -Value $edit.Current.Name + if ($candidateFromName) { + return $candidateFromName + } + } + + return $null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType = 'web.tab.current' + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null + } + catch { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-Heartbeat { + param( + [string]$BucketId, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName + ) + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Send-CategoryHeartbeat { + param( + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup, + [string]$CategoryRule + ) + + $bucketId = 'aw-detmir-web-category_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-detmir-web-category' -BucketType 'aw.web.category' + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + categoryRule = $CategoryRule + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +Load-CustomCategoryRules -Path $resolvedRulesPath +Load-DlpPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("коллектор запущен для {0}" -f $script:ApiBase) + +while ($true) { + try { + $context = Get-ForegroundWindowContext + if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) { + $url = Get-BrowserUrlFromWindow -Handle $context.Handle + if ($url) { + $browserKey = $script:BrowserMap[$context.ProcessName] + $domain = Get-HostFromUrl -Url $url + if (-not $domain) { + $domain = 'unknown' + } + + $rootDomain = Get-RootDomain -DomainHost $domain + if (-not $rootDomain) { + $rootDomain = $domain + } + + $category = Get-WebCategory -DomainHost $domain + $bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey) + Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName + Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule + + $decision = Get-DlpDecision -Domain $domain -RootDomain $rootDomain -Url $url -Title $context.Title -BrowserKey $browserKey -Category $category.Name -CategoryGroup $category.Group + if ($decision) { + $fingerprint = '{0}|{1}|{2}|{3}' -f $decision.id, $browserKey, $rootDomain, $env:USERNAME + $cooldown = [Math]::Max([int]$decision.cooldownSeconds, 30) + if (Should-EmitIncident -Fingerprint $fingerprint -CooldownSeconds $cooldown) { + Write-DlpIncidentLog ("{0} {1} {2} {3}" -f $decision.severity, $decision.action, $decision.id, $url) + if (@('alert', 'block', 'quarantine') -contains ([string]$decision.action).ToLowerInvariant()) { + Send-DlpIncidentHeartbeat -Decision $decision -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group + } + } + } + } + } + } + catch { + Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + } + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = [string]$Decision.id + action = [string]$Decision.action + severity = [string]$Decision.severity + message = [string]$Decision.message + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + username = $env:USERNAME + hostname = $script:Hostname + sessionId = $script:SessionId + source = 'uia-native-dlp' + } + $captureData + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-CollectorLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message) + return @{} + } +} + +function Get-ForegroundWindowContext { + $handle = [NativeAwMethods]::GetForegroundWindow() + if ($handle -eq [IntPtr]::Zero) { + return $null + } + + $processId = [uint32]0 + [void][NativeAwMethods]::GetWindowThreadProcessId($handle, [ref]$processId) + if (-not $processId) { + return $null + } + + $process = Get-Process -Id ([int]$processId) -ErrorAction SilentlyContinue + if (-not $process) { + return $null + } + + $textLength = [NativeAwMethods]::GetWindowTextLength($handle) + $builder = [Text.StringBuilder]::new([Math]::Max($textLength + 1, 260)) + [void][NativeAwMethods]::GetWindowText($handle, $builder, $builder.Capacity) + + return [pscustomobject]@{ + Handle = $handle + ProcessName = $process.ProcessName.ToLowerInvariant() + Title = $builder.ToString() + } +} + +function Get-BrowserUrlFromWindow { + param([IntPtr]$Handle) + + $root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle) + if (-not $root) { + return $null + } + + $editCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Edit + ) + + $edits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $editCondition) + foreach ($edit in $edits) { + $valuePattern = $null + if ($edit.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$valuePattern)) { + $candidate = ConvertTo-NormalizedUrl -Value $valuePattern.Current.Value + if ($candidate) { + return $candidate + } + } + + $candidateFromName = ConvertTo-NormalizedUrl -Value $edit.Current.Name + if ($candidateFromName) { + return $candidateFromName + } + } + + return $null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType = 'web.tab.current' + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$RulesPath, + [string]$PolicyPath, + [string]$LogPath, + [string]$IncidentLogPath, + [int]$PollSeconds, + [int]$PulseSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class NativeAwMethods { + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern int GetWindowTextLength(IntPtr hWnd); +} +"@ + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\AWatch-rus\web-category-rules.json' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) } +$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentLogPath = $resolvedIncidentLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false +$script:IncidentState = @{} +$script:DlpRules = @() +$script:DlpDefaults = [ordered]@{ + enabled = $false + cooldownSeconds = 300 + action = 'log' + severity = 'low' +} +$script:BrowserMap = @{ + msedge = 'edge' + chrome = 'chrome' + brave = 'brave' + vivaldi = 'vivaldi' + opera = 'opera' + firefox = 'firefox' +} +$script:CategoryRules = @( + @{ Name = 'work_business_systems'; Group = 'work'; Domains = @('bitrix24.ru', '1c.ru', 'sbis.ru', 'kontur.ru', 'diadoc.ru', 'nalog.gov.ru', 'gosuslugi.ru') } + @{ Name = 'work_docs_collab'; Group = 'work'; Domains = @('office.com', 'sharepoint.com', 'docs.google.com', 'drive.google.com', 'notion.so', 'miro.com') } + @{ Name = 'work_dev'; Group = 'work'; Domains = @('github.com', 'gitlab.com', 'bitbucket.org', 'youtrack.cloud', 'atlassian.net') } + @{ Name = 'work_communication'; Group = 'work'; Domains = @('teams.microsoft.com', 'outlook.office.com', 'web.telegram.org', 'slack.com', 'zoom.us') } + @{ Name = 'neutral_search_reference'; Group = 'neutral'; Domains = @('google.com', 'google.ru', 'yandex.ru', 'bing.com', 'duckduckgo.com', 'wikipedia.org') } + @{ Name = 'neutral_news'; Group = 'neutral'; Domains = @('rbc.ru', 'tass.ru', 'ria.ru', 'kommersant.ru', 'vedomosti.ru') } + @{ Name = 'personal_social'; Group = 'personal'; Domains = @('vk.com', 'ok.ru', 'facebook.com', 'instagram.com', 'tiktok.com', 'x.com', 'twitter.com') } + @{ Name = 'personal_video'; Group = 'personal'; Domains = @('youtube.com', 'youtu.be', 'rutube.ru', 'twitch.tv', 'kinopoisk.ru') } + @{ Name = 'personal_marketplace'; Group = 'personal'; Domains = @('ozon.ru', 'wildberries.ru', 'avito.ru', 'aliexpress.com', 'market.yandex.ru') } + @{ Name = 'personal_entertainment'; Group = 'personal'; Domains = @('dzen.ru', 'pikabu.ru', 'dtf.ru', 'playground.ru') } +) + +function Write-CollectorLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Write-DlpIncidentLog { + param([string]$Message) + + if (-not $script:LocalAgentLogsEnabled) { + return + } + + try { + Add-Content -LiteralPath $script:IncidentLogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Test-DomainMatch { + param( + [string]$DomainHost, + [string]$RuleDomain + ) + + if ([string]::IsNullOrWhiteSpace($DomainHost) -or [string]::IsNullOrWhiteSpace($RuleDomain)) { + return $false + } + + $left = $DomainHost.ToLowerInvariant() + $right = $RuleDomain.ToLowerInvariant() + return $left -eq $right -or $left.EndsWith('.' + $right) +} + +function Get-HostFromUrl { + param([string]$Url) + + if ([string]::IsNullOrWhiteSpace($Url)) { + return $null + } + + try { + $uri = [Uri]$Url + $uriHost = $uri.Host.ToLowerInvariant() + if ($uriHost.StartsWith('www.')) { + return $uriHost.Substring(4) + } + + return $uriHost + } + catch { + return $null + } +} + +function Get-RootDomain { + param([string]$DomainHost) + + if ([string]::IsNullOrWhiteSpace($DomainHost)) { + return $null + } + + $parts = $DomainHost.Split('.') + if ($parts.Count -le 2) { + return $DomainHost + } + + $suffix = ('{0}.{1}' -f $parts[$parts.Count - 2], $parts[$parts.Count - 1]).ToLowerInvariant() + $compoundTlds = @('co.uk', 'com.au', 'co.jp', 'com.br', 'co.in', 'com.tr', 'com.cn') + if (($compoundTlds -contains $suffix) -and $parts.Count -ge 3) { + return ('{0}.{1}' -f $parts[$parts.Count - 3], $suffix).ToLowerInvariant() + } + + return $suffix +} + +function ConvertTo-NormalizedUrl { + param([AllowNull()][string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + $candidate = $Value.Trim() + if ($candidate.Length -lt 4) { + return $null + } + + if ($candidate -match '^(?i)(search|find|address and search|search with|новая вкладка|new tab)') { + return $null + } + + if ($candidate -match '^(?i)(https?|file|ftp|chrome|edge|about|view-source)://') { + return $candidate + } + + if ($candidate -match '^(?i)localhost([/:]|$)') { + return "http://$candidate" + } + + if ($candidate -match '^[a-z0-9.-]+\.[a-z]{2,}([/:?#].*)?$') { + return "https://$candidate" + } + + return $null +} + +function Load-CustomCategoryRules { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $rules = @() + + if ($parsed.rules) { + $sourceRules = @($parsed.rules) + } + elseif ($parsed -is [System.Collections.IEnumerable]) { + $sourceRules = @($parsed) + } + else { + $sourceRules = @() + } + + foreach ($rule in $sourceRules) { + if (-not $rule) { + continue + } + + $name = [string]$rule.name + $group = [string]$rule.group + $domains = @($rule.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) + + if ($name -and $group -and $domains.Count -gt 0) { + $rules += @{ + Name = $name + Group = $group + Domains = $domains + } + } + } + + if ($rules.Count -gt 0) { + $script:CategoryRules = @($rules) + @($script:CategoryRules) + Write-CollectorLog ("пользовательские правила загружены: {0}" -f $rules.Count) + } + } + catch { + Write-CollectorLog ("не удалось загрузить пользовательские правила: {0}" -f $_.Exception.Message) + } +} + +function Get-WebCategory { + param([string]$DomainHost) + + foreach ($rule in $script:CategoryRules) { + foreach ($domain in $rule.Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return [pscustomobject]@{ + Name = [string]$rule.Name + Group = [string]$rule.Group + Rule = [string]$domain + } + } + } + } + + return [pscustomobject]@{ + Name = 'uncategorized' + Group = 'neutral' + Rule = 'none' + } +} + +function Test-DomainListMatch { + param( + [string]$DomainHost, + [string[]]$Domains + ) + + if (-not $Domains -or $Domains.Count -eq 0) { + return $false + } + + foreach ($domain in $Domains) { + if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) { + return $true + } + } + + return $false +} + +function Test-DlpRuleTimeWindow { + param( + [int]$CurrentHour, + [AllowNull()][int]$HourFrom, + [AllowNull()][int]$HourTo + ) + + if ($null -eq $HourFrom -or $null -eq $HourTo) { + return $true + } + + if ($HourFrom -eq $HourTo) { + return $true + } + + if ($HourFrom -lt $HourTo) { + return ($CurrentHour -ge $HourFrom -and $CurrentHour -lt $HourTo) + } + + return ($CurrentHour -ge $HourFrom -or $CurrentHour -lt $HourTo) +} + +function Load-DlpPolicy { + param([string]$Path) + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("DLP-политика не найдена, DLP отключен: {0}" -f $Path) + return + } + + try { + $parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $defaults = $parsed.defaults + if ($defaults) { + if ($defaults.PSObject.Properties.Name -contains 'enabled') { + $script:DlpDefaults.enabled = [bool]$defaults.enabled + } + if ($defaults.cooldownSeconds) { + $script:DlpDefaults.cooldownSeconds = [int]$defaults.cooldownSeconds + } + if ($defaults.action) { + $script:DlpDefaults.action = [string]$defaults.action + } + if ($defaults.severity) { + $script:DlpDefaults.severity = [string]$defaults.severity + } + } + + $loaded = @() + foreach ($rule in @($parsed.rules)) { + if (-not $rule) { continue } + $when = $rule.when + if (-not $when) { + $when = [pscustomobject]@{} + } + $loaded += [pscustomobject]@{ + id = [string]$rule.id + enabled = if ($rule.PSObject.Properties.Name -contains 'enabled') { [bool]$rule.enabled } else { $true } + action = if ($rule.action) { [string]$rule.action } else { [string]$script:DlpDefaults.action } + severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:DlpDefaults.severity } + message = if ($rule.message) { [string]$rule.message } else { "Сработало DLP-правило: $($rule.id)" } + cooldownSeconds = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:DlpDefaults.cooldownSeconds } + when = [pscustomobject]@{ + domains = if ($when.PSObject.Properties.Name -contains 'domains') { @($when.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categoryGroups = if ($when.PSObject.Properties.Name -contains 'categoryGroups') { @($when.categoryGroups | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + categories = if ($when.PSObject.Properties.Name -contains 'categories') { @($when.categories | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + browsers = if ($when.PSObject.Properties.Name -contains 'browsers') { @($when.browsers | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() } + urlRegex = if ($when.PSObject.Properties.Name -contains 'urlRegex' -and $when.urlRegex) { [string]$when.urlRegex } else { $null } + titleRegex = if ($when.PSObject.Properties.Name -contains 'titleRegex' -and $when.titleRegex) { [string]$when.titleRegex } else { $null } + hourFrom = if ($when.PSObject.Properties.Name -contains 'hourFrom') { [int]$when.hourFrom } else { $null } + hourTo = if ($when.PSObject.Properties.Name -contains 'hourTo') { [int]$when.hourTo } else { $null } + } + } + } + + $script:DlpRules = @($loaded) + Write-CollectorLog ("DLP-политика загружена: включена={0}, правил={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count) + } + catch { + Write-CollectorLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message) + } +} + +function Test-DlpRuleMatch { + param( + [pscustomobject]$Rule, + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $Rule.enabled) { + return $false + } + + $when = $Rule.when + $currentHour = (Get-Date).Hour + if (-not (Test-DlpRuleTimeWindow -CurrentHour $currentHour -HourFrom $when.hourFrom -HourTo $when.hourTo)) { + return $false + } + + if ($when.domains.Count -gt 0) { + $domainMatched = (Test-DomainListMatch -DomainHost $Domain -Domains $when.domains) -or (Test-DomainListMatch -DomainHost $RootDomain -Domains $when.domains) + if (-not $domainMatched) { + return $false + } + } + + if ($when.categoryGroups.Count -gt 0 -and ($when.categoryGroups -notcontains $CategoryGroup.ToLowerInvariant())) { + return $false + } + + if ($when.categories.Count -gt 0 -and ($when.categories -notcontains $Category.ToLowerInvariant())) { + return $false + } + + if ($when.browsers.Count -gt 0 -and ($when.browsers -notcontains $BrowserKey.ToLowerInvariant())) { + return $false + } + + if ($when.urlRegex) { + if (-not ($Url -match $when.urlRegex)) { + return $false + } + } + + if ($when.titleRegex) { + if (-not ($Title -match $when.titleRegex)) { + return $false + } + } + + return $true +} + +function Get-DlpDecision { + param( + [string]$Domain, + [string]$RootDomain, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$Category, + [string]$CategoryGroup + ) + + if (-not $script:DlpDefaults.enabled) { + return $null + } + + foreach ($rule in $script:DlpRules) { + if (Test-DlpRuleMatch -Rule $rule -Domain $Domain -RootDomain $RootDomain -Url $Url -Title $Title -BrowserKey $BrowserKey -Category $Category -CategoryGroup $CategoryGroup) { + return $rule + } + } + + return $null +} + +function Should-EmitIncident { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:IncidentState.ContainsKey($Fingerprint)) { + $last = [datetime]$script:IncidentState[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:IncidentState[$Fingerprint] = $now + return $true +} + +function Send-DlpIncidentHeartbeat { + param( + [pscustomobject]$Decision, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId ([string]$Decision.id) -SignalType 'web' + } + catch { + } + } + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = [string]$Decision.id + action = [string]$Decision.action + severity = [string]$Decision.severity + message = [string]$Decision.message + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + username = $env:USERNAME + hostname = $script:Hostname + sessionId = $script:SessionId + source = 'uia-native-dlp' + } + $captureData + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-CollectorLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message) + return @{} + } +} + +function Get-ForegroundWindowContext { + $handle = [NativeAwMethods]::GetForegroundWindow() + if ($handle -eq [IntPtr]::Zero) { + return $null + } + + $processId = [uint32]0 + [void][NativeAwMethods]::GetWindowThreadProcessId($handle, [ref]$processId) + if (-not $processId) { + return $null + } + + $process = Get-Process -Id ([int]$processId) -ErrorAction SilentlyContinue + if (-not $process) { + return $null + } + + $textLength = [NativeAwMethods]::GetWindowTextLength($handle) + $builder = [Text.StringBuilder]::new([Math]::Max($textLength + 1, 260)) + [void][NativeAwMethods]::GetWindowText($handle, $builder, $builder.Capacity) + + return [pscustomobject]@{ + Handle = $handle + ProcessName = $process.ProcessName.ToLowerInvariant() + Title = $builder.ToString() + } +} + +function Get-BrowserUrlFromWindow { + param([IntPtr]$Handle) + + $root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle) + if (-not $root) { + return $null + } + + $editCondition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Edit + ) + + $edits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $editCondition) + foreach ($edit in $edits) { + $valuePattern = $null + if ($edit.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$valuePattern)) { + $candidate = ConvertTo-NormalizedUrl -Value $valuePattern.Current.Value + if ($candidate) { + return $candidate + } + } + + $candidateFromName = ConvertTo-NormalizedUrl -Value $edit.Current.Name + if ($candidateFromName) { + return $candidateFromName + } + } + + return $null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType = 'web.tab.current' + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + try { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + $script:KnownBuckets[$BucketId] = $true + return + } + catch { + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null + } + catch { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-Heartbeat { + param( + [string]$BucketId, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName + ) + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Send-CategoryHeartbeat { + param( + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup, + [string]$CategoryRule + ) + + $bucketId = 'aw-detmir-web-category_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-detmir-web-category' -BucketType 'aw.web.category' + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + categoryRule = $CategoryRule + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +Load-CustomCategoryRules -Path $resolvedRulesPath +Load-DlpPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("коллектор запущен для {0}" -f $script:ApiBase) + +while ($true) { + try { + $context = Get-ForegroundWindowContext + if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) { + $url = Get-BrowserUrlFromWindow -Handle $context.Handle + if ($url) { + $browserKey = $script:BrowserMap[$context.ProcessName] + $domain = Get-HostFromUrl -Url $url + if (-not $domain) { + $domain = 'unknown' + } + + $rootDomain = Get-RootDomain -DomainHost $domain + if (-not $rootDomain) { + $rootDomain = $domain + } + + $category = Get-WebCategory -DomainHost $domain + $bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey) + Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName + Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule + + $decision = Get-DlpDecision -Domain $domain -RootDomain $rootDomain -Url $url -Title $context.Title -BrowserKey $browserKey -Category $category.Name -CategoryGroup $category.Group + if ($decision) { + $fingerprint = '{0}|{1}|{2}|{3}' -f $decision.id, $browserKey, $rootDomain, $env:USERNAME + $cooldown = [Math]::Max([int]$decision.cooldownSeconds, 30) + if (Should-EmitIncident -Fingerprint $fingerprint -CooldownSeconds $cooldown) { + Write-DlpIncidentLog ("{0} {1} {2} {3}" -f $decision.severity, $decision.action, $decision.id, $url) + if (@('alert', 'block', 'quarantine') -contains ([string]$decision.action).ToLowerInvariant()) { + Send-DlpIncidentHeartbeat -Decision $decision -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group + } + } + } + } + } + } + catch { + Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + try { + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null + } + catch { + Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null + } + $script:KnownBuckets[$BucketId] = $true +} + +function Send-Heartbeat { + param( + [string]$BucketId, + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName + ) + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Send-CategoryHeartbeat { + param( + [string]$Url, + [string]$Title, + [string]$BrowserKey, + [string]$ProcessName, + [string]$Domain, + [string]$RootDomain, + [string]$Category, + [string]$CategoryGroup, + [string]$CategoryRule + ) + + $bucketId = 'aw-detmir-web-category_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-detmir-web-category' -BucketType 'aw.web.category' + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + url = $Url + title = $Title + browser = $BrowserKey + app = "$ProcessName.exe" + domain = $Domain + rootDomain = $RootDomain + category = $Category + categoryGroup = $CategoryGroup + categoryRule = $CategoryRule + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null } Load-CustomCategoryRules -Path $resolvedRulesPath diff --git a/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 index 92e5494..8a83b16 100755 --- a/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 +++ b/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ServerHost, @@ -112,8 +112,8 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName -Write-Host 'ActivityWatch развёрнут для пользователей:' -$targetUsers | ForEach-Object { Write-Host " - $_" } -Write-Host "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort" -Write-Host "Каталог данных: $StateRoot" -Write-Host "Файл DLP-политики: $($assetResult.ActivePolicy)" +Write-Output 'ActivityWatch развёрнут для пользователей:' +$targetUsers | ForEach-Object { Write-Output " - $_" } +Write-Output "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort" +Write-Output "Каталог данных: $StateRoot" +Write-Output "Файл DLP-политики: $($assetResult.ActivePolicy)" diff --git a/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 index fa6fd75..1efc953 100644 --- a/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 +++ b/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ServerHost, @@ -136,6 +136,6 @@ if ($reportDirectory) { $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $effectiveReportPath -Encoding UTF8 -Write-Host 'Комплексное развёртывание ActivityWatch завершено.' -Write-Host "Пользователи: $($resolvedUsers -join ', ')" -Write-Host "Отчёт: $effectiveReportPath" +Write-Output 'Комплексное развёртывание ActivityWatch завершено.' +Write-Output "Пользователи: $($resolvedUsers -join ', ')" +Write-Output "Отчёт: $effectiveReportPath" diff --git a/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 index 160265d..7f4952a 100755 --- a/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 +++ b/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ServerHost, @@ -104,9 +104,9 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName -Write-Host "ActivityWatch развёрнут для пользователя: $TargetUser" -Write-Host "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort" -Write-Host "Каталог установки: $InstallRoot" -Write-Host "Каталог данных: $StateRoot" -Write-Host "Файл правил: $($assetResult.ActiveRules)" -Write-Host "Файл DLP-политики: $($assetResult.ActivePolicy)" +Write-Output "ActivityWatch развёрнут для пользователя: $TargetUser" +Write-Output "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort" +Write-Output "Каталог установки: $InstallRoot" +Write-Output "Каталог данных: $StateRoot" +Write-Output "Файл правил: $($assetResult.ActiveRules)" +Write-Output "Файл DLP-политики: $($assetResult.ActivePolicy)" diff --git a/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 b/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 index b286e48..5a09101 100644 --- a/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 +++ b/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 @@ -1,4 +1,35 @@ -[CmdletBinding()] +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +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 { Write-Error [CmdletBinding()] param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$ServerHost, @@ -40,7 +71,7 @@ function Invoke-AwJsonPost { ) $bytes = [Text.Encoding]::UTF8.GetBytes($Json) - Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null } function Ensure-Bucket { @@ -321,6 +352,44 @@ function Get-StringHash { } } +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + function Load-DlpPolicy { param([string]$Path) @@ -783,7 +852,7 @@ while ($true) { } try { - $clipboardText = Get-Clipboard -Raw -ErrorAction SilentlyContinue + $clipboardText = Get-ClipboardTextSafe if ($clipboardText) { $clipboardHash = Get-StringHash -Value $clipboardText if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { @@ -919,3 +988,6678 @@ while ($true) { Start-Sleep -Seconds $resolvedPollSeconds } +; } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { + } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { + } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { + } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { + } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { + } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { + } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { + } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { + } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { + } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { + } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { + } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { + } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { + } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { + } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { + } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { + } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { + } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { + } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { + } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { + } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { Write-Error [CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-EndpointLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { + return + } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + + if ($script:KnownBuckets.ContainsKey($BucketId)) { + return + } + + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Send-EndpointSignalHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal' + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-DlpIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [string]$SignalType, + [hashtable]$Data + ) + + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + + $captureData = @{} + if ($script:IncidentScreenshotEnabled) { + try { + $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType + } + catch { + } + } + + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'endpoint-signals-phase2' + } + $Data + $captureData + } | ConvertTo-Json -Depth 7 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Get-FileSha256Hex { + param([Parameter(Mandatory = $true)][string]$Path) + try { + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $stream.Dispose() + $sha.Dispose() + } + } + catch { + return $null + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Get-IncidentScreenshotPath { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_') + $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_') + $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_') + $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff') + $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule + $file = '{0}_{1}' -f $stamp, $file + return (Join-Path $script:IncidentArtifactsRoot $file) +} + +function Ensure-ScreenshotTypesLoaded { + if ($script:ScreenshotTypesLoaded) { + return + } + Add-Type -AssemblyName System.Windows.Forms | Out-Null + Add-Type -AssemblyName System.Drawing | Out-Null + $script:ScreenshotTypesLoaded = $true +} + +function Capture-IncidentScreenshot { + param( + [Parameter(Mandatory = $true)][string]$RuleId, + [Parameter(Mandatory = $true)][string]$SignalType + ) + + try { + Ensure-Directory -Path $script:IncidentArtifactsRoot + Ensure-ScreenshotTypesLoaded + + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height) + $gfx = [System.Drawing.Graphics]::FromImage($bmp) + try { + $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size) + $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + $gfx.Dispose() + $bmp.Dispose() + } + + return @{ + screenshotPath = $path + screenshotFormat = 'png' + screenshotWidth = [int]$vs.Width + screenshotHeight = [int]$vs.Height + screenshotSha256 = (Get-FileSha256Hex -Path $path) + } + } + catch { + Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message) + return @{} + } +} + +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { + $sha.Dispose() + } +} + +function Get-ClipboardTextSafe { + [OutputType([string])] + param() + + try { + $v = Get-Clipboard -Raw -ErrorAction Stop + if ($null -ne $v) { return [string]$v } + } + catch { + Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message) + } + + # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases. + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null + $result = [string]::Empty + $thread = [System.Threading.Thread]{ + try { + $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText() + } + catch { + $script:__aw_clip = $null + } + } + $thread.SetApartmentState([System.Threading.ApartmentState]::STA) + $thread.Start() + $thread.Join(3000) | Out-Null + if ($thread.IsAlive) { $thread.Abort() } + $result = [string]$script:__aw_clip + Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue + return $result + } + catch { + Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message) + return $null + } +} + +function Load-DlpPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + clipboard = @() + usb = @() + print = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + + if ($raw.endpoint) { + if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) } + if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) } + if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) } + } + } + catch { + Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + + $script:Cooldown[$Fingerprint] = $now + return $true +} + +function Evaluate-ClipboardRules { + param( + [string]$ClipboardText, + [string]$ClipboardHash + ) + + foreach ($rule in @($script:Policy.endpoint.clipboard)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 } + $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() } + if ($ClipboardText.Length -lt $minLength) { continue } + + $matched = $false + foreach ($pattern in $regexPatterns) { + if ($ClipboardText -match [string]$pattern) { + $matched = $true + break + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ + clipboardHash = $ClipboardHash + clipboardLength = $ClipboardText.Length + enforced = $enforced + } + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + } +} + +function Evaluate-UsbRules { + param( + [string]$DriveLetter, + [string]$VolumeName + ) + + foreach ($rule in @($script:Policy.endpoint.usb)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ + driveLetter = $DriveLetter + volumeName = $VolumeName + enforced = $enforced + } + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + } +} + +function Evaluate-PrintRules { + param( + [string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + + foreach ($rule in @($script:Policy.endpoint.print)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $match = $true + if ($rule.printerRegex) { + $match = $match -and ($PrinterName -match [string]$rule.printerRegex) + } + if ($rule.documentRegex) { + $match = $match -and ($DocumentName -match [string]$rule.documentRegex) + } + if (-not $match) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ + printerName = $PrinterName + documentName = $DocumentName + owner = $Owner + enforced = $enforced + } + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + } +} + +function Test-LooksLikeMojibakeQuestionMarks { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $true } + return $Value -match '\?{2,}' +} + +function Normalize-OwnerForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized -match '[\\/]') { + $parts = $normalized -split '[\\/]' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[$parts.Count - 1] + } + } + if ($normalized -match '@') { + $parts = $normalized -split '@' + if ($parts.Count -gt 0) { + $normalized = [string]$parts[0] + } + } + return $normalized +} + +function Test-OwnerLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-OwnerForMatch -Value $Expected + $actualNorm = Normalize-OwnerForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Normalize-PrinterForMatch { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + $normalized = $Value.Trim().ToLowerInvariant() + if ($normalized.Contains(',')) { + $normalized = ($normalized -split ',', 2)[0].Trim() + } + if ($normalized -match '\son\s') { + $normalized = ($normalized -split '\son\s', 2)[0].Trim() + } + return $normalized +} + +function Test-PrinterLooseMatch { + param( + [string]$Expected, + [string]$Actual + ) + $expectedNorm = Normalize-PrinterForMatch -Value $Expected + $actualNorm = Normalize-PrinterForMatch -Value $Actual + if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) { + return $false + } + return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm) +} + +function Get-PrintServiceEventSummary { + param([Parameter(Mandatory = $true)]$Event) + + $props = @($Event.Properties) + $propertyValues = @() + foreach ($prop in $props) { + $propertyValues += [string]$prop.Value + } + + [pscustomobject]@{ + RecordId = [string]$Event.RecordId + TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' } + PropertyCount = $props.Count + DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' } + Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' } + PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' } + PropertyValues = $propertyValues + } +} + +function Get-PrintServiceDocumentFallback { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Owner, + [string]$PrinterName + ) + + $preferred = [string]$EventSummary.DocumentName + if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { + return $preferred + } + + $pathCandidates = New-Object System.Collections.Generic.List[string] + $textCandidates = New-Object System.Collections.Generic.List[string] + + foreach ($value in @($EventSummary.PropertyValues)) { + $candidate = [string]$value + if ([string]::IsNullOrWhiteSpace($candidate)) { continue } + if ($candidate -eq $preferred) { continue } + if ($Owner -and $candidate -like "*$Owner*") { continue } + if ($PrinterName -and $candidate -like "*$PrinterName*") { continue } + if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue } + + if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') { + $pathCandidates.Add($candidate) + continue + } + + if ($candidate -match '^[0-9]+$') { + continue + } + + $textCandidates.Add($candidate) + } + + foreach ($candidate in @($pathCandidates)) { + $leaf = Split-Path -Path $candidate -Leaf + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + return $leaf + } + return $candidate + } + + foreach ($candidate in @($textCandidates)) { + return $candidate + } + + return $null +} + +function Write-PrintServiceEventTrace { + param( + [Parameter(Mandatory = $true)]$EventSummary, + [string]$Phase, + [string]$MatchReason, + [string]$ResolvedDocument + ) + + $properties = if ($EventSummary.PropertyValues) { + ($EventSummary.PropertyValues -join ' | ') + } + else { + '' + } + + Write-EndpointLog ( + 'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f + $Phase, + $EventSummary.RecordId, + $EventSummary.TimeCreated, + $EventSummary.Owner, + $EventSummary.PrinterName, + $EventSummary.DocumentName, + $ResolvedDocument, + $properties, + $MatchReason + ) +} + +function Get-BetterDocumentNameFromPrintServiceEvents { + param( + [string]$Owner, + [string]$PrinterName + ) + + try { + $startTime = (Get-Date).AddMinutes(-15) + $events = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = $startTime + } -MaxEvents 200 -ErrorAction Stop + + foreach ($pass in @('strict', 'relaxed')) { + foreach ($event in @($events)) { + $summary = Get-PrintServiceEventSummary -Event $event + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName + + $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true } + $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true } + + if ($pass -eq 'strict') { + if ($Owner -and -not $ownerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + if ($PrinterName -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument + continue + } + } + else { + if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument + continue + } + } + + if ([string]::IsNullOrWhiteSpace($resolvedDocument)) { + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument '' + continue + } + + $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' } + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument + return $resolvedDocument + } + } + } + catch { + } + + return $null +} + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } +$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } +$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenUsb = @{} +$script:SeenPrintJob = @{} +$script:SeenPrintEvent = @{} +$script:LastClipboardHash = $null +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60) +$script:LastSelfTestAt = [datetime]::MinValue +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot +$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled +$script:ScreenshotTypesLoaded = $false + +Load-DlpPolicy -Path $resolvedPolicyPath +Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) + +while ($true) { + try { + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) { + Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{ + collector = 'dlp-endpoint-signals' + policyEnabled = [bool]$script:Policy.defaults.enabled + } + $script:LastSelfTestAt = $nowUtc + } + + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + try { + $clipboardText = Get-ClipboardTextSafe + if ($clipboardText) { + $clipboardHash = Get-StringHash -Value $clipboardText + if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) { + $script:LastClipboardHash = $clipboardHash + Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{ + clipboardHash = $clipboardHash + clipboardLength = $clipboardText.Length + } + Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash + } + } + } + catch { + } + + try { + $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue + $currentUsb = @{} + foreach ($drive in @($usbDrives)) { + $deviceId = [string]$drive.DeviceID + if (-not $deviceId) { continue } + $currentUsb[$deviceId] = $true + if (-not $script:SeenUsb.ContainsKey($deviceId)) { + $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime() + $volumeName = [string]$drive.VolumeName + Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{ + driveLetter = $deviceId + volumeName = $volumeName + } + Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName + } + } + + foreach ($known in @($script:SeenUsb.Keys)) { + if (-not $currentUsb.ContainsKey($known)) { + $script:SeenUsb.Remove($known) + } + } + } + catch { + } + + try { + $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($printJobs)) { + $jobId = [string]$job.JobId + if (-not $jobId) { continue } + if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } + $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() + + $printerName = [string]$job.Name + $documentName = [string]$job.Document + $owner = [string]$job.Owner + $documentNameOriginal = $documentName + + if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { + $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName + if ($eventDocumentName) { + $documentName = $eventDocumentName + } + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = $documentName + documentNameOriginal = $documentNameOriginal + owner = $owner + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner + } + + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintJob.Keys)) { + $ts = [datetime]$script:SeenPrintJob[$k] + if ($ts -lt $cleanupBefore) { + $script:SeenPrintJob.Remove($k) + } + } + } + catch { + } + + try { + $printEvents = Get-WinEvent -FilterHashtable @{ + LogName = 'Microsoft-Windows-PrintService/Operational' + Id = 307 + StartTime = (Get-Date).AddMinutes(-20) + } -MaxEvents 200 -ErrorAction SilentlyContinue + + foreach ($event in @($printEvents)) { + $recordId = [string]$event.RecordId + if (-not $recordId) { continue } + if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue } + $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime() + + $summary = Get-PrintServiceEventSummary -Event $event + $documentName = [string]$summary.DocumentName + $owner = [string]$summary.Owner + $printerName = [string]$summary.PrinterName + $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName + + Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument + + if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") { + continue + } + + Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{ + printerName = $printerName + documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } + documentNameOriginal = $documentName + owner = $owner + eventRecordId = $recordId + eventSource = 'printservice-307' + } + Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner + } + + $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenPrintEvent.Keys)) { + $ts = [datetime]$script:SeenPrintEvent[$k] + if ($ts -lt $cleanupBeforeEvent) { + $script:SeenPrintEvent.Remove($k) + } + } + } + catch { + } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} +; } + } + catch { + Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} diff --git a/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 b/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 index d382f41..1c13665 100755 --- a/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 +++ b/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$ServerHost, @@ -151,6 +151,6 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $effectiveRecoveryScript -ConfigPath $effectiveConfigPath Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName -Write-Host 'Укрепление и восстановление ActivityWatch завершены.' -Write-Host "Конфигурация: $effectiveConfigPath" -Write-Host "Пользователи восстановлены: $($effectiveUsers -join ', ')" +Write-Output 'Укрепление и восстановление ActivityWatch завершены.' +Write-Output "Конфигурация: $effectiveConfigPath" +Write-Output "Пользователи восстановлены: $($effectiveUsers -join ', ')" diff --git a/install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1 b/install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1 index eb7cbe4..f91e6eb 100644 --- a/install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1 +++ b/install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding(SupportsShouldProcess = $true)] +[CmdletBinding(SupportsShouldProcess = $true)] param( [string]$OldInstallRoot = 'C:\Program Files\ActivityWatch-Phase2', [string]$OldStateRoot = 'C:\ProgramData\ActivityWatch-Phase2', @@ -154,7 +154,36 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Миграция ActivityWatch W @{ Source = $NewStateRoot; Name = 'new-state' } )) { if (Test-Path -LiteralPath $item.Source) { - Copy-Item -LiteralPath $item.Source -Destination (Join-Path $backupRoot $item.Name) -Recurse -Force + $backupDest = Join-Path $backupRoot $item.Name + New-ActivityWatchDirectory -Path $backupDest + + $excludeDirs = @() + if ($item.Source -eq $NewStateRoot) { + # Avoid infinite recursion: backupRoot is inside NewStateRoot by default. + $excludeDirs += $backupRoot + } + + $robocopyArgs = @( + $item.Source, + $backupDest, + '/E', + '/R:1', + '/W:1', + '/NFL', + '/NDL', + '/NJH', + '/NJS', + '/NP' + ) + if ($excludeDirs.Count -gt 0) { + $robocopyArgs += '/XD' + $robocopyArgs += $excludeDirs + } + + & robocopy @robocopyArgs | Out-Null + if ($LASTEXITCODE -ge 8) { + throw "Backup robocopy failed (exit=$LASTEXITCODE) for source '$($item.Source)' to '$backupDest'" + } } } diff --git a/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 b/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 index 52b35d2..15a4822 100644 --- a/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 +++ b/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json' + [string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json' ) Set-StrictMode -Version Latest @@ -13,49 +13,26 @@ $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' } -$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' } $rulesPath = [string]$config.paths.rulesPath -$policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$config.paths.policyPath } else { Join-Path $stateRoot 'dlp-policy.json' } $launchScript = [string]$config.paths.launchScript $recoveryScript = [string]$config.paths.recoveryScript -$afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true } -$windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true } $requiredFiles = @( + (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe'), + (Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe'), $collectorScript, - $endpointCollectorScript, - $sessionCollectorScript, $rulesPath, - $policyPath, $launchScript, $recoveryScript, $ConfigPath ) -if ($afkExpected) { - $requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe') -} -if ($windowExpected) { - $requiredFiles += (Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe') -} $missingFiles = @( $requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_) } ) -$processNames = @() -if ($afkExpected) { $processNames += 'aw-watcher-afk' } -if ($windowExpected) { $processNames += 'aw-watcher-window' } -$runningProcesses = @() -if ($processNames.Count -gt 0) { - $runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId -} -$sessionCollectorProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { - ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and - $_.CommandLine -match [Regex]::Escape($sessionCollectorScript) - } | - Select-Object Name, ProcessId, SessionId, CommandLine +$processNames = @('aw-watcher-afk', 'aw-watcher-window') +$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId $taskNames = @() if ($config.userTasks) { @@ -65,7 +42,7 @@ $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 + $task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue if ($task) { [pscustomobject]@{ taskName = $task.TaskName @@ -76,7 +53,7 @@ $tasks = foreach ($taskName in $taskNames) { else { [pscustomobject]@{ taskName = $taskName - state = 'Отсутствует' + state = 'Missing' present = $false } } @@ -99,16 +76,8 @@ $result = [ordered]@{ ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present })) } processes = [ordered]@{ - expected = $processNames list = @($runningProcesses) - sessionCollectors = @($sessionCollectorProcesses) - ok = [bool]( - ( - ($processNames.Count -eq 0) -or - (($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge $processNames.Count) - ) -and - ($sessionCollectorProcesses.Count -ge 1) - ) + ok = [bool](($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge 2) } } diff --git a/install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 b/install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 index 226a500..e3021c5 100644 --- a/install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 +++ b/install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 @@ -1,4 +1,44 @@ -param( +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$Hostname, + [int]$PollSeconds = 30 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-Config { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация не найдена: $Path" + } + + Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [Parameter(Mandatory = $true)][string]$ApiBase, + [Parameter(Mandatory = $true)][string]$BucketId, + [Parameter(Mandatory = $true)][string]$HostnameValue + ) + + try { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + return + } + catch { Write-Error param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$Hostname, [int]$PollSeconds = 30 @@ -47,7 +87,12 @@ function Ensure-Bucket { hostname = $HostnameValue } | ConvertTo-Json -Compress - Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body + try { + Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body + } + catch { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + } } function Get-SessionRecords { @@ -159,3 +204,458 @@ while ($true) { Start-Sleep -Seconds $sleepSec } +; } + + $body = @{ + client = 'aw-worktime-session-collector' + type = 'aw.worktime.session' + hostname = $HostnameValue + } | ConvertTo-Json -Compress + + try { + Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body + } + catch { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + } +} + +function Get-SessionRecords { + $records = @() + + try { + $lines = quser 2>$null + if (-not $lines) { + return @() + } + + foreach ($line in ($lines | Select-Object -Skip 1)) { + $clean = ($line -replace '^\s*>?', '').Trim() + if (-not $clean) { + continue + } + + $parts = $clean -split '\s+' + if ($parts.Count -lt 4) { + continue + } + + $sessionName = '' + $sessionIdIndex = 2 + if ($parts[1] -match '^\d+$') { + $sessionIdIndex = 1 + } + else { + $sessionName = $parts[1] + } + + $sessionId = 0 + if ($parts[$sessionIdIndex] -match '^\d+$') { + $sessionId = [int]$parts[$sessionIdIndex] + } + + $records += [pscustomobject]@{ + username = $parts[0] + sessionName = $sessionName + sessionId = $sessionId + state = $parts[$sessionIdIndex + 1] + } + } + } + catch { Write-Error param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$Hostname, + [int]$PollSeconds = 30 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-Config { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация не найдена: $Path" + } + + Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [Parameter(Mandatory = $true)][string]$ApiBase, + [Parameter(Mandatory = $true)][string]$BucketId, + [Parameter(Mandatory = $true)][string]$HostnameValue + ) + + try { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + return + } + catch { + } + + $body = @{ + client = 'aw-worktime-session-collector' + type = 'aw.worktime.session' + hostname = $HostnameValue + } | ConvertTo-Json -Compress + + try { + Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body + } + catch { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + } +} + +function Get-SessionRecords { + $records = @() + + try { + $lines = quser 2>$null + if (-not $lines) { + return @() + } + + foreach ($line in ($lines | Select-Object -Skip 1)) { + $clean = ($line -replace '^\s*>?', '').Trim() + if (-not $clean) { + continue + } + + $parts = $clean -split '\s+' + if ($parts.Count -lt 4) { + continue + } + + $sessionName = '' + $sessionIdIndex = 2 + if ($parts[1] -match '^\d+$') { + $sessionIdIndex = 1 + } + else { + $sessionName = $parts[1] + } + + $sessionId = 0 + if ($parts[$sessionIdIndex] -match '^\d+$') { + $sessionId = [int]$parts[$sessionIdIndex] + } + + $records += [pscustomobject]@{ + username = $parts[0] + sessionName = $sessionName + sessionId = $sessionId + state = $parts[$sessionIdIndex + 1] + } + } + } + catch { + } + + return $records +} + +function Test-SessionIsActive { + param([AllowNull()][string]$State) + if ([string]::IsNullOrWhiteSpace($State)) { return $false } + $s = $State.Trim().ToLowerInvariant() + return ($s -eq 'active') -or ($s -like 'актив*') +} + +$cfg = Get-Config -Path $ConfigPath +$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME } +$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port +$bucketId = 'aw-worktime-sessions_' + $hostValue +$pulse = 120 +$sleepSec = if ($PollSeconds -gt 0) { + $PollSeconds +} +elseif ($cfg.collector -and $cfg.collector.pollSeconds) { + [int]$cfg.collector.pollSeconds +} +else { + 30 +} + +Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue + +while ($true) { + $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $records = Get-SessionRecords + if (-not $records -or $records.Count -eq 0) { + $records = @([pscustomobject]@{ + username = $env:USERNAME + sessionName = '' + sessionId = (Get-Process -Id $PID).SessionId + state = 'Unknown' + }) + } + + foreach ($rec in $records) { + $payload = @{ + timestamp = $now + duration = 0 + data = @{ + username = [string]$rec.username + userId = "$($env:USERDOMAIN)\$($rec.username)" + sessionId = [int]$rec.sessionId + sessionName = [string]$rec.sessionName + state = [string]$rec.state + active = (Test-SessionIsActive -State ([string]$rec.state)) + hostname = $hostValue + source = 'worktime-session-collector' + } + } | ConvertTo-Json -Depth 6 -Compress + + try { + Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload + } + catch { + } + } + + Start-Sleep -Seconds $sleepSec +} +; } + + return $records +} + +function Test-SessionIsActive { + param([AllowNull()][string]$State) + if ([string]::IsNullOrWhiteSpace($State)) { return $false } + $s = $State.Trim().ToLowerInvariant() + return ($s -eq 'active') -or ($s -like 'актив*') +} + +$cfg = Get-Config -Path $ConfigPath +$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME } +$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port +$bucketId = 'aw-worktime-sessions_' + $hostValue +$pulse = 120 +$sleepSec = if ($PollSeconds -gt 0) { + $PollSeconds +} +elseif ($cfg.collector -and $cfg.collector.pollSeconds) { + [int]$cfg.collector.pollSeconds +} +else { + 30 +} + +Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue + +while ($true) { + $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $records = Get-SessionRecords + if (-not $records -or $records.Count -eq 0) { + $records = @([pscustomobject]@{ + username = $env:USERNAME + sessionName = '' + sessionId = (Get-Process -Id $PID).SessionId + state = 'Unknown' + }) + } + + foreach ($rec in $records) { + $payload = @{ + timestamp = $now + duration = 0 + data = @{ + username = [string]$rec.username + userId = "$($env:USERDOMAIN)\$($rec.username)" + sessionId = [int]$rec.sessionId + sessionName = [string]$rec.sessionName + state = [string]$rec.state + active = (Test-SessionIsActive -State ([string]$rec.state)) + hostname = $hostValue + source = 'worktime-session-collector' + } + } | ConvertTo-Json -Depth 6 -Compress + + try { + Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload + } + catch { Write-Error param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$Hostname, + [int]$PollSeconds = 30 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-Config { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Конфигурация не найдена: $Path" + } + + Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [Parameter(Mandatory = $true)][string]$ApiBase, + [Parameter(Mandatory = $true)][string]$BucketId, + [Parameter(Mandatory = $true)][string]$HostnameValue + ) + + try { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + return + } + catch { + } + + $body = @{ + client = 'aw-worktime-session-collector' + type = 'aw.worktime.session' + hostname = $HostnameValue + } | ConvertTo-Json -Compress + + try { + Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body + } + catch { + Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + } +} + +function Get-SessionRecords { + $records = @() + + try { + $lines = quser 2>$null + if (-not $lines) { + return @() + } + + foreach ($line in ($lines | Select-Object -Skip 1)) { + $clean = ($line -replace '^\s*>?', '').Trim() + if (-not $clean) { + continue + } + + $parts = $clean -split '\s+' + if ($parts.Count -lt 4) { + continue + } + + $sessionName = '' + $sessionIdIndex = 2 + if ($parts[1] -match '^\d+$') { + $sessionIdIndex = 1 + } + else { + $sessionName = $parts[1] + } + + $sessionId = 0 + if ($parts[$sessionIdIndex] -match '^\d+$') { + $sessionId = [int]$parts[$sessionIdIndex] + } + + $records += [pscustomobject]@{ + username = $parts[0] + sessionName = $sessionName + sessionId = $sessionId + state = $parts[$sessionIdIndex + 1] + } + } + } + catch { + } + + return $records +} + +function Test-SessionIsActive { + param([AllowNull()][string]$State) + if ([string]::IsNullOrWhiteSpace($State)) { return $false } + $s = $State.Trim().ToLowerInvariant() + return ($s -eq 'active') -or ($s -like 'актив*') +} + +$cfg = Get-Config -Path $ConfigPath +$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME } +$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port +$bucketId = 'aw-worktime-sessions_' + $hostValue +$pulse = 120 +$sleepSec = if ($PollSeconds -gt 0) { + $PollSeconds +} +elseif ($cfg.collector -and $cfg.collector.pollSeconds) { + [int]$cfg.collector.pollSeconds +} +else { + 30 +} + +Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue + +while ($true) { + $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $records = Get-SessionRecords + if (-not $records -or $records.Count -eq 0) { + $records = @([pscustomobject]@{ + username = $env:USERNAME + sessionName = '' + sessionId = (Get-Process -Id $PID).SessionId + state = 'Unknown' + }) + } + + foreach ($rec in $records) { + $payload = @{ + timestamp = $now + duration = 0 + data = @{ + username = [string]$rec.username + userId = "$($env:USERDOMAIN)\$($rec.username)" + sessionId = [int]$rec.sessionId + sessionName = [string]$rec.sessionName + state = [string]$rec.state + active = (Test-SessionIsActive -State ([string]$rec.state)) + hostname = $hostValue + source = 'worktime-session-collector' + } + } | ConvertTo-Json -Depth 6 -Compress + + try { + Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload + } + catch { + } + } + + Start-Sleep -Seconds $sleepSec +} +; } + } + + Start-Sleep -Seconds $sleepSec +}