Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a8f01049e | ||
|
|
6578f6341f | ||
|
|
cc33ffa3dc | ||
|
|
ff548a5840 | ||
|
|
f916764d53 | ||
|
|
2bab84f9f9 | ||
|
|
3e565b7a2e | ||
|
|
3fa15f826d | ||
|
|
08ba731345 | ||
|
|
df497839f6 | ||
|
|
36e4255ad9 | ||
|
|
3971c459ef | ||
|
|
b7a7ac42e4 | ||
|
|
4359f6d5eb | ||
|
|
7ea4ebd463 | ||
|
|
5a4064dc0f | ||
|
|
c97ffe2cbd | ||
|
|
f45ef0038d | ||
|
|
8088b19dc7 | ||
|
|
046aa3ed1d | ||
|
|
b6f019982d | ||
|
|
fae2e2ca14 | ||
|
|
f436950bda | ||
|
|
d7fedde69d | ||
|
|
7f58a49c0a |
@@ -1,11 +1,13 @@
|
||||
# Local secrets
|
||||
secrets/deploy.secrets.env
|
||||
secrets/runtime.env
|
||||
|
||||
# Runtime / reports
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
windows/*.report.json
|
||||
.rollout-logs/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
- `docs/linux-remote-worker.md` — полный Linux remote-worker stack: GUI, SSH/console и browser admin UI вроде Proxmox `:8006`.
|
||||
- `docs/console-ssh-logger.md` — логирование только консольных команд и SSH-сессий в AW.
|
||||
- `docs/dlp-gap-analysis.md` — разрыв до enterprise DLP и roadmap.
|
||||
- `docs/dlp-aggregator.md` — прототип централизованной агрегации DLP/file-operation событий.
|
||||
- `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox.
|
||||
- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI.
|
||||
- `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT).
|
||||
- `pfsense/` — внешний poller для pfSense API и systemd unit под Debian/Ubuntu utility VM.
|
||||
- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, Windows/RDP DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`) и session-level presence для удалённых Windows/RDP пользователей (`aw-worktime-sessions_*`).
|
||||
- `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок.
|
||||
- `scripts/aggregate_dlp_events.py` — сбор `aw-file-operations_*` и `aw-dlp-incidents_*` в SQLite/PostgreSQL.
|
||||
- `scripts/install_aw_linux_client.sh` — установка Linux bundle + autostart для remote AW server.
|
||||
- `scripts/install_aw_console_ssh_logger.sh` — user-space установка console/ssh logger.
|
||||
- `scripts/install_aw_linux_web_category_logger.sh` — user-space классификация browser admin UI по title/class.
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
+478
-154
@@ -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,78 +236,328 @@
|
||||
- Перезагрузить 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-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
|
||||
- name: (Check mode) Пропустить WebUI patch и запуск сервиса
|
||||
ansible.builtin.debug:
|
||||
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-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: '<script[^>]+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js)[^>]*></script>'
|
||||
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-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: '</head>'
|
||||
replace: '<script src="/js/sw-cleanup.js?v={{ aw_sw_cleanup_cache_bust }}"></script></head>'
|
||||
- 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: '</body>'
|
||||
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
|
||||
- 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 }}
|
||||
XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share
|
||||
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
|
||||
|
||||
- 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_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 }}
|
||||
- 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.systemd:
|
||||
name: activitywatch-server.service
|
||||
enabled: true
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
- name: Вывести результат применения хотфиксов
|
||||
ansible.builtin.debug:
|
||||
msg: "apply_webui_ru_patch.sh: {{ apply_ru_patch_result.stdout }}"
|
||||
|
||||
- name: Дождаться ответа API
|
||||
- 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: '<script[^>]+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js)[^>]*></script>'
|
||||
replace: ''
|
||||
|
||||
- name: Добавить cleanup script RU patch в index.html
|
||||
ansible.builtin.replace:
|
||||
path: "{{ aw_server_webui_dir }}/index.html"
|
||||
regexp: '</head>'
|
||||
replace: '<script src="/js/sw-cleanup.js?v={{ aw_sw_cleanup_cache_bust }}"></script></head>'
|
||||
|
||||
- name: Добавить загрузчик RU patch перед закрытием body
|
||||
ansible.builtin.replace:
|
||||
path: "{{ aw_server_webui_dir }}/index.html"
|
||||
regexp: '</body>'
|
||||
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
|
||||
|
||||
- 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:
|
||||
@@ -277,16 +579,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
|
||||
)
|
||||
}}
|
||||
@@ -314,20 +612,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:
|
||||
|
||||
@@ -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
|
||||
@@ -77,6 +78,7 @@
|
||||
- ActivityWatch.Windows.Common.psm1
|
||||
- browser-domains-native-collector.ps1
|
||||
- dlp-endpoint-signals-collector.ps1
|
||||
- file-operations-collector.ps1
|
||||
- worktime-session-collector.ps1
|
||||
- migrate-awatch-rus-paths.ps1
|
||||
- deploy-domain-users.ps1
|
||||
@@ -86,6 +88,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"
|
||||
@@ -130,6 +144,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' }}
|
||||
@@ -149,6 +164,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:
|
||||
@@ -171,62 +199,55 @@
|
||||
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: >-
|
||||
{{
|
||||
aw_windows_api_smoke_check_bucket
|
||||
if (aw_windows_api_smoke_check_bucket | default('') | string | length) > 0
|
||||
else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim)
|
||||
}}
|
||||
aw_windows_api_smoke_check_bucket_effective: "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
|
||||
|
||||
@@ -4,15 +4,24 @@ 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"
|
||||
|
||||
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 задаёт границу дня и стартовое время окна отчёта.
|
||||
@@ -22,3 +31,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/"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
aw_server_version: "v0.13.2"
|
||||
aw_server_download_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-linux-x86_64.zip"
|
||||
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"
|
||||
|
||||
aw_repo_root: "{{ playbook_dir | dirname }}"
|
||||
|
||||
# Optional: apply worktime settings via server-side settings API.
|
||||
aw_apply_worktime_settings: true
|
||||
|
||||
aw_server_cors_origins:
|
||||
- "http://127.0.0.1:5600"
|
||||
- "http://localhost:5600"
|
||||
- "http://10.10.10.13:5600"
|
||||
- "http://aw-server:5600"
|
||||
|
||||
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/"
|
||||
@@ -0,0 +1,9 @@
|
||||
# Secret handling:
|
||||
# - put the real SSH password into env var before running Ansible:
|
||||
# export AW_SSH_PASSWORD='...'
|
||||
ansible_password: "{{ lookup('env', 'AW_SSH_PASSWORD') }}"
|
||||
|
||||
ansible_become: true
|
||||
ansible_become_method: sudo
|
||||
# If sudo password differs, set AW_SUDO_PASSWORD. Otherwise it will reuse AW_SSH_PASSWORD.
|
||||
ansible_become_password: "{{ lookup('env', 'AW_SUDO_PASSWORD') | default(lookup('env', 'AW_SSH_PASSWORD'), true) }}"
|
||||
@@ -0,0 +1,53 @@
|
||||
# Secret handling:
|
||||
# - put the real password into env var before running Ansible:
|
||||
# export AW_WINRM_PASSWORD='...'
|
||||
ansible_password: "{{ lookup('env', 'AW_WINRM_PASSWORD') }}"
|
||||
|
||||
aw_windows_repo_root: "{{ playbook_dir | dirname }}"
|
||||
aw_windows_deploy_root: "C:\\Program Files\\AWatch-rus"
|
||||
aw_windows_server_scheme: "http"
|
||||
aw_windows_server_host: "10.10.10.13"
|
||||
aw_windows_server_port: 5600
|
||||
|
||||
aw_windows_package_version: "v0.13.2"
|
||||
aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip"
|
||||
aw_windows_package_zip_path: ""
|
||||
|
||||
aw_windows_domain: "SHARKON2025"
|
||||
aw_windows_users:
|
||||
- user1
|
||||
- user2
|
||||
- user3
|
||||
- user4
|
||||
- user5
|
||||
aw_windows_extra_users: []
|
||||
|
||||
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
|
||||
aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts"
|
||||
aw_windows_logon_marker_enabled: true
|
||||
aw_windows_skip_hardening: false
|
||||
|
||||
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
|
||||
aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
|
||||
|
||||
aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ansible.json"
|
||||
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
|
||||
aw_windows_fail_on_validation_error: true
|
||||
|
||||
aw_windows_migration_enabled: true
|
||||
aw_windows_legacy_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
|
||||
aw_windows_legacy_state_root: "C:\\ProgramData\\ActivityWatch-Phase2"
|
||||
aw_windows_migration_report_remote_path: "{{ aw_windows_state_root }}\\aw_migration_ansible.json"
|
||||
|
||||
aw_windows_api_smoke_check_enabled: true
|
||||
aw_windows_api_smoke_check_bucket: ""
|
||||
aw_windows_api_smoke_check_limit: 10
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[proxmox]
|
||||
# Optional. Leave empty if you don't use Proxmox provisioning from this repo.
|
||||
# pve-main ansible_host=10.10.10.2 ansible_user=igor ansible_port=22
|
||||
|
||||
[aw_server]
|
||||
aw-server ansible_host=10.10.10.13 ansible_user=igor ansible_port=22
|
||||
|
||||
[aw_windows]
|
||||
# Note: on RU-localized Windows the built-in admin account name is often "Администратор".
|
||||
rdp-prod ansible_host=192.168.100.21 ansible_user=Администратор ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
|
||||
|
||||
[aw_pfsense_pollers]
|
||||
# Optional.
|
||||
# pfsense-poller1 ansible_host=192.168.100.30 ansible_user=root ansible_port=22
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
- name: Post-deploy validation for Windows/RDP AWatch-rus
|
||||
hosts: aw_windows
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
aw_windows_launch_task_pattern: "ActivityWatch Launch *"
|
||||
aw_windows_recovery_task_name: "ActivityWatch Recovery"
|
||||
aw_windows_force_task_restart: true
|
||||
aw_windows_api_smoke_check_enabled: true
|
||||
aw_windows_api_smoke_check_bucket: ""
|
||||
aw_windows_api_smoke_check_limit: 10
|
||||
|
||||
tasks:
|
||||
- name: Принудительно запустить ActivityWatch recovery и launch tasks
|
||||
when: aw_windows_force_task_restart | bool
|
||||
ansible.windows.win_powershell:
|
||||
script: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}"
|
||||
Get-ScheduledTask |
|
||||
Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" |
|
||||
ForEach-Object { Start-ScheduledTask -TaskName $_.TaskName }
|
||||
|
||||
- name: Получить Windows hostname для AW smoke-check bucket
|
||||
when: aw_windows_api_smoke_check_enabled | bool
|
||||
ansible.windows.win_command: powershell.exe -NoProfile -Command "$env:COMPUTERNAME"
|
||||
register: aw_windows_hostname_result
|
||||
changed_when: false
|
||||
|
||||
- name: Вычислить AW AFK smoke-check bucket
|
||||
when: aw_windows_api_smoke_check_enabled | bool
|
||||
ansible.builtin.set_fact:
|
||||
aw_windows_api_smoke_check_bucket_effective: >-
|
||||
{{
|
||||
aw_windows_api_smoke_check_bucket
|
||||
if (aw_windows_api_smoke_check_bucket | default('') | string | length) > 0
|
||||
else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim)
|
||||
}}
|
||||
|
||||
- name: Дождаться свежих AFK событий на AW server
|
||||
when: aw_windows_api_smoke_check_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
|
||||
|
||||
- name: Выполнить валидацию и сохранить отчёт на целевом Windows host
|
||||
ansible.windows.win_powershell:
|
||||
script: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$report = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
|
||||
-ConfigPath "{{ aw_windows_state_root }}\deployment-config.json"
|
||||
$report | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
|
||||
if ({{ '$true' if (aw_windows_fail_on_validation_error | bool) else '$false' }} -and -not [bool]$report.overallOk) {
|
||||
throw "ActivityWatch validation failed. Report: {{ aw_windows_validation_remote_path }}"
|
||||
}
|
||||
|
||||
- name: Создать локальный каталог для validation reports
|
||||
ansible.builtin.file:
|
||||
path: "{{ aw_windows_validation_local_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
delegate_to: localhost
|
||||
|
||||
- name: Забрать validation report
|
||||
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:
|
||||
- "Validation OK on {{ inventory_hostname }}."
|
||||
- "Report: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -24,7 +24,7 @@ TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
|
||||
TIMESPIRAL_NEEDLE='start:new Date("2022-08-08")'
|
||||
TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
|
||||
CATEGORY_HELPER_NEEDLE='hostname:t.hostnameChoices[0]'
|
||||
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t}))[0]||t.hostnameChoices[0]'
|
||||
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t&&"undefined"!==t}))[0]||t.hostnameChoices[0]'
|
||||
|
||||
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
|
||||
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
|
||||
|
||||
@@ -1541,14 +1541,28 @@
|
||||
|
||||
function rewriteUnknownCategoryBuilderQueryBody(body) {
|
||||
if (typeof body !== "string") return body;
|
||||
if (body.indexOf("aw-watcher-window_unknown") === -1 && body.indexOf("aw-watcher-afk_unknown") === -1) {
|
||||
return body;
|
||||
if (body.indexOf("undefined") !== -1) {
|
||||
body = body
|
||||
.replace(/flood\(query_bucket\(find_bucket\(\\"undefined\\"\)\)\)/g, '[]')
|
||||
.replace(/query_bucket\(find_bucket\(\\"undefined\\"\)\)/g, '[]')
|
||||
.replace(/flood\(query_bucket\(\\"undefined\\"\)\)/g, '[]')
|
||||
.replace(/query_bucket\(\\"undefined\\"\)/g, '[]');
|
||||
const ph = getPreferredWindowHostFromBuckets();
|
||||
if (ph) {
|
||||
body = body
|
||||
.replace(/aw-watcher-window_undefined/g, "aw-watcher-window_" + ph)
|
||||
.replace(/aw-watcher-afk_undefined/g, "aw-watcher-afk_" + ph);
|
||||
}
|
||||
}
|
||||
const preferredHost = getPreferredWindowHostFromBuckets();
|
||||
if (!preferredHost) return body;
|
||||
return body
|
||||
.replace(/aw-watcher-window_unknown/g, "aw-watcher-window_" + preferredHost)
|
||||
.replace(/aw-watcher-afk_unknown/g, "aw-watcher-afk_" + preferredHost);
|
||||
if (body.indexOf("aw-watcher-window_unknown") !== -1 || body.indexOf("aw-watcher-afk_unknown") !== -1) {
|
||||
const preferredHost = getPreferredWindowHostFromBuckets();
|
||||
if (preferredHost) {
|
||||
body = body
|
||||
.replace(/aw-watcher-window_unknown/g, "aw-watcher-window_" + preferredHost)
|
||||
.replace(/aw-watcher-afk_unknown/g, "aw-watcher-afk_" + preferredHost);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function installCategoryBuilderNetworkPatch() {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Central DLP aggregator prototype
|
||||
|
||||
`scripts/aggregate_dlp_events.py` collects Phase 2 DLP telemetry from ActivityWatch buckets and stores normalized rows in one database for Grafana/SIEM-style reporting.
|
||||
|
||||
## Streams
|
||||
|
||||
The prototype reads:
|
||||
|
||||
- `aw-file-operations_*` (`aw.file.operation`) — file create/delete/rename telemetry, including `archiveHint`.
|
||||
- `aw-dlp-incidents_*` (`aw.dlp.incident`) — browser/endpoint DLP incidents and screenshot metadata when available.
|
||||
|
||||
## SQLite smoke test
|
||||
|
||||
SQLite is the default so the collector can be tested without deploying PostgreSQL:
|
||||
|
||||
```bash
|
||||
python3 scripts/aggregate_dlp_events.py \
|
||||
--aw-url http://10.10.10.13:5600/api/0 \
|
||||
--sqlite-path data/dlp-events.sqlite3 \
|
||||
--lookback-hours 24
|
||||
```
|
||||
|
||||
Useful checks:
|
||||
|
||||
```bash
|
||||
sqlite3 data/dlp-events.sqlite3 \
|
||||
"select stream_type, hostname, count(*) from dlp_events group by 1,2 order by 3 desc;"
|
||||
|
||||
sqlite3 data/dlp-events.sqlite3 \
|
||||
"select event_ts, hostname, username, file_path from dlp_file_operations where archive_hint = 1 order by event_ts desc limit 20;"
|
||||
```
|
||||
|
||||
## PostgreSQL mode
|
||||
|
||||
For centralized reporting, pass a DSN through an environment variable instead of committing secrets:
|
||||
|
||||
```bash
|
||||
export DLP_AGGREGATOR_POSTGRES_DSN='postgresql://aw_dlp:${PASSWORD}@postgres.internal:5432/aw_dlp'
|
||||
python3 -m pip install 'psycopg[binary]'
|
||||
python3 scripts/aggregate_dlp_events.py \
|
||||
--aw-url http://10.10.10.13:5600/api/0
|
||||
```
|
||||
|
||||
Minimum database bootstrap:
|
||||
|
||||
```sql
|
||||
create database aw_dlp;
|
||||
create user aw_dlp_ingest with password '<strong generated password>';
|
||||
grant connect on database aw_dlp to aw_dlp_ingest;
|
||||
grant usage, create on schema public to aw_dlp_ingest;
|
||||
```
|
||||
|
||||
The script creates:
|
||||
|
||||
- table `dlp_events`
|
||||
- view `dlp_file_operations`
|
||||
- view `dlp_incidents`
|
||||
|
||||
## Incremental state
|
||||
|
||||
By default, the aggregator stores the last successful end timestamp in:
|
||||
|
||||
```text
|
||||
data/dlp-aggregator-state.json
|
||||
```
|
||||
|
||||
Future runs resume from that timestamp with a small overlap window to avoid missing late events. Duplicate inserts are ignored by `(bucket_id, event_id)`.
|
||||
|
||||
## Scheduling example
|
||||
|
||||
Cron every minute:
|
||||
|
||||
```cron
|
||||
* * * * * cd /opt/AWatch-rus && /usr/bin/python3 scripts/aggregate_dlp_events.py --aw-url http://10.10.10.13:5600/api/0 >> /var/log/aw-dlp-aggregator.log 2>&1
|
||||
```
|
||||
|
||||
## Example Grafana queries
|
||||
|
||||
Archive creation by user:
|
||||
|
||||
```sql
|
||||
select
|
||||
date_trunc('minute', event_ts) as time,
|
||||
hostname,
|
||||
username,
|
||||
count(*) as archives
|
||||
from dlp_file_operations
|
||||
where archive_hint = true
|
||||
group by 1, 2, 3
|
||||
order by 1 desc;
|
||||
```
|
||||
|
||||
DLP incidents by severity:
|
||||
|
||||
```sql
|
||||
select
|
||||
date_trunc('hour', event_ts) as time,
|
||||
severity,
|
||||
count(*) as incidents
|
||||
from dlp_incidents
|
||||
group by 1, 2
|
||||
order by 1 desc;
|
||||
```
|
||||
@@ -0,0 +1,126 @@
|
||||
# DLP Enforcement (action: "block")
|
||||
|
||||
## Обзор
|
||||
|
||||
Phase 2.5 расширяет DLP endpoint collector функциями **активного предотвращения** (enforcement).
|
||||
При `action: "block"` в правиле DLP-политики коллектор не только регистрирует инцидент, но и выполняет блокирующее действие:
|
||||
|
||||
| Канал | Действие при `block` |
|
||||
|-----------|-----------------------------------------------------------|
|
||||
| clipboard | Очистка буфера обмена (`Set-Clipboard -Value $null`) |
|
||||
| usb | Перевод USB-диска в read-only (`Set-Disk -IsReadOnly`) |
|
||||
| print | Отмена задания печати (`Remove-CimInstance Win32_PrintJob`)|
|
||||
|
||||
Во всех случаях пользователь получает Windows-уведомление (balloon notification) с описанием причины блокировки.
|
||||
|
||||
## Конфигурация политики
|
||||
|
||||
Формат `dlp-policy.json` не изменился — поле `action` в правиле теперь поддерживает значение `"block"` наряду с `"alert"` (по умолчанию).
|
||||
|
||||
### Пример: блокировка USB записи
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"enabled": true,
|
||||
"action": "alert",
|
||||
"severity": "medium",
|
||||
"cooldownSeconds": 300
|
||||
},
|
||||
"endpoint": {
|
||||
"usb": [
|
||||
{
|
||||
"id": "block-all-usb-write",
|
||||
"action": "block",
|
||||
"severity": "high",
|
||||
"message": "Запись на USB-носитель заблокирована политикой DLP"
|
||||
}
|
||||
],
|
||||
"clipboard": [
|
||||
{
|
||||
"id": "block-pdn-clipboard",
|
||||
"action": "block",
|
||||
"severity": "high",
|
||||
"regexPatterns": [
|
||||
"\\b\\d{3}-\\d{3}-\\d{3}\\s?\\d{2}\\b",
|
||||
"\\b\\d{4}\\s?\\d{6}\\b"
|
||||
],
|
||||
"minLength": 8,
|
||||
"message": "Буфер обмена очищен: обнаружены персональные данные (СНИЛС/паспорт)"
|
||||
}
|
||||
],
|
||||
"print": [
|
||||
{
|
||||
"id": "block-confidential-print",
|
||||
"action": "block",
|
||||
"severity": "high",
|
||||
"documentRegex": "(?i)(конфиденциально|секретно|confidential|restricted)",
|
||||
"message": "Печать заблокирована: документ содержит метку конфиденциальности"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Пример: только мониторинг (без блокировки)
|
||||
|
||||
```json
|
||||
{
|
||||
"endpoint": {
|
||||
"usb": [
|
||||
{
|
||||
"id": "monitor-usb",
|
||||
"action": "alert",
|
||||
"severity": "medium",
|
||||
"message": "Обнаружено подключение USB-носителя"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Телеметрия
|
||||
|
||||
Каждый инцидент с enforcement записывается в bucket `aw-dlp-incidents_<host>` с дополнительным полем:
|
||||
|
||||
```json
|
||||
{
|
||||
"ruleId": "block-all-usb-write",
|
||||
"action": "block",
|
||||
"severity": "high",
|
||||
"signalType": "usb_insert",
|
||||
"enforced": true,
|
||||
"driveLetter": "E:",
|
||||
"volumeName": "FLASH_DRIVE"
|
||||
}
|
||||
```
|
||||
|
||||
- `enforced: true` — блокировка выполнена успешно
|
||||
- `enforced: false` — блокировка не удалась (недостаточно прав, устройство недоступно и т.д.)
|
||||
|
||||
## Требования
|
||||
|
||||
- **Clipboard block**: Не требует повышенных прав.
|
||||
- **USB write-block**: Требует запуск от имени администратора (для `Set-Disk -IsReadOnly`). При запуске без прав блокировка не сработает, но инцидент будет зарегистрирован с `enforced: false`.
|
||||
- **Print block**: Требует права на отмену заданий печати (обычно — SYSTEM или администратор принт-сервера).
|
||||
|
||||
## Уведомления
|
||||
|
||||
При каждой блокировке пользователю показывается Windows balloon notification:
|
||||
|
||||
| Канал | Заголовок |
|
||||
|-----------|--------------------------------------|
|
||||
| clipboard | `DLP: буфер обмена очищен` |
|
||||
| usb | `DLP: USB заблокирован для записи` |
|
||||
| print | `DLP: печать заблокирована` |
|
||||
|
||||
Текст уведомления берётся из поля `message` правила политики.
|
||||
|
||||
## Rollback
|
||||
|
||||
Для отключения enforcement без изменения кода — смените `action` с `"block"` на `"alert"` в `dlp-policy.json`. Все правила продолжат мониторинг без блокировки.
|
||||
|
||||
Для USB, переведённого в read-only, восстановление:
|
||||
```powershell
|
||||
Get-Disk | Where-Object { $_.BusType -eq 'USB' -and $_.IsReadOnly } | Set-Disk -IsReadOnly $false
|
||||
```
|
||||
@@ -29,8 +29,26 @@
|
||||
|
||||
- USB/print/clipboard collectors (endpoint signals) — внедрено.
|
||||
- Incident pipeline расширен на endpoint события — внедрено.
|
||||
- File-operation telemetry (create/copy/archive/upload hints) — в backlog.
|
||||
- Central incident aggregation/export — в backlog.
|
||||
- File-operation telemetry (create/delete/rename/archive hints) — прототип внедрён (`windows/file-operations-collector.ps1`).
|
||||
- Central incident aggregation/export — прототип внедрён (`scripts/aggregate_dlp_events.py`, `docs/dlp-aggregator.md`).
|
||||
|
||||
### Phase 2.5 — Enforcement (внедрено)
|
||||
|
||||
- USB write-block (`Set-Disk -IsReadOnly`) при `action: "block"` — внедрено.
|
||||
- Print job cancel (`Remove-CimInstance Win32_PrintJob`) при `action: "block"` — внедрено.
|
||||
- Clipboard clear (`Set-Clipboard -Value $null`) при `action: "block"` — внедрено.
|
||||
- Windows balloon notification пользователю при блокировке — внедрено.
|
||||
- Телеметрия enforcement (`enforced: true/false` в incident heartbeat) — внедрено.
|
||||
- Документация: `docs/dlp-enforcement.md`.
|
||||
|
||||
### Phase 2.5 — Email Outbound Collector (внедрено)
|
||||
|
||||
- Мониторинг исходящей почты через Outlook COM (Sent Items polling) — внедрено.
|
||||
- SMTP network connection detection (порты 25/587/465/2525) — внедрено.
|
||||
- DLP-правила `endpoint.email[]` (regex по теме, получателям, вложениям, externalOnly) — внедрено.
|
||||
- Enforcement: перемещение в Drafts при `action: "block"` (Outlook mode) — внедрено.
|
||||
- Приватность: тема/получатели как SHA256, тело не читается — внедрено.
|
||||
- Документация: `docs/email-outbound-collector.md`.
|
||||
|
||||
### Phase 3
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Email Outbound Collector
|
||||
|
||||
## Обзор
|
||||
|
||||
Мониторинг исходящей почты на Windows-эндпоинтах. Два режима работы:
|
||||
|
||||
| Режим | Источник | Данные |
|
||||
|----------|--------------------------------|-------------------------------------------------------|
|
||||
| outlook | Outlook COM (Sent Items) | Subject, From, To/CC, вложения, размер тела |
|
||||
| smtp | `Get-NetTCPConnection` | SMTP-соединения (порты 25/587/465/2525), процесс |
|
||||
|
||||
По умолчанию `Mode = 'both'` — оба режима активны одновременно.
|
||||
|
||||
## Запуск
|
||||
|
||||
```powershell
|
||||
# С deployment-config.json (штатный вариант)
|
||||
.\email-outbound-collector.ps1
|
||||
|
||||
# С явными параметрами
|
||||
.\email-outbound-collector.ps1 -ServerHost 10.10.10.13 -ServerPort 5600 -Mode outlook
|
||||
|
||||
# Только SMTP мониторинг (без Outlook)
|
||||
.\email-outbound-collector.ps1 -ServerHost 10.10.10.13 -Mode smtp
|
||||
```
|
||||
|
||||
### Параметры
|
||||
|
||||
| Параметр | По умолчанию | Описание |
|
||||
|----------------|-----------------------------------------|---------------------------------|
|
||||
| `-ConfigPath` | `C:\ProgramData\ActivityWatch\deployment-config.json` | Путь к конфигу |
|
||||
| `-ServerHost` | из конфига | Адрес AW-сервера |
|
||||
| `-ServerPort` | из конфига / 5600 | Порт AW-сервера |
|
||||
| `-PolicyPath` | из конфига / `dlp-policy.json` | Путь к DLP-политике |
|
||||
| `-Mode` | `both` | `outlook`, `smtp`, или `both` |
|
||||
| `-PollSeconds` | из конфига / 10 | Интервал опроса |
|
||||
|
||||
## AW Buckets
|
||||
|
||||
- `aw-email-monitor_<host>` — все email-события (signal heartbeats)
|
||||
- `aw-dlp-incidents_<host>` — инциденты при срабатывании DLP-правил
|
||||
|
||||
## DLP-политика: секция `endpoint.email`
|
||||
|
||||
Добавляется в существующий `dlp-policy.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"endpoint": {
|
||||
"email": [
|
||||
{
|
||||
"id": "block-external-attachments",
|
||||
"action": "block",
|
||||
"severity": "high",
|
||||
"minAttachments": 1,
|
||||
"externalOnly": true,
|
||||
"internalDomain": "@company.ru",
|
||||
"message": "Запрещена отправка вложений на внешние адреса"
|
||||
},
|
||||
{
|
||||
"id": "alert-confidential-subject",
|
||||
"action": "alert",
|
||||
"severity": "medium",
|
||||
"subjectRegex": "(?i)(конфиденциально|секретно|для служебного пользования)",
|
||||
"message": "Обнаружена отправка письма с пометкой конфиденциальности"
|
||||
},
|
||||
{
|
||||
"id": "alert-personal-data",
|
||||
"action": "alert",
|
||||
"severity": "high",
|
||||
"recipientRegex": "(?i)(gmail\\.com|mail\\.ru|yandex\\.ru|yahoo\\.com)",
|
||||
"minAttachments": 1,
|
||||
"message": "Отправка вложений на личную почту"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Параметры правил
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|-------------------|--------|-----------------------------------------------------------|
|
||||
| `id` | string | Уникальный ID правила (обязательно) |
|
||||
| `action` | string | `alert` (по умолчанию) или `block` |
|
||||
| `severity` | string | `low`, `medium`, `high`, `critical` |
|
||||
| `subjectRegex` | string | Regex по теме письма |
|
||||
| `recipientRegex` | string | Regex по списку получателей |
|
||||
| `senderRegex` | string | Regex по адресу отправителя |
|
||||
| `attachmentRegex` | string | Regex по именам вложений |
|
||||
| `minAttachments` | int | Минимальное количество вложений для срабатывания |
|
||||
| `minBodyLength` | int | Минимальная длина тела письма |
|
||||
| `externalOnly` | bool | Срабатывать только на внешних получателей |
|
||||
| `internalDomain` | string | Домен организации (используется с `externalOnly`) |
|
||||
| `cooldownSeconds` | int | Cooldown между повторными инцидентами |
|
||||
| `message` | string | Текст уведомления пользователю и в инцидент |
|
||||
|
||||
## Enforcement (action: "block")
|
||||
|
||||
**Outlook mode**: письмо перемещается из Sent Items в Drafts. Пользователь получает balloon notification.
|
||||
|
||||
**SMTP mode**: только уведомление (перехват SMTP-соединения на сетевом уровне не реализуем из PowerShell). Инцидент записывается с `enforced: false`.
|
||||
|
||||
## Телеметрия
|
||||
|
||||
### Heartbeat `email_sent` (Outlook mode)
|
||||
```json
|
||||
{
|
||||
"signalType": "email_sent",
|
||||
"subject": "<sha256 hash>",
|
||||
"sender": "user@company.ru",
|
||||
"recipientCount": 3,
|
||||
"recipients": "<sha256 hash>",
|
||||
"attachmentCount": 2,
|
||||
"attachmentNames": "report.xlsx; data.csv",
|
||||
"bodyLength": 1520,
|
||||
"collectionMode": "outlook"
|
||||
}
|
||||
```
|
||||
|
||||
### Heartbeat `smtp_connection` (SMTP mode)
|
||||
```json
|
||||
{
|
||||
"signalType": "smtp_connection",
|
||||
"remoteAddress": "74.125.205.108",
|
||||
"remotePort": 587,
|
||||
"processId": 12340,
|
||||
"processName": "OUTLOOK",
|
||||
"collectionMode": "smtp"
|
||||
}
|
||||
```
|
||||
|
||||
### Incident
|
||||
```json
|
||||
{
|
||||
"ruleId": "block-external-attachments",
|
||||
"action": "block",
|
||||
"severity": "high",
|
||||
"signalType": "email_outbound",
|
||||
"subject": "<sha256>",
|
||||
"attachmentCount": 2,
|
||||
"enforced": true
|
||||
}
|
||||
```
|
||||
|
||||
## Приватность
|
||||
|
||||
- Тема и получатели записываются как SHA256-хеш (не открытый текст).
|
||||
- Тело письма не читается и не хранится — записывается только длина.
|
||||
- Имена вложений записываются открытым текстом (для DLP-анализа).
|
||||
|
||||
## Интеграция в ensemble
|
||||
|
||||
Добавьте в `launch-watchers.ps1` или Task Scheduler:
|
||||
|
||||
```powershell
|
||||
Start-Process powershell.exe -ArgumentList '-ExecutionPolicy Bypass -File "C:\ProgramData\ActivityWatch\email-outbound-collector.ps1"' -WindowStyle Hidden
|
||||
```
|
||||
|
||||
## Требования
|
||||
|
||||
- **Outlook mode**: Microsoft Outlook установлен и настроен для текущего пользователя.
|
||||
- **SMTP mode**: Не требует дополнительного ПО. Работает на уровне TCP-соединений.
|
||||
- **Enforcement (block)**: Outlook mode — требует доступ к COM объекту Outlook.
|
||||
@@ -196,6 +196,56 @@
|
||||
- { 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-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-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 }}
|
||||
XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share
|
||||
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
|
||||
|
||||
- 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"
|
||||
@@ -225,21 +275,6 @@
|
||||
regexp: '</body>'
|
||||
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
|
||||
|
||||
- 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_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 }}
|
||||
|
||||
- name: Включить и запустить сервис
|
||||
ansible.builtin.systemd:
|
||||
name: activitywatch-server.service
|
||||
|
||||
@@ -24,7 +24,7 @@ TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
|
||||
TIMESPIRAL_NEEDLE='start:new Date("2022-08-08")'
|
||||
TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
|
||||
CATEGORY_HELPER_NEEDLE='hostname:t.hostnameChoices[0]'
|
||||
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t}))[0]||t.hostnameChoices[0]'
|
||||
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t&&"undefined"!==t}))[0]||t.hostnameChoices[0]'
|
||||
|
||||
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
|
||||
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
|
||||
|
||||
@@ -1500,14 +1500,28 @@
|
||||
|
||||
function rewriteUnknownCategoryBuilderQueryBody(body) {
|
||||
if (typeof body !== "string") return body;
|
||||
if (body.indexOf("aw-watcher-window_unknown") === -1 && body.indexOf("aw-watcher-afk_unknown") === -1) {
|
||||
return body;
|
||||
if (body.indexOf("undefined") !== -1) {
|
||||
body = body
|
||||
.replace(/flood\(query_bucket\(find_bucket\(\\"undefined\\"\)\)\)/g, '[]')
|
||||
.replace(/query_bucket\(find_bucket\(\\"undefined\\"\)\)/g, '[]')
|
||||
.replace(/flood\(query_bucket\(\\"undefined\\"\)\)/g, '[]')
|
||||
.replace(/query_bucket\(\\"undefined\\"\)/g, '[]');
|
||||
const ph = getPreferredWindowHostFromBuckets();
|
||||
if (ph) {
|
||||
body = body
|
||||
.replace(/aw-watcher-window_undefined/g, "aw-watcher-window_" + ph)
|
||||
.replace(/aw-watcher-afk_undefined/g, "aw-watcher-afk_" + ph);
|
||||
}
|
||||
}
|
||||
const preferredHost = getPreferredWindowHostFromBuckets();
|
||||
if (!preferredHost) return body;
|
||||
return body
|
||||
.replace(/aw-watcher-window_unknown/g, "aw-watcher-window_" + preferredHost)
|
||||
.replace(/aw-watcher-afk_unknown/g, "aw-watcher-afk_" + preferredHost);
|
||||
if (body.indexOf("aw-watcher-window_unknown") !== -1 || body.indexOf("aw-watcher-afk_unknown") !== -1) {
|
||||
const preferredHost = getPreferredWindowHostFromBuckets();
|
||||
if (preferredHost) {
|
||||
body = body
|
||||
.replace(/aw-watcher-window_unknown/g, "aw-watcher-window_" + preferredHost)
|
||||
.replace(/aw-watcher-afk_unknown/g, "aw-watcher-afk_" + preferredHost);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function installCategoryBuilderNetworkPatch() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
|
||||
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
|
||||
[string]$ServerHost,
|
||||
[int]$ServerPort,
|
||||
[ValidateSet('http', 'https')]
|
||||
@@ -81,7 +81,7 @@ function Send-EndpointSignalHeartbeat {
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'endpoint-signals-awatch-rus'
|
||||
source = 'endpoint-signals-phase2'
|
||||
} + $Data
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
|
||||
@@ -122,7 +122,7 @@ function Send-DlpIncidentHeartbeat {
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'endpoint-signals-awatch-rus'
|
||||
source = 'endpoint-signals-phase2'
|
||||
} + $Data + $captureData
|
||||
} | ConvertTo-Json -Depth 7 -Compress
|
||||
|
||||
@@ -210,11 +210,104 @@ function Capture-IncidentScreenshot {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message)
|
||||
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 }
|
||||
@@ -246,7 +339,7 @@ function Load-DlpPolicy {
|
||||
}
|
||||
|
||||
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
|
||||
Write-EndpointLog ("DLP-политика не найдена, используются значения по умолчанию: {0}" -f $Path)
|
||||
Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -266,7 +359,7 @@ function Load-DlpPolicy {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message)
|
||||
Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,13 +412,20 @@ function Evaluate-ClipboardRules {
|
||||
|
||||
$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 { "Сработало правило буфера обмена: $ruleId" }
|
||||
$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 ("инцидент буфера обмена правило={0} действие={1} важность={2}" -f $ruleId, $action, $severity)
|
||||
Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,13 +447,20 @@ function Evaluate-UsbRules {
|
||||
|
||||
$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-носителя: $ruleId" }
|
||||
$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 ("инцидент USB правило={0} действие={1} важность={2} диск={3}" -f $ruleId, $action, $severity, $DriveLetter)
|
||||
Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,14 +492,21 @@ function Evaluate-PrintRules {
|
||||
|
||||
$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 { "Сработало правило печати: $ruleId" }
|
||||
$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 ("инцидент печати правило={0} действие={1} важность={2} принтер={3}" -f $ruleId, $action, $severity, $PrinterName)
|
||||
Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,52 +516,6 @@ function Test-LooksLikeMojibakeQuestionMarks {
|
||||
return $Value -match '\?{2,}'
|
||||
}
|
||||
|
||||
function Test-DocumentNameNeedsFallback {
|
||||
param([AllowNull()][string]$Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
|
||||
$trimmed = $Value.Trim()
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $trimmed) { return $true }
|
||||
if ($trimmed -match '^[0-9]+$') { return $true }
|
||||
if ($trimmed -match '^(?i)(print document|document|local downlevel document)$') { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-EventXmlValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][xml]$EventXml,
|
||||
[Parameter(Mandatory = $true)][string]$Name
|
||||
)
|
||||
|
||||
$node = $EventXml.Event.UserData.DocumentPrinted.$Name
|
||||
if ($null -ne $node) {
|
||||
return [string]$node
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function Get-PrintJobPrinterName {
|
||||
param(
|
||||
[AllowNull()][string]$JobName,
|
||||
[AllowNull()][string]$FallbackPrinterName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($JobName)) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($FallbackPrinterName)) {
|
||||
return $FallbackPrinterName.Trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
$parts = $JobName -split ',', 2
|
||||
if ($parts.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($parts[0])) {
|
||||
return $parts[0].Trim()
|
||||
}
|
||||
|
||||
return $JobName.Trim()
|
||||
}
|
||||
|
||||
function Normalize-OwnerForMatch {
|
||||
param([AllowNull()][string]$Value)
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
|
||||
@@ -515,50 +583,13 @@ function Get-PrintServiceEventSummary {
|
||||
$propertyValues += [string]$prop.Value
|
||||
}
|
||||
|
||||
$xml = $null
|
||||
try {
|
||||
$xml = [xml]$Event.ToXml()
|
||||
}
|
||||
catch {
|
||||
}
|
||||
|
||||
$jobId = ''
|
||||
$documentName = ''
|
||||
$owner = ''
|
||||
$portName = ''
|
||||
$printerName = ''
|
||||
$sizeBytes = ''
|
||||
$pageCount = ''
|
||||
|
||||
if ($xml) {
|
||||
$jobId = Get-EventXmlValue -EventXml $xml -Name 'Param1'
|
||||
$documentName = Get-EventXmlValue -EventXml $xml -Name 'Param2'
|
||||
$owner = Get-EventXmlValue -EventXml $xml -Name 'Param3'
|
||||
$portName = Get-EventXmlValue -EventXml $xml -Name 'Param4'
|
||||
$printerName = Get-EventXmlValue -EventXml $xml -Name 'Param5'
|
||||
$sizeBytes = Get-EventXmlValue -EventXml $xml -Name 'Param7'
|
||||
$pageCount = Get-EventXmlValue -EventXml $xml -Name 'Param8'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($jobId) -and $props.Count -ge 1) { $jobId = [string]$props[0].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($documentName) -and $props.Count -ge 2) { $documentName = [string]$props[1].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($owner) -and $props.Count -ge 3) { $owner = [string]$props[2].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($portName) -and $props.Count -ge 4) { $portName = [string]$props[3].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($printerName) -and $props.Count -ge 5) { $printerName = [string]$props[4].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($sizeBytes) -and $props.Count -ge 7) { $sizeBytes = [string]$props[6].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($pageCount) -and $props.Count -ge 8) { $pageCount = [string]$props[7].Value }
|
||||
|
||||
[pscustomobject]@{
|
||||
RecordId = [string]$Event.RecordId
|
||||
TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' }
|
||||
PropertyCount = $props.Count
|
||||
JobId = $jobId
|
||||
DocumentName = $documentName
|
||||
Owner = $owner
|
||||
PortName = $portName
|
||||
PrinterName = $printerName
|
||||
SizeBytes = $sizeBytes
|
||||
PageCount = $pageCount
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -571,7 +602,7 @@ function Get-PrintServiceDocumentFallback {
|
||||
)
|
||||
|
||||
$preferred = [string]$EventSummary.DocumentName
|
||||
if (-not (Test-DocumentNameNeedsFallback -Value $preferred)) {
|
||||
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
|
||||
return $preferred
|
||||
}
|
||||
|
||||
@@ -582,10 +613,9 @@ function Get-PrintServiceDocumentFallback {
|
||||
$candidate = [string]$value
|
||||
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
|
||||
if ($candidate -eq $preferred) { continue }
|
||||
if ($EventSummary.JobId -and $candidate -eq [string]$EventSummary.JobId) { continue }
|
||||
if ($Owner -and $candidate -like "*$Owner*") { continue }
|
||||
if ($PrinterName -and $candidate -like "*$PrinterName*") { continue }
|
||||
if (Test-DocumentNameNeedsFallback -Value $candidate) { continue }
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }
|
||||
|
||||
if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
|
||||
$pathCandidates.Add($candidate)
|
||||
@@ -630,7 +660,7 @@ function Write-PrintServiceEventTrace {
|
||||
}
|
||||
|
||||
Write-EndpointLog (
|
||||
'printservice-307 этап={0} recordId={1} время={2} владелец={3} принтер={4} документ={5} итоговыйДокумент={6} свойства=[{7}] причина={8}' -f
|
||||
'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,
|
||||
@@ -645,7 +675,6 @@ function Write-PrintServiceEventTrace {
|
||||
|
||||
function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
param(
|
||||
[string]$JobId,
|
||||
[string]$Owner,
|
||||
[string]$PrinterName
|
||||
)
|
||||
@@ -663,41 +692,32 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
$summary = Get-PrintServiceEventSummary -Event $event
|
||||
$resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName
|
||||
|
||||
$jobMatches = if ($JobId) { [string]$summary.JobId -eq [string]$JobId } else { $true }
|
||||
$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 ($JobId -and -not $jobMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-jobid-strict' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
if ($Owner -and -not $ownerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-strict' -ResolvedDocument $resolvedDocument
|
||||
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 'несовпадение-принтера-strict' -ResolvedDocument $resolvedDocument
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($JobId -and (-not $jobMatches) -and $Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-и-принтера-relaxed' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
if ((-not $JobId) -and $Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-и-принтера-relaxed' -ResolvedDocument $resolvedDocument
|
||||
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 ('нет-кандидата-документа-' + $pass) -ResolvedDocument ''
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument ''
|
||||
continue
|
||||
}
|
||||
|
||||
$matchReasonBase = if (Test-DocumentNameNeedsFallback -Value $summary.DocumentName) { 'использован-резервный-вариант' } else { 'напрямую' }
|
||||
$matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument
|
||||
return $resolvedDocument
|
||||
}
|
||||
@@ -710,15 +730,15 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
}
|
||||
|
||||
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
|
||||
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' }
|
||||
$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' }
|
||||
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
|
||||
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
|
||||
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) }
|
||||
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
|
||||
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
|
||||
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'ActivityWatch-Phase2\\incident-artifacts' }
|
||||
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
|
||||
|
||||
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
|
||||
@@ -742,7 +762,7 @@ $script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
|
||||
$script:ScreenshotTypesLoaded = $false
|
||||
|
||||
Load-DlpPolicy -Path $resolvedPolicyPath
|
||||
Write-EndpointLog ("endpoint-коллектор запущен для {0}" -f $script:ApiBase)
|
||||
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
@@ -803,13 +823,13 @@ while ($true) {
|
||||
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
|
||||
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
|
||||
|
||||
$printerName = Get-PrintJobPrinterName -JobName ([string]$job.Name) -FallbackPrinterName ([string]$job.DriverName)
|
||||
$printerName = [string]$job.Name
|
||||
$documentName = [string]$job.Document
|
||||
$owner = [string]$job.Owner
|
||||
$documentNameOriginal = $documentName
|
||||
|
||||
if (Test-DocumentNameNeedsFallback -Value $documentName) {
|
||||
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -JobId $jobId -Owner $owner -PrinterName $printerName
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
|
||||
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
|
||||
if ($eventDocumentName) {
|
||||
$documentName = $eventDocumentName
|
||||
}
|
||||
@@ -820,7 +840,6 @@ while ($true) {
|
||||
documentName = $documentName
|
||||
documentNameOriginal = $documentNameOriginal
|
||||
owner = $owner
|
||||
printJobId = $jobId
|
||||
}
|
||||
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
|
||||
}
|
||||
@@ -884,7 +903,7 @@ while ($true) {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("ошибка коллектора: {0}" -f $_.Exception.Message)
|
||||
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds $resolvedPollSeconds
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
DLP email outbound collector for AWatch-rus (Phase 2.5).
|
||||
Monitors outgoing email via Outlook COM Sent Items polling
|
||||
and/or SMTP network connection detection.
|
||||
|
||||
.DESCRIPTION
|
||||
Two collection modes (configurable, can run simultaneously):
|
||||
- outlook : Polls Outlook Sent Items via COM for new messages.
|
||||
- smtp : Monitors SMTP connections (ports 25/587/465) via
|
||||
Get-NetTCPConnection for any process sending mail.
|
||||
|
||||
Sends heartbeats to AW bucket `aw-email-monitor_<host>`.
|
||||
Evaluates DLP policy rules from `endpoint.email[]` section.
|
||||
Supports enforcement: action="block" moves the email to Drafts
|
||||
(Outlook mode) or logs with enforced=false (SMTP mode).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
|
||||
[string]$ServerHost,
|
||||
[int]$ServerPort,
|
||||
[ValidateSet('http', 'https')]
|
||||
[string]$ServerScheme,
|
||||
[string]$PolicyPath,
|
||||
[string]$LogPath,
|
||||
[int]$PollSeconds,
|
||||
[ValidateSet('outlook', 'smtp', 'both')]
|
||||
[string]$Mode = 'both'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared infrastructure (mirrors other collectors)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Get-DeploymentConfig {
|
||||
param([string]$Path)
|
||||
if ($Path -and (Test-Path -LiteralPath $Path)) {
|
||||
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Write-CollectorLog {
|
||||
param([string]$Message)
|
||||
if (-not $script:LocalAgentLogsEnabled) { return }
|
||||
try {
|
||||
Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
function Invoke-AwJsonPost {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Uri,
|
||||
[Parameter(Mandatory = $true)][string]$Json
|
||||
)
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
|
||||
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
|
||||
}
|
||||
|
||||
function Ensure-Bucket {
|
||||
param(
|
||||
[string]$BucketId,
|
||||
[string]$ClientName,
|
||||
[string]$BucketType
|
||||
)
|
||||
if ($script:KnownBuckets.ContainsKey($BucketId)) { return }
|
||||
$body = @{
|
||||
client = $ClientName
|
||||
type = $BucketType
|
||||
hostname = $script:Hostname
|
||||
} | ConvertTo-Json -Compress
|
||||
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
|
||||
$script:KnownBuckets[$BucketId] = $true
|
||||
}
|
||||
|
||||
function Get-StringHash {
|
||||
param([AllowNull()][string]$Value)
|
||||
if ($null -eq $Value) { return $null }
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes($Value)
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join ''
|
||||
}
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Send-EmailHeartbeat {
|
||||
param(
|
||||
[string]$SignalType,
|
||||
[hashtable]$Data
|
||||
)
|
||||
$bucketId = 'aw-email-monitor_' + $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-email-monitor' -BucketType 'aw.dlp.email'
|
||||
$payload = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = @{
|
||||
signalType = $SignalType
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'email-outbound-collector'
|
||||
} + $Data
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
|
||||
}
|
||||
|
||||
function Send-EmailIncidentHeartbeat {
|
||||
param(
|
||||
[string]$RuleId,
|
||||
[string]$Action,
|
||||
[string]$Severity,
|
||||
[string]$Message,
|
||||
[hashtable]$Data
|
||||
)
|
||||
$bucketId = 'aw-dlp-incidents_' + $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident'
|
||||
$payload = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = @{
|
||||
ruleId = $RuleId
|
||||
action = $Action
|
||||
severity = $Severity
|
||||
message = $Message
|
||||
signalType = 'email_outbound'
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'email-outbound-collector'
|
||||
} + $Data
|
||||
} | ConvertTo-Json -Depth 7 -Compress
|
||||
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
|
||||
}
|
||||
|
||||
function Show-EnforcementNotification {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Title,
|
||||
[Parameter(Mandatory = $true)][string]$Body
|
||||
)
|
||||
try {
|
||||
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
|
||||
$icon = New-Object System.Windows.Forms.NotifyIcon
|
||||
$icon.Icon = [System.Drawing.SystemIcons]::Warning
|
||||
$icon.BalloonTipTitle = $Title
|
||||
$icon.BalloonTipText = $Body
|
||||
$icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning
|
||||
$icon.Visible = $true
|
||||
$icon.ShowBalloonTip(5000)
|
||||
Start-Sleep -Milliseconds 200
|
||||
$icon.Dispose()
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DLP Policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Load-EmailPolicy {
|
||||
param([string]$Path)
|
||||
|
||||
$script:Policy = [ordered]@{
|
||||
defaults = [ordered]@{
|
||||
enabled = $true
|
||||
cooldownSeconds = 300
|
||||
action = 'alert'
|
||||
severity = 'medium'
|
||||
}
|
||||
endpoint = [ordered]@{
|
||||
email = @()
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
|
||||
Write-CollectorLog ("policy not found, using defaults: {0}" -f $Path)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
if ($raw.defaults) {
|
||||
if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled }
|
||||
if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds }
|
||||
if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action }
|
||||
if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity }
|
||||
}
|
||||
if ($raw.endpoint -and $raw.endpoint.email) {
|
||||
$script:Policy.endpoint.email = @($raw.endpoint.email)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("policy parse failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
function Should-EmitByCooldown {
|
||||
param(
|
||||
[string]$Fingerprint,
|
||||
[int]$CooldownSeconds
|
||||
)
|
||||
$now = (Get-Date).ToUniversalTime()
|
||||
if ($script:Cooldown.ContainsKey($Fingerprint)) {
|
||||
$last = [datetime]$script:Cooldown[$Fingerprint]
|
||||
if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
$script:Cooldown[$Fingerprint] = $now
|
||||
return $true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Email DLP rule evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Evaluate-EmailRules {
|
||||
param(
|
||||
[string]$Subject,
|
||||
[string]$RecipientsJoined,
|
||||
[string]$SenderAddress,
|
||||
[int]$AttachmentCount,
|
||||
[string]$AttachmentNames,
|
||||
[int]$BodyLength,
|
||||
[string]$MessageId,
|
||||
$OutlookMailItem
|
||||
)
|
||||
|
||||
foreach ($rule in @($script:Policy.endpoint.email)) {
|
||||
if (-not $rule) { continue }
|
||||
if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
|
||||
$ruleId = [string]$rule.id
|
||||
if (-not $ruleId) { continue }
|
||||
|
||||
$matched = $true
|
||||
|
||||
if ($rule.subjectRegex) {
|
||||
$matched = $matched -and ($Subject -match [string]$rule.subjectRegex)
|
||||
}
|
||||
if ($rule.recipientRegex) {
|
||||
$matched = $matched -and ($RecipientsJoined -match [string]$rule.recipientRegex)
|
||||
}
|
||||
if ($rule.senderRegex) {
|
||||
$matched = $matched -and ($SenderAddress -match [string]$rule.senderRegex)
|
||||
}
|
||||
if ($rule.attachmentRegex -and $AttachmentNames) {
|
||||
$matched = $matched -and ($AttachmentNames -match [string]$rule.attachmentRegex)
|
||||
}
|
||||
if ($rule.minAttachments) {
|
||||
$matched = $matched -and ($AttachmentCount -ge [int]$rule.minAttachments)
|
||||
}
|
||||
if ($rule.minBodyLength) {
|
||||
$matched = $matched -and ($BodyLength -ge [int]$rule.minBodyLength)
|
||||
}
|
||||
if ($rule.externalOnly -and [bool]$rule.externalOnly) {
|
||||
$internalDomain = if ($rule.internalDomain) { [string]$rule.internalDomain } else { '' }
|
||||
if ($internalDomain -and $RecipientsJoined -notmatch [regex]::Escape($internalDomain)) {
|
||||
# all recipients are external — continue matching
|
||||
}
|
||||
elseif ($internalDomain) {
|
||||
$matched = $false
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $matched) { continue }
|
||||
|
||||
$cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
|
||||
$fingerprint = "email|$ruleId|$MessageId|$env:USERNAME"
|
||||
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
|
||||
|
||||
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
|
||||
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
|
||||
$message = if ($rule.message) { [string]$rule.message } else { "Email rule matched: $ruleId" }
|
||||
|
||||
$enforced = $false
|
||||
if ($action -eq 'block' -and $null -ne $OutlookMailItem) {
|
||||
$enforced = Invoke-EmailEnforcement -MailItem $OutlookMailItem -RuleId $ruleId
|
||||
Show-EnforcementNotification -Title 'DLP: письмо перемещено в черновики' -Body $message
|
||||
}
|
||||
elseif ($action -eq 'block') {
|
||||
Show-EnforcementNotification -Title 'DLP: обнаружена отправка письма' -Body $message
|
||||
}
|
||||
|
||||
Send-EmailIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -Data @{
|
||||
subject = (Get-StringHash -Value $Subject)
|
||||
recipients = (Get-StringHash -Value $RecipientsJoined)
|
||||
sender = $SenderAddress
|
||||
attachmentCount = $AttachmentCount
|
||||
attachmentNames = $AttachmentNames
|
||||
bodyLength = $BodyLength
|
||||
enforced = $enforced
|
||||
}
|
||||
Write-CollectorLog ("incident email rule={0} action={1} severity={2} enforced={3} subject_hash={4}" -f $ruleId, $action, $severity, $enforced, (Get-StringHash -Value $Subject))
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-EmailEnforcement {
|
||||
[OutputType([bool])]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$MailItem,
|
||||
[string]$RuleId
|
||||
)
|
||||
try {
|
||||
$draftsFolder = $script:OutlookNamespace.GetDefaultFolder(16) # olFolderDrafts
|
||||
$MailItem.Move($draftsFolder) | Out-Null
|
||||
Write-CollectorLog ("enforcement: email moved to Drafts rule={0} subject_hash={1}" -f $RuleId, (Get-StringHash -Value $MailItem.Subject))
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("enforcement: email move to Drafts failed rule={0}: {1}" -f $RuleId, $_.Exception.Message)
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outlook Sent Items polling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Initialize-OutlookCom {
|
||||
try {
|
||||
$script:OutlookApp = New-Object -ComObject Outlook.Application
|
||||
$script:OutlookNamespace = $script:OutlookApp.GetNamespace('MAPI')
|
||||
$script:SentFolder = $script:OutlookNamespace.GetDefaultFolder(5) # olFolderSentMail
|
||||
Write-CollectorLog "Outlook COM initialized, Sent Items folder opened"
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("Outlook COM init failed: {0}" -f $_.Exception.Message)
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-OutlookSentItems {
|
||||
param([datetime]$Since)
|
||||
|
||||
$results = @()
|
||||
try {
|
||||
$items = $script:SentFolder.Items
|
||||
$items.Sort('[SentOn]', $true)
|
||||
|
||||
$filter = "[SentOn] >= '{0}'" -f $Since.ToString('MM/dd/yyyy HH:mm')
|
||||
$restricted = $items.Restrict($filter)
|
||||
|
||||
foreach ($item in $restricted) {
|
||||
try {
|
||||
if ($item.Class -ne 43) { continue } # olMail = 43
|
||||
|
||||
$recipients = @()
|
||||
for ($i = 1; $i -le $item.Recipients.Count; $i++) {
|
||||
$recip = $item.Recipients.Item($i)
|
||||
$recipients += [string]$recip.Address
|
||||
}
|
||||
|
||||
$attachmentNames = @()
|
||||
for ($i = 1; $i -le $item.Attachments.Count; $i++) {
|
||||
$attachmentNames += [string]$item.Attachments.Item($i).FileName
|
||||
}
|
||||
|
||||
$results += [pscustomobject]@{
|
||||
EntryID = [string]$item.EntryID
|
||||
Subject = [string]$item.Subject
|
||||
SenderAddress = [string]$item.SenderEmailAddress
|
||||
SenderName = [string]$item.SenderName
|
||||
Recipients = $recipients
|
||||
RecipientsJoined = ($recipients -join '; ')
|
||||
AttachmentCount = [int]$item.Attachments.Count
|
||||
AttachmentNames = ($attachmentNames -join '; ')
|
||||
BodyLength = if ($item.Body) { $item.Body.Length } else { 0 }
|
||||
SentOn = $item.SentOn
|
||||
MailItem = $item
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("Outlook Sent Items scan failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
return $results
|
||||
}
|
||||
|
||||
function Poll-OutlookSentItems {
|
||||
$items = Get-OutlookSentItems -Since $script:OutlookLastPoll
|
||||
|
||||
foreach ($item in $items) {
|
||||
$entryId = $item.EntryID
|
||||
if ($script:SeenEntryIds.ContainsKey($entryId)) { continue }
|
||||
$script:SeenEntryIds[$entryId] = (Get-Date).ToUniversalTime()
|
||||
|
||||
$subjectHash = Get-StringHash -Value $item.Subject
|
||||
|
||||
Send-EmailHeartbeat -SignalType 'email_sent' -Data @{
|
||||
subject = $subjectHash
|
||||
sender = [string]$item.SenderAddress
|
||||
senderName = [string]$item.SenderName
|
||||
recipientCount = $item.Recipients.Count
|
||||
recipients = (Get-StringHash -Value $item.RecipientsJoined)
|
||||
attachmentCount = [int]$item.AttachmentCount
|
||||
attachmentNames = [string]$item.AttachmentNames
|
||||
bodyLength = [int]$item.BodyLength
|
||||
sentOn = if ($item.SentOn) { $item.SentOn.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } else { '' }
|
||||
collectionMode = 'outlook'
|
||||
}
|
||||
Write-CollectorLog ("email_sent outlook subject_hash={0} to={1} attachments={2}" -f $subjectHash, $item.Recipients.Count, $item.AttachmentCount)
|
||||
|
||||
Evaluate-EmailRules `
|
||||
-Subject $item.Subject `
|
||||
-RecipientsJoined $item.RecipientsJoined `
|
||||
-SenderAddress $item.SenderAddress `
|
||||
-AttachmentCount $item.AttachmentCount `
|
||||
-AttachmentNames $item.AttachmentNames `
|
||||
-BodyLength $item.BodyLength `
|
||||
-MessageId $entryId `
|
||||
-OutlookMailItem $item.MailItem
|
||||
}
|
||||
|
||||
$script:OutlookLastPoll = (Get-Date).AddSeconds(-10)
|
||||
|
||||
# Cleanup old entry IDs (keep last 24h)
|
||||
$cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-24)
|
||||
foreach ($k in @($script:SeenEntryIds.Keys)) {
|
||||
if ([datetime]$script:SeenEntryIds[$k] -lt $cleanupBefore) {
|
||||
$script:SeenEntryIds.Remove($k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMTP network connection monitoring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Poll-SmtpConnections {
|
||||
try {
|
||||
$smtpPorts = @(25, 587, 465, 2525)
|
||||
$connections = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
|
||||
Where-Object { $smtpPorts -contains $_.RemotePort }
|
||||
|
||||
foreach ($conn in @($connections)) {
|
||||
$processId = [int]$conn.OwningProcess
|
||||
$remoteAddr = [string]$conn.RemoteAddress
|
||||
$remotePort = [int]$conn.RemotePort
|
||||
$fingerprint = "{0}:{1}:{2}" -f $processId, $remoteAddr, $remotePort
|
||||
if ($script:SeenSmtpConnections.ContainsKey($fingerprint)) { continue }
|
||||
$script:SeenSmtpConnections[$fingerprint] = (Get-Date).ToUniversalTime()
|
||||
|
||||
$processName = ''
|
||||
try {
|
||||
$proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
|
||||
$processName = [string]$proc.ProcessName
|
||||
}
|
||||
catch { }
|
||||
|
||||
Send-EmailHeartbeat -SignalType 'smtp_connection' -Data @{
|
||||
remoteAddress = $remoteAddr
|
||||
remotePort = $remotePort
|
||||
processId = $processId
|
||||
processName = $processName
|
||||
localPort = [int]$conn.LocalPort
|
||||
collectionMode = 'smtp'
|
||||
}
|
||||
Write-CollectorLog ("smtp_connection process={0}({1}) remote={2}:{3}" -f $processName, $processId, $remoteAddr, $remotePort)
|
||||
|
||||
Evaluate-EmailRules `
|
||||
-Subject '' `
|
||||
-RecipientsJoined $remoteAddr `
|
||||
-SenderAddress $env:USERNAME `
|
||||
-AttachmentCount 0 `
|
||||
-AttachmentNames '' `
|
||||
-BodyLength 0 `
|
||||
-MessageId $fingerprint `
|
||||
-OutlookMailItem $null
|
||||
}
|
||||
|
||||
# Cleanup old SMTP connections (keep last 8h)
|
||||
$cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8)
|
||||
foreach ($k in @($script:SeenSmtpConnections.Keys)) {
|
||||
if ([datetime]$script:SeenSmtpConnections[$k] -lt $cleanupBefore) {
|
||||
$script:SeenSmtpConnections.Remove($k)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("SMTP poll error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
|
||||
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
|
||||
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
|
||||
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
|
||||
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
|
||||
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 10 }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
|
||||
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("email-outbound-{0}.log" -f $env:USERNAME) }
|
||||
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
|
||||
|
||||
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
|
||||
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
|
||||
$script:Hostname = $env:COMPUTERNAME
|
||||
$script:SessionId = (Get-Process -Id $PID).SessionId
|
||||
$script:KnownBuckets = @{}
|
||||
$script:Cooldown = @{}
|
||||
$script:SeenEntryIds = @{}
|
||||
$script:SeenSmtpConnections = @{}
|
||||
$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30)
|
||||
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
|
||||
$script:LogPath = $resolvedLogPath
|
||||
$script:OutlookApp = $null
|
||||
$script:OutlookNamespace = $null
|
||||
$script:SentFolder = $null
|
||||
$script:OutlookLastPoll = (Get-Date).AddMinutes(-5)
|
||||
|
||||
Load-EmailPolicy -Path $resolvedPolicyPath
|
||||
Write-CollectorLog ("email collector started mode={0} against {1}" -f $Mode, $script:ApiBase)
|
||||
|
||||
$useOutlook = ($Mode -eq 'outlook' -or $Mode -eq 'both')
|
||||
$useSmtp = ($Mode -eq 'smtp' -or $Mode -eq 'both')
|
||||
$outlookReady = $false
|
||||
|
||||
if ($useOutlook) {
|
||||
$outlookReady = Initialize-OutlookCom
|
||||
if (-not $outlookReady -and $Mode -eq 'outlook') {
|
||||
Write-CollectorLog "Outlook COM not available, collector will retry"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
if (-not $script:Policy.defaults.enabled) {
|
||||
Start-Sleep -Seconds $resolvedPollSeconds
|
||||
continue
|
||||
}
|
||||
|
||||
if ($useOutlook) {
|
||||
if (-not $outlookReady) {
|
||||
$outlookReady = Initialize-OutlookCom
|
||||
}
|
||||
if ($outlookReady) {
|
||||
try {
|
||||
Poll-OutlookSentItems
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("outlook poll error: {0}" -f $_.Exception.Message)
|
||||
$outlookReady = $false
|
||||
$script:OutlookApp = $null
|
||||
$script:OutlookNamespace = $null
|
||||
$script:SentFolder = $null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($useSmtp) {
|
||||
try {
|
||||
Poll-SmtpConnections
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("smtp poll error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds $resolvedPollSeconds
|
||||
}
|
||||
Executable
+517
@@ -0,0 +1,517 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Protocol, TypeAlias
|
||||
|
||||
|
||||
JsonScalar: TypeAlias = str | int | float | bool | None
|
||||
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
|
||||
|
||||
DEFAULT_BUCKET_PREFIXES = ("aw-file-operations_", "aw-dlp-incidents_")
|
||||
DEFAULT_SQLITE_PATH = "data/dlp-events.sqlite3"
|
||||
EVENT_COLUMNS = (
|
||||
"bucket_id",
|
||||
"event_id",
|
||||
"stream_type",
|
||||
"hostname",
|
||||
"username",
|
||||
"event_ts",
|
||||
"duration",
|
||||
"operation",
|
||||
"file_path",
|
||||
"old_file_path",
|
||||
"extension",
|
||||
"archive_hint",
|
||||
"rule_id",
|
||||
"action",
|
||||
"severity",
|
||||
"signal_type",
|
||||
"message",
|
||||
"source",
|
||||
"screenshot_path",
|
||||
"raw_json",
|
||||
"ingested_at",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bucket:
|
||||
id: str
|
||||
type: str
|
||||
client: str
|
||||
hostname: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AwEvent:
|
||||
bucket_id: str
|
||||
hostname: str
|
||||
stream_type: str
|
||||
event_id: str
|
||||
timestamp: str
|
||||
duration: float
|
||||
data: dict[str, JsonValue]
|
||||
|
||||
|
||||
class PsycopgConnection(Protocol):
|
||||
def cursor(self):
|
||||
...
|
||||
|
||||
def commit(self) -> None:
|
||||
...
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(tz=UTC)
|
||||
|
||||
|
||||
def parse_timestamp(value: str) -> datetime:
|
||||
normalized = value.replace("Z", "+00:00")
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
|
||||
|
||||
def format_aw_timestamp(value: datetime) -> str:
|
||||
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def load_state(path: Path) -> dict[str, str]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def save_state(path: Path, state: dict[str, str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def normalize_base_url(base_url: str) -> str:
|
||||
return base_url.rstrip("/")
|
||||
|
||||
|
||||
def aw_get_json(base_url: str, path: str, timeout: int) -> JsonValue:
|
||||
url = normalize_base_url(base_url) + path
|
||||
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def list_buckets(base_url: str, timeout: int) -> list[Bucket]:
|
||||
payload = aw_get_json(base_url, "/buckets", timeout)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("ActivityWatch /buckets response must be a JSON object")
|
||||
buckets: list[Bucket] = []
|
||||
for bucket_id, bucket_data in payload.items():
|
||||
if not isinstance(bucket_data, dict):
|
||||
continue
|
||||
buckets.append(
|
||||
Bucket(
|
||||
id=str(bucket_id),
|
||||
type=str(bucket_data.get("type", "")),
|
||||
client=str(bucket_data.get("client", "")),
|
||||
hostname=str(bucket_data.get("hostname", "")),
|
||||
)
|
||||
)
|
||||
return buckets
|
||||
|
||||
|
||||
def bucket_stream_type(bucket: Bucket) -> str | None:
|
||||
if bucket.id.startswith("aw-file-operations_") or bucket.type == "aw.file.operation":
|
||||
return "file_operation"
|
||||
if bucket.id.startswith("aw-dlp-incidents_") or bucket.type == "aw.dlp.incident":
|
||||
return "dlp_incident"
|
||||
return None
|
||||
|
||||
|
||||
def select_buckets(buckets: Iterable[Bucket], prefixes: tuple[str, ...]) -> list[tuple[Bucket, str]]:
|
||||
selected: list[tuple[Bucket, str]] = []
|
||||
for bucket in buckets:
|
||||
stream_type = bucket_stream_type(bucket)
|
||||
if stream_type and any(bucket.id.startswith(prefix) for prefix in prefixes):
|
||||
selected.append((bucket, stream_type))
|
||||
return selected
|
||||
|
||||
|
||||
def build_events_path(bucket_id: str, start: datetime, end: datetime, limit: int) -> str:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"start": format_aw_timestamp(start),
|
||||
"end": format_aw_timestamp(end),
|
||||
"limit": str(limit),
|
||||
}
|
||||
)
|
||||
return f"/buckets/{urllib.parse.quote(bucket_id, safe='')}/events?{query}"
|
||||
|
||||
|
||||
def event_key(bucket_id: str, timestamp: str, duration: float, data: dict[str, JsonValue]) -> str:
|
||||
payload = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return f"{bucket_id}|{timestamp}|{duration}|{payload}"
|
||||
|
||||
|
||||
def fetch_bucket_events(
|
||||
base_url: str,
|
||||
bucket: Bucket,
|
||||
stream_type: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
limit: int,
|
||||
timeout: int,
|
||||
) -> list[AwEvent]:
|
||||
payload = aw_get_json(base_url, build_events_path(bucket.id, start, end, limit), timeout)
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError(f"ActivityWatch events response for {bucket.id} must be a JSON array")
|
||||
events: list[AwEvent] = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
timestamp = str(item["timestamp"])
|
||||
duration = float(item.get("duration", 0) or 0)
|
||||
data = item.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
data = {"raw": data}
|
||||
item_id = str(item.get("id") or event_key(bucket.id, timestamp, duration, data))
|
||||
events.append(
|
||||
AwEvent(
|
||||
bucket_id=bucket.id,
|
||||
hostname=bucket.hostname or str(data.get("hostname") or ""),
|
||||
stream_type=stream_type,
|
||||
event_id=item_id,
|
||||
timestamp=timestamp,
|
||||
duration=duration,
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def connect_sqlite(path: Path) -> sqlite3.Connection:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(str(path))
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=NORMAL")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
return connection
|
||||
|
||||
|
||||
def ensure_schema(connection: sqlite3.Connection) -> None:
|
||||
connection.executescript(
|
||||
"""
|
||||
create table if not exists dlp_events (
|
||||
id integer primary key autoincrement,
|
||||
bucket_id text not null,
|
||||
event_id text not null,
|
||||
stream_type text not null,
|
||||
hostname text not null,
|
||||
username text,
|
||||
event_ts text not null,
|
||||
duration real not null default 0,
|
||||
operation text,
|
||||
file_path text,
|
||||
old_file_path text,
|
||||
extension text,
|
||||
archive_hint integer not null default 0,
|
||||
rule_id text,
|
||||
action text,
|
||||
severity text,
|
||||
signal_type text,
|
||||
message text,
|
||||
source text,
|
||||
screenshot_path text,
|
||||
raw_json text not null,
|
||||
ingested_at text not null,
|
||||
unique (bucket_id, event_id)
|
||||
);
|
||||
create index if not exists idx_dlp_events_event_ts on dlp_events(event_ts);
|
||||
create index if not exists idx_dlp_events_host_ts on dlp_events(hostname, event_ts);
|
||||
create index if not exists idx_dlp_events_stream_ts on dlp_events(stream_type, event_ts);
|
||||
create index if not exists idx_dlp_events_archive on dlp_events(archive_hint, event_ts);
|
||||
create index if not exists idx_dlp_events_rule on dlp_events(rule_id, event_ts);
|
||||
|
||||
create view if not exists dlp_file_operations as
|
||||
select *
|
||||
from dlp_events
|
||||
where stream_type = 'file_operation';
|
||||
|
||||
create view if not exists dlp_incidents as
|
||||
select *
|
||||
from dlp_events
|
||||
where stream_type = 'dlp_incident';
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def ensure_postgres_schema(connection: PsycopgConnection) -> None:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
create table if not exists dlp_events (
|
||||
id bigserial primary key,
|
||||
bucket_id text not null,
|
||||
event_id text not null,
|
||||
stream_type text not null,
|
||||
hostname text not null,
|
||||
username text,
|
||||
event_ts timestamptz not null,
|
||||
duration double precision not null default 0,
|
||||
operation text,
|
||||
file_path text,
|
||||
old_file_path text,
|
||||
extension text,
|
||||
archive_hint boolean not null default false,
|
||||
rule_id text,
|
||||
action text,
|
||||
severity text,
|
||||
signal_type text,
|
||||
message text,
|
||||
source text,
|
||||
screenshot_path text,
|
||||
raw_json jsonb not null,
|
||||
ingested_at timestamptz not null,
|
||||
unique (bucket_id, event_id)
|
||||
);
|
||||
create index if not exists idx_dlp_events_event_ts on dlp_events(event_ts);
|
||||
create index if not exists idx_dlp_events_host_ts on dlp_events(hostname, event_ts);
|
||||
create index if not exists idx_dlp_events_stream_ts on dlp_events(stream_type, event_ts);
|
||||
create index if not exists idx_dlp_events_archive on dlp_events(archive_hint, event_ts);
|
||||
create index if not exists idx_dlp_events_rule on dlp_events(rule_id, event_ts);
|
||||
|
||||
create or replace view dlp_file_operations as
|
||||
select *
|
||||
from dlp_events
|
||||
where stream_type = 'file_operation';
|
||||
|
||||
create or replace view dlp_incidents as
|
||||
select *
|
||||
from dlp_events
|
||||
where stream_type = 'dlp_incident';
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def first_string(data: dict[str, JsonValue], keys: tuple[str, ...]) -> str | None:
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if value is not None and str(value) != "":
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
def bool_as_int(value: JsonValue) -> int:
|
||||
if isinstance(value, bool):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
return int(value.lower() in {"1", "true", "yes", "y"})
|
||||
return int(bool(value))
|
||||
|
||||
|
||||
def event_row(event: AwEvent, ingested_at: str) -> tuple[JsonValue, ...]:
|
||||
data = event.data
|
||||
event_id = event.event_id or event_key(event.bucket_id, event.timestamp, event.duration, data)
|
||||
return (
|
||||
event.bucket_id,
|
||||
event_id,
|
||||
event.stream_type,
|
||||
event.hostname,
|
||||
first_string(data, ("username", "user")),
|
||||
event.timestamp,
|
||||
event.duration,
|
||||
first_string(data, ("operation",)),
|
||||
first_string(data, ("path", "filePath")),
|
||||
first_string(data, ("oldPath", "oldFilePath")),
|
||||
first_string(data, ("extension",)),
|
||||
bool_as_int(data.get("archiveHint")),
|
||||
first_string(data, ("ruleId", "rule")),
|
||||
first_string(data, ("action",)),
|
||||
first_string(data, ("severity",)),
|
||||
first_string(data, ("signalType",)),
|
||||
first_string(data, ("message",)),
|
||||
first_string(data, ("source",)),
|
||||
first_string(data, ("screenshotPath", "capturePath", "artifactPath")),
|
||||
json.dumps(data, ensure_ascii=False, sort_keys=True),
|
||||
ingested_at,
|
||||
)
|
||||
|
||||
|
||||
def insert_events(connection: sqlite3.Connection, events: Iterable[AwEvent]) -> int:
|
||||
inserted = 0
|
||||
now = format_aw_timestamp(utc_now())
|
||||
for event in events:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
insert or ignore into dlp_events (
|
||||
bucket_id,
|
||||
event_id,
|
||||
stream_type,
|
||||
hostname,
|
||||
username,
|
||||
event_ts,
|
||||
duration,
|
||||
operation,
|
||||
file_path,
|
||||
old_file_path,
|
||||
extension,
|
||||
archive_hint,
|
||||
rule_id,
|
||||
action,
|
||||
severity,
|
||||
signal_type,
|
||||
message,
|
||||
source,
|
||||
screenshot_path,
|
||||
raw_json,
|
||||
ingested_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
event_row(event, now),
|
||||
)
|
||||
inserted += int(cursor.rowcount > 0)
|
||||
connection.commit()
|
||||
return inserted
|
||||
|
||||
|
||||
def insert_postgres_events(dsn: str, events: Iterable[AwEvent]) -> int:
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError as exc:
|
||||
raise SystemExit("PostgreSQL mode requires psycopg: python3 -m pip install 'psycopg[binary]'") from exc
|
||||
|
||||
inserted = 0
|
||||
now = format_aw_timestamp(utc_now())
|
||||
columns = ", ".join(EVENT_COLUMNS)
|
||||
placeholders = ", ".join(["%s"] * len(EVENT_COLUMNS))
|
||||
sql = f"""
|
||||
insert into dlp_events ({columns})
|
||||
values ({placeholders})
|
||||
on conflict (bucket_id, event_id) do nothing
|
||||
"""
|
||||
with psycopg.connect(dsn) as connection:
|
||||
ensure_postgres_schema(connection)
|
||||
with connection.cursor() as cursor:
|
||||
for event in events:
|
||||
row = list(event_row(event, now))
|
||||
row[EVENT_COLUMNS.index("archive_hint")] = bool(row[EVENT_COLUMNS.index("archive_hint")])
|
||||
cursor.execute(sql, row)
|
||||
inserted += int(cursor.rowcount > 0)
|
||||
connection.commit()
|
||||
return inserted
|
||||
|
||||
|
||||
def get_start_time(args: argparse.Namespace, state: dict[str, str]) -> datetime:
|
||||
if args.since:
|
||||
return parse_timestamp(args.since)
|
||||
if state.get("last_end"):
|
||||
return parse_timestamp(state["last_end"]) - timedelta(seconds=args.overlap_seconds)
|
||||
return utc_now() - timedelta(hours=args.lookback_hours)
|
||||
|
||||
|
||||
def parse_prefixes(value: str) -> tuple[str, ...]:
|
||||
prefixes = tuple(item.strip() for item in value.split(",") if item.strip())
|
||||
if not prefixes:
|
||||
raise argparse.ArgumentTypeError("at least one bucket prefix is required")
|
||||
return prefixes
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Aggregate AWatch-rus DLP buckets into a local warehouse database.")
|
||||
parser.add_argument("--aw-url", default=os.environ.get("AW_URL", "http://127.0.0.1:5600/api/0"))
|
||||
parser.add_argument("--postgres-dsn", default=os.environ.get("DLP_AGGREGATOR_POSTGRES_DSN"))
|
||||
parser.add_argument("--sqlite-path", default=os.environ.get("DLP_AGGREGATOR_SQLITE_PATH", DEFAULT_SQLITE_PATH))
|
||||
parser.add_argument("--state-path", default=os.environ.get("DLP_AGGREGATOR_STATE_PATH", "data/dlp-aggregator-state.json"))
|
||||
parser.add_argument("--bucket-prefixes", type=parse_prefixes, default=DEFAULT_BUCKET_PREFIXES)
|
||||
parser.add_argument("--since", help="UTC ISO timestamp. Overrides saved state, for example 2026-05-02T00:00:00Z.")
|
||||
parser.add_argument("--lookback-hours", type=int, default=24)
|
||||
parser.add_argument("--overlap-seconds", type=int, default=60)
|
||||
parser.add_argument("--limit", type=int, default=10000)
|
||||
parser.add_argument("--timeout", type=int, default=15)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
state_path = Path(args.state_path)
|
||||
state = load_state(state_path)
|
||||
start = get_start_time(args, state)
|
||||
end = utc_now()
|
||||
|
||||
buckets = select_buckets(list_buckets(args.aw_url, args.timeout), args.bucket_prefixes)
|
||||
all_events: list[AwEvent] = []
|
||||
for bucket, stream_type in buckets:
|
||||
all_events.extend(fetch_bucket_events(args.aw_url, bucket, stream_type, start, end, args.limit, args.timeout))
|
||||
|
||||
if args.dry_run:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"aw_url": args.aw_url,
|
||||
"start": format_aw_timestamp(start),
|
||||
"end": format_aw_timestamp(end),
|
||||
"selected_buckets": [bucket.id for bucket, _stream_type in buckets],
|
||||
"fetched_events": len(all_events),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
if args.postgres_dsn:
|
||||
target = "postgres"
|
||||
target_path = args.postgres_dsn.split("@")[-1]
|
||||
inserted = insert_postgres_events(args.postgres_dsn, all_events)
|
||||
else:
|
||||
target = "sqlite"
|
||||
sqlite_path = Path(args.sqlite_path)
|
||||
target_path = str(sqlite_path)
|
||||
connection = connect_sqlite(sqlite_path)
|
||||
try:
|
||||
ensure_schema(connection)
|
||||
inserted = insert_events(connection, all_events)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
state["last_end"] = format_aw_timestamp(end)
|
||||
save_state(state_path, state)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"aw_url": args.aw_url,
|
||||
"target": target,
|
||||
"target_path": target_path,
|
||||
"state_path": str(state_path),
|
||||
"start": format_aw_timestamp(start),
|
||||
"end": format_aw_timestamp(end),
|
||||
"selected_buckets": len(buckets),
|
||||
"fetched_events": len(all_events),
|
||||
"inserted_events": inserted,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except urllib.error.URLError as exc:
|
||||
print(f"ActivityWatch API request failed: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def connect(path: Path) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(str(path))
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=NORMAL")
|
||||
return connection
|
||||
|
||||
|
||||
def bucket_key(row: sqlite3.Row) -> tuple[str, str, str, str]:
|
||||
return (
|
||||
str(row["name"]),
|
||||
str(row["type"]),
|
||||
str(row["client"]),
|
||||
str(row["hostname"]),
|
||||
)
|
||||
|
||||
|
||||
def ensure_parent(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_existing_events(connection: sqlite3.Connection, bucketrow: int) -> set[tuple[int, int, str]]:
|
||||
cursor = connection.execute(
|
||||
"select starttime, endtime, data from events where bucketrow = ?",
|
||||
(bucketrow,),
|
||||
)
|
||||
return {(int(start), int(end), str(data)) for start, end, data in cursor.fetchall()}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--overlay")
|
||||
args = parser.parse_args()
|
||||
|
||||
base = Path(args.base)
|
||||
output = Path(args.output)
|
||||
overlay = Path(args.overlay) if args.overlay else None
|
||||
|
||||
if not base.exists():
|
||||
raise SystemExit(f"Base DB not found: {base}")
|
||||
|
||||
ensure_parent(output)
|
||||
tmp_output = output.with_suffix(output.suffix + ".tmp")
|
||||
if tmp_output.exists():
|
||||
tmp_output.unlink()
|
||||
shutil.copy2(base, tmp_output)
|
||||
|
||||
dest = connect(tmp_output)
|
||||
dest.row_factory = sqlite3.Row
|
||||
|
||||
inserted_buckets = 0
|
||||
inserted_events = 0
|
||||
|
||||
if overlay and overlay.exists():
|
||||
source = connect(overlay)
|
||||
source.row_factory = sqlite3.Row
|
||||
try:
|
||||
source_buckets = source.execute(
|
||||
"select rowid as bucketrow, id, name, type, client, hostname, created, data_deprecated, data from buckets order by rowid"
|
||||
).fetchall()
|
||||
|
||||
dest_bucket_map = {
|
||||
bucket_key(row): row["bucketrow"]
|
||||
for row in dest.execute(
|
||||
"select rowid as bucketrow, id, name, type, client, hostname, created, data_deprecated, data from buckets order by rowid"
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
for src_bucket in source_buckets:
|
||||
key = bucket_key(src_bucket)
|
||||
dest_rowid = dest_bucket_map.get(key)
|
||||
if dest_rowid is None:
|
||||
cursor = dest.execute(
|
||||
"""
|
||||
insert into buckets (name, type, client, hostname, created, data_deprecated, data)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
src_bucket["name"],
|
||||
src_bucket["type"],
|
||||
src_bucket["client"],
|
||||
src_bucket["hostname"],
|
||||
src_bucket["created"],
|
||||
src_bucket["data_deprecated"],
|
||||
src_bucket["data"],
|
||||
),
|
||||
)
|
||||
dest_rowid = int(cursor.lastrowid)
|
||||
dest_bucket_map[key] = dest_rowid
|
||||
inserted_buckets += 1
|
||||
|
||||
existing_events = load_existing_events(dest, dest_rowid)
|
||||
for starttime, endtime, data in source.execute(
|
||||
"select starttime, endtime, data from events where bucketrow = ? order by id",
|
||||
(src_bucket["bucketrow"],),
|
||||
).fetchall():
|
||||
event_key = (int(starttime), int(endtime), str(data))
|
||||
if event_key in existing_events:
|
||||
continue
|
||||
dest.execute(
|
||||
"insert into events (bucketrow, starttime, endtime, data) values (?, ?, ?, ?)",
|
||||
(dest_rowid, int(starttime), int(endtime), str(data)),
|
||||
)
|
||||
existing_events.add(event_key)
|
||||
inserted_events += 1
|
||||
|
||||
dest.commit()
|
||||
finally:
|
||||
source.close()
|
||||
|
||||
dest.close()
|
||||
os.replace(tmp_output, output)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"base": str(base),
|
||||
"overlay": str(overlay) if overlay else None,
|
||||
"output": str(output),
|
||||
"inserted_buckets": inserted_buckets,
|
||||
"inserted_events": inserted_events,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
if [[ -f "${ROOT_DIR}/secrets/runtime.env" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "${ROOT_DIR}/secrets/runtime.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
: "${AW_SSH_PASSWORD:?AW_SSH_PASSWORD is required}"
|
||||
: "${AW_WINRM_PASSWORD:?AW_WINRM_PASSWORD is required}"
|
||||
|
||||
command -v sshpass >/dev/null 2>&1 || { echo "missing sshpass" >&2; exit 127; }
|
||||
command -v ansible-playbook >/dev/null 2>&1 || { echo "missing ansible-playbook" >&2; exit 127; }
|
||||
|
||||
SERVER_HOST="${AW_SERVER_HOST:-10.10.10.13}"
|
||||
SERVER_USER="${AW_SERVER_USER:-igor}"
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
REMOTE_BACKUP_DIR="/var/lib/activitywatch/backups/prod-restore-${TIMESTAMP}"
|
||||
LEGACY_DB="/root/.local/share/activitywatch/aw-server-rust/sqlite.db"
|
||||
TARGET_DB="/var/lib/activitywatch/.local/share/activitywatch/aw-server-rust/sqlite.db"
|
||||
REMOTE_MERGE_SCRIPT="/tmp/merge_aw_server_dbs.py"
|
||||
|
||||
ssh_remote() {
|
||||
sshpass -p "$AW_SSH_PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "${SERVER_USER}@${SERVER_HOST}" "$@"
|
||||
}
|
||||
|
||||
scp_remote() {
|
||||
sshpass -p "$AW_SSH_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$@"
|
||||
}
|
||||
|
||||
scp_remote "${ROOT_DIR}/scripts/merge_aw_server_dbs.py" "${SERVER_USER}@${SERVER_HOST}:${REMOTE_MERGE_SCRIPT}"
|
||||
|
||||
ssh_remote "sudo mkdir -p '${REMOTE_BACKUP_DIR}' && sudo chown root:root '${REMOTE_BACKUP_DIR}'"
|
||||
ssh_remote "sudo test -f '${LEGACY_DB}'"
|
||||
ssh_remote "sudo test -f '${TARGET_DB}'"
|
||||
ssh_remote "sudo cp -a '${LEGACY_DB}' '${REMOTE_BACKUP_DIR}/legacy-root-sqlite.db' && sudo cp -a '${TARGET_DB}' '${REMOTE_BACKUP_DIR}/target-before-merge-sqlite.db'"
|
||||
ssh_remote "sudo systemctl stop activitywatch-server.service || true"
|
||||
ssh_remote "sudo python3 '${REMOTE_MERGE_SCRIPT}' --base '${LEGACY_DB}' --overlay '${TARGET_DB}' --output '${REMOTE_BACKUP_DIR}/sqlite.merged.db'"
|
||||
ssh_remote "sudo install -o activitywatch -g activitywatch -m 0644 '${REMOTE_BACKUP_DIR}/sqlite.merged.db' '${TARGET_DB}'"
|
||||
|
||||
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_server.yml
|
||||
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_windows.yml
|
||||
ansible-playbook -i ansible/inventory.ini ansible/post_validate_aw_windows.yml
|
||||
|
||||
python3 - <<'PY'
|
||||
import json, urllib.request
|
||||
base = 'http://10.10.10.13:5600'
|
||||
window_payload = {
|
||||
'timeperiods': ['2026-04-29T00:00:00+03:00/2026-04-29T23:59:59+03:00'],
|
||||
'query': [
|
||||
'window_events = query_bucket(find_bucket("aw-watcher-window_SHARKON2025"));',
|
||||
'RETURN = window_events;'
|
||||
]
|
||||
}
|
||||
req = urllib.request.Request(base + '/api/0/query/', data=json.dumps(window_payload).encode(), method='POST', headers={'Content-Type': 'application/json', 'Origin': 'http://10.10.10.13:5600'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
window_count = len(data[0]) if isinstance(data, list) and data else 0
|
||||
if window_count <= 0:
|
||||
raise SystemExit('no historical window data restored for 2026-04-29')
|
||||
with urllib.request.urlopen(base + '/api/0/settings/') as response:
|
||||
settings = json.loads(response.read().decode())
|
||||
if settings.get('always_active_pattern') != 'aw-watcher-window':
|
||||
raise SystemExit('always_active_pattern is not configured')
|
||||
print(json.dumps({'restored_window_events_2026_04_29': window_count, 'always_active_pattern': settings.get('always_active_pattern')}, ensure_ascii=False))
|
||||
PY
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
timestamp() { date +"%Y%m%d-%H%M%S"; }
|
||||
|
||||
LOG_DIR="${ROOT_DIR}/.rollout-logs/$(timestamp)"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
log() { printf "%s %s\n" "$(date +"%F %T")" "$*" | tee -a "${LOG_DIR}/rollout.log" >&2; }
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || { log "ERROR: missing command: $1"; exit 127; }
|
||||
}
|
||||
|
||||
prompt_secret() {
|
||||
local var_name="$1"
|
||||
local prompt="$2"
|
||||
if [[ -n "${!var_name:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
read -r -s -p "${prompt}: " "$var_name"
|
||||
echo
|
||||
export "$var_name"
|
||||
}
|
||||
|
||||
require_cmd git
|
||||
require_cmd ansible-playbook
|
||||
require_cmd ansible
|
||||
|
||||
log "Repo: ${ROOT_DIR}"
|
||||
log "Branch: $(git branch --show-current)"
|
||||
|
||||
log "Running local quality gate..."
|
||||
./scripts/quality-gate.sh | tee -a "${LOG_DIR}/quality-gate.log"
|
||||
|
||||
if [[ -f "${ROOT_DIR}/secrets/runtime.env" ]]; then
|
||||
log "Loading secrets/runtime.env"
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "${ROOT_DIR}/secrets/runtime.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [[ ! -f ansible/inventory.ini ]]; then
|
||||
log "ERROR: missing ansible/inventory.ini"
|
||||
log "Hint: copy ansible/inventory.example.ini -> ansible/inventory.ini and adjust hosts."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -t 0 ]]; then
|
||||
prompt_secret AW_SSH_PASSWORD "Enter SSH password for aw_server (root@10.10.10.13)"
|
||||
prompt_secret AW_WINRM_PASSWORD "Enter WinRM password for aw_windows (192.168.100.21)"
|
||||
fi
|
||||
|
||||
if [[ -z "${AW_SSH_PASSWORD:-}" || -z "${AW_WINRM_PASSWORD:-}" ]]; then
|
||||
log "ERROR: missing AW_SSH_PASSWORD or AW_WINRM_PASSWORD."
|
||||
log "Provide them via interactive prompt (TTY) or create secrets/runtime.env."
|
||||
exit 3
|
||||
fi
|
||||
|
||||
log "Preflight connectivity..."
|
||||
ansible -i ansible/inventory.ini aw_server -m ping | tee -a "${LOG_DIR}/ping_aw_server.log"
|
||||
ansible -i ansible/inventory.ini aw_windows -m win_ping | tee -a "${LOG_DIR}/ping_aw_windows.log"
|
||||
|
||||
log "Dry-run aw_server..."
|
||||
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_server.yml --check --diff | tee -a "${LOG_DIR}/check_aw_server.log"
|
||||
|
||||
log "Deploy aw_server..."
|
||||
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_server.yml | tee -a "${LOG_DIR}/deploy_aw_server.log"
|
||||
|
||||
log "Dry-run aw_windows..."
|
||||
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_windows.yml --check --diff | tee -a "${LOG_DIR}/check_aw_windows.log"
|
||||
|
||||
log "Deploy aw_windows..."
|
||||
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_windows.yml | tee -a "${LOG_DIR}/deploy_aw_windows.log"
|
||||
|
||||
log "DONE. Logs: ${LOG_DIR}"
|
||||
@@ -20,6 +20,17 @@ function New-ActivityWatchDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
function Enable-ActivityWatchPrintTelemetry {
|
||||
$policyPath = 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers'
|
||||
if (-not (Test-Path -LiteralPath $policyPath)) {
|
||||
New-Item -Path $policyPath -Force | Out-Null
|
||||
}
|
||||
New-ItemProperty -Path $policyPath -Name 'ShowJobTitleInEventLogs' -Value 1 -PropertyType DWord -Force | Out-Null
|
||||
|
||||
& wevtutil.exe sl 'Microsoft-Windows-PrintService/Operational' /e:true | Out-Null
|
||||
}
|
||||
|
||||
|
||||
function Get-ActivityWatchPackageUrl {
|
||||
param(
|
||||
[string]$Version = 'v0.13.2'
|
||||
@@ -49,7 +60,9 @@ function Get-ActivityWatchArchive {
|
||||
}
|
||||
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$archivePath = Join-Path $WorkingRoot ("activitywatch-{0}.zip" -f $Version.TrimStart('v'))
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$suffix = ([guid]::NewGuid().Guid.Substring(0, 8))
|
||||
$archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix)
|
||||
Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath
|
||||
return $archivePath
|
||||
}
|
||||
@@ -85,6 +98,16 @@ function Install-ActivityWatchPackage {
|
||||
New-ActivityWatchDirectory -Path $WorkingRoot
|
||||
New-ActivityWatchDirectory -Path $BackupRoot
|
||||
|
||||
# Ensure nothing is holding locks inside InstallRoot during upgrade.
|
||||
foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) {
|
||||
try {
|
||||
Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
$extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid)
|
||||
if (Test-Path -LiteralPath $extractRoot) {
|
||||
Remove-Item -LiteralPath $extractRoot -Recurse -Force
|
||||
@@ -243,6 +266,8 @@ function Copy-ActivityWatchCollectorAssets {
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$EndpointCollectorScriptSource,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FileCollectorScriptSource,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SessionCollectorScriptSource,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExampleRulesSource,
|
||||
@@ -258,6 +283,7 @@ function Copy-ActivityWatchCollectorAssets {
|
||||
|
||||
$collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1'
|
||||
$endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1'
|
||||
$fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1'
|
||||
$sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1'
|
||||
$exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json'
|
||||
$rulesTarget = Join-Path $StateRoot 'web-category-rules.json'
|
||||
@@ -266,6 +292,7 @@ function Copy-ActivityWatchCollectorAssets {
|
||||
|
||||
Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force
|
||||
Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force
|
||||
Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force
|
||||
Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force
|
||||
Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force
|
||||
Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force
|
||||
@@ -286,6 +313,7 @@ function Copy-ActivityWatchCollectorAssets {
|
||||
return [pscustomobject]@{
|
||||
CollectorScript = $collectorTarget
|
||||
EndpointCollectorScript = $endpointCollectorTarget
|
||||
FileCollectorScript = $fileCollectorTarget
|
||||
SessionCollectorScript = $sessionCollectorTarget
|
||||
ExampleRules = $exampleRulesTarget
|
||||
ActiveRules = $rulesTarget
|
||||
@@ -313,6 +341,8 @@ function New-ActivityWatchDeploymentConfig {
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$EndpointCollectorScript,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FileCollectorScript,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SessionCollectorScript,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RulesPath,
|
||||
@@ -326,6 +356,7 @@ function New-ActivityWatchDeploymentConfig {
|
||||
[int]$RecoveryIntervalSeconds,
|
||||
[bool]$AfkEnabled = $true,
|
||||
[bool]$WindowEnabled = $true,
|
||||
[bool]$FileOpsEnabled = $true,
|
||||
[bool]$LocalAgentLogsEnabled = $true,
|
||||
[bool]$IncidentCaptureEnabled = $true,
|
||||
[bool]$IncidentScreenshotEnabled = $true,
|
||||
@@ -356,6 +387,7 @@ function New-ActivityWatchDeploymentConfig {
|
||||
logsRoot = $LogsRoot
|
||||
collectorScript = $CollectorScript
|
||||
endpointCollectorScript = $EndpointCollectorScript
|
||||
fileCollectorScript = $FileCollectorScript
|
||||
sessionCollectorScript = $SessionCollectorScript
|
||||
rulesPath = $RulesPath
|
||||
policyPath = $PolicyPath
|
||||
@@ -369,6 +401,7 @@ function New-ActivityWatchDeploymentConfig {
|
||||
collectors = [pscustomobject]@{
|
||||
afkEnabled = $AfkEnabled
|
||||
windowEnabled = $WindowEnabled
|
||||
fileOpsEnabled = $FileOpsEnabled
|
||||
}
|
||||
logging = [pscustomobject]@{
|
||||
localAgentLogsEnabled = $LocalAgentLogsEnabled
|
||||
@@ -443,6 +476,9 @@ param(
|
||||
Set-StrictMode -Version Latest
|
||||
`$ErrorActionPreference = 'Stop'
|
||||
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
Add-Type -AssemblyName System.Net.Http
|
||||
|
||||
function Get-DeploymentConfig {
|
||||
param([string]`$Path)
|
||||
return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json
|
||||
@@ -480,8 +516,21 @@ function Invoke-AwJsonPost {
|
||||
[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
|
||||
`$httpClient = New-Object System.Net.Http.HttpClient
|
||||
try {
|
||||
`$content = New-Object System.Net.Http.StringContent(`$Json, [System.Text.Encoding]::UTF8, 'application/json')
|
||||
`$response = `$httpClient.PostAsync(`$Uri, `$content).Result
|
||||
if (-not `$response.IsSuccessStatusCode) {
|
||||
return `$false
|
||||
}
|
||||
return `$true
|
||||
}
|
||||
catch {
|
||||
return `$false
|
||||
}
|
||||
finally {
|
||||
`$httpClient.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-Bucket {
|
||||
@@ -510,7 +559,9 @@ function Ensure-Bucket {
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
try {
|
||||
Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body
|
||||
if (-not (Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
@@ -614,6 +665,10 @@ function Start-CollectorScriptIfNeeded {
|
||||
[int]`$SessionId
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath `$ScriptPath)) {
|
||||
return
|
||||
}
|
||||
@@ -634,18 +689,21 @@ function Start-CollectorScriptIfNeeded {
|
||||
`$config = Get-DeploymentConfig -Path `$ConfigPath
|
||||
`$sessionId = (Get-Process -Id `$PID).SessionId
|
||||
`$installRoot = [string]`$config.paths.installRoot
|
||||
`$stateRoot = [string]`$config.paths.stateRoot
|
||||
`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port
|
||||
`$script:Hostname = `$env:COMPUTERNAME
|
||||
`$script:KnownBuckets = @{}
|
||||
`$collectorScript = [string]`$config.paths.collectorScript
|
||||
`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { '' }
|
||||
`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { '' }
|
||||
`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' }
|
||||
`$fileCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]`$config.paths.fileCollectorScript } else { Join-Path `$stateRoot 'file-operations-collector.ps1' }
|
||||
`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' }
|
||||
`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe'
|
||||
`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe'
|
||||
`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port)
|
||||
`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
`$afkEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]`$config.collectors.afkEnabled } else { `$true }
|
||||
`$windowEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]`$config.collectors.windowEnabled } else { `$true }
|
||||
`$fileOpsEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]`$config.collectors.fileOpsEnabled } else { `$true }
|
||||
|
||||
if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) {
|
||||
throw "Не найден aw-watcher-afk.exe: `$afkExe"
|
||||
@@ -670,6 +728,9 @@ catch {
|
||||
}
|
||||
Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
||||
Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
||||
if (`$fileOpsEnabled) {
|
||||
Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
||||
}
|
||||
Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
||||
"@
|
||||
|
||||
@@ -891,6 +952,37 @@ function Get-ActivityWatchScheduledTaskByCommand {
|
||||
return $null
|
||||
}
|
||||
|
||||
function Remove-StaleActivityWatchUserTasks {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[pscustomobject[]]$TaskDefinitions,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LaunchScriptPath
|
||||
)
|
||||
|
||||
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath
|
||||
$desiredTaskNames = @($TaskDefinitions | ForEach-Object { [string]$_.LaunchTaskName })
|
||||
|
||||
foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) {
|
||||
$taskName = [string]$candidate.TaskName
|
||||
if ($desiredTaskNames -contains $taskName) {
|
||||
continue
|
||||
}
|
||||
|
||||
$usesCurrentLauncher = $false
|
||||
foreach ($action in @($candidate.Actions)) {
|
||||
if ([string]$action.Arguments -like "*$launcherPath*") {
|
||||
$usesCurrentLauncher = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($usesCurrentLauncher) {
|
||||
Remove-ActivityWatchScheduledTask -TaskName $taskName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Register-ActivityWatchUserTasks {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@@ -904,6 +996,7 @@ function Register-ActivityWatchUserTasks {
|
||||
$wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe'
|
||||
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath
|
||||
Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath
|
||||
Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath
|
||||
|
||||
foreach ($definition in $TaskDefinitions) {
|
||||
$action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`""
|
||||
|
||||
@@ -18,6 +18,7 @@ param(
|
||||
[int]$RecoveryIntervalSeconds = 180,
|
||||
[bool]$AfkEnabled = $true,
|
||||
[bool]$WindowEnabled = $true,
|
||||
[bool]$FileOpsEnabled = $true,
|
||||
[bool]$LocalAgentLogsEnabled = $false,
|
||||
[bool]$IncidentCaptureEnabled = $true,
|
||||
[bool]$IncidentScreenshotEnabled = $true,
|
||||
@@ -44,12 +45,14 @@ $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
|
||||
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
|
||||
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
|
||||
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
|
||||
$fileCollectorSource = Join-Path $PSScriptRoot 'file-operations-collector.ps1'
|
||||
$sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1'
|
||||
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
|
||||
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
|
||||
|
||||
New-ActivityWatchDirectory -Path $StateRoot
|
||||
New-ActivityWatchDirectory -Path $logsRoot
|
||||
Enable-ActivityWatchPrintTelemetry
|
||||
|
||||
$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $Version -WorkingRoot $workingRoot
|
||||
Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $InstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null
|
||||
@@ -58,6 +61,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
|
||||
$assetResult = Copy-ActivityWatchCollectorAssets `
|
||||
-CollectorScriptSource $collectorSource `
|
||||
-EndpointCollectorScriptSource $endpointCollectorSource `
|
||||
-FileCollectorScriptSource $fileCollectorSource `
|
||||
-SessionCollectorScriptSource $sessionCollectorSource `
|
||||
-ExampleRulesSource $exampleRulesSource `
|
||||
-ExamplePolicySource $examplePolicySource `
|
||||
@@ -78,6 +82,7 @@ $config = New-ActivityWatchDeploymentConfig `
|
||||
-LogsRoot $logsRoot `
|
||||
-CollectorScript $assetResult.CollectorScript `
|
||||
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
|
||||
-FileCollectorScript $assetResult.FileCollectorScript `
|
||||
-SessionCollectorScript $assetResult.SessionCollectorScript `
|
||||
-RulesPath $assetResult.ActiveRules `
|
||||
-PolicyPath $assetResult.ActivePolicy `
|
||||
@@ -86,6 +91,7 @@ $config = New-ActivityWatchDeploymentConfig `
|
||||
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
||||
-AfkEnabled $AfkEnabled `
|
||||
-WindowEnabled $WindowEnabled `
|
||||
-FileOpsEnabled $FileOpsEnabled `
|
||||
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
||||
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
||||
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
||||
|
||||
@@ -18,6 +18,7 @@ param(
|
||||
[int]$RecoveryIntervalSeconds = 180,
|
||||
[bool]$AfkEnabled = $true,
|
||||
[bool]$WindowEnabled = $true,
|
||||
[bool]$FileOpsEnabled = $true,
|
||||
[bool]$LocalAgentLogsEnabled = $false,
|
||||
[bool]$IncidentCaptureEnabled = $true,
|
||||
[bool]$IncidentScreenshotEnabled = $true,
|
||||
@@ -64,6 +65,7 @@ if (-not (Test-Path -LiteralPath $deployScript)) {
|
||||
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
||||
-AfkEnabled $AfkEnabled `
|
||||
-WindowEnabled $WindowEnabled `
|
||||
-FileOpsEnabled $FileOpsEnabled `
|
||||
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
||||
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
||||
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
||||
@@ -86,6 +88,7 @@ if (-not $SkipHardening) {
|
||||
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
||||
-AfkEnabled $AfkEnabled `
|
||||
-WindowEnabled $WindowEnabled `
|
||||
-FileOpsEnabled $FileOpsEnabled `
|
||||
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
||||
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
||||
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
||||
@@ -112,6 +115,7 @@ $report = [ordered]@{
|
||||
collectors = [ordered]@{
|
||||
afkEnabled = $AfkEnabled
|
||||
windowEnabled = $WindowEnabled
|
||||
fileOpsEnabled = $FileOpsEnabled
|
||||
}
|
||||
hardeningApplied = (-not $SkipHardening)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[CmdletBinding()]
|
||||
\xEF\xBB\xBF-ne \xEF\xBB\xBF
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
|
||||
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
|
||||
[string]$ServerHost,
|
||||
[int]$ServerPort,
|
||||
[ValidateSet('http', 'https')]
|
||||
@@ -81,7 +82,7 @@ function Send-EndpointSignalHeartbeat {
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'endpoint-signals-awatch-rus'
|
||||
source = 'endpoint-signals-phase2'
|
||||
} + $Data
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
|
||||
@@ -122,7 +123,7 @@ function Send-DlpIncidentHeartbeat {
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'endpoint-signals-awatch-rus'
|
||||
source = 'endpoint-signals-phase2'
|
||||
} + $Data + $captureData
|
||||
} | ConvertTo-Json -Depth 7 -Compress
|
||||
|
||||
@@ -210,11 +211,104 @@ function Capture-IncidentScreenshot {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message)
|
||||
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 }
|
||||
@@ -246,7 +340,7 @@ function Load-DlpPolicy {
|
||||
}
|
||||
|
||||
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
|
||||
Write-EndpointLog ("DLP-политика не найдена, используются значения по умолчанию: {0}" -f $Path)
|
||||
Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -266,7 +360,7 @@ function Load-DlpPolicy {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message)
|
||||
Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,13 +413,20 @@ function Evaluate-ClipboardRules {
|
||||
|
||||
$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 { "Сработало правило буфера обмена: $ruleId" }
|
||||
$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 ("инцидент буфера обмена правило={0} действие={1} важность={2}" -f $ruleId, $action, $severity)
|
||||
Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,13 +448,20 @@ function Evaluate-UsbRules {
|
||||
|
||||
$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-носителя: $ruleId" }
|
||||
$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 ("инцидент USB правило={0} действие={1} важность={2} диск={3}" -f $ruleId, $action, $severity, $DriveLetter)
|
||||
Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,14 +493,21 @@ function Evaluate-PrintRules {
|
||||
|
||||
$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 { "Сработало правило печати: $ruleId" }
|
||||
$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 ("инцидент печати правило={0} действие={1} важность={2} принтер={3}" -f $ruleId, $action, $severity, $PrinterName)
|
||||
Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,52 +517,6 @@ function Test-LooksLikeMojibakeQuestionMarks {
|
||||
return $Value -match '\?{2,}'
|
||||
}
|
||||
|
||||
function Test-DocumentNameNeedsFallback {
|
||||
param([AllowNull()][string]$Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
|
||||
$trimmed = $Value.Trim()
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $trimmed) { return $true }
|
||||
if ($trimmed -match '^[0-9]+$') { return $true }
|
||||
if ($trimmed -match '^(?i)(print document|document|local downlevel document)$') { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-EventXmlValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][xml]$EventXml,
|
||||
[Parameter(Mandatory = $true)][string]$Name
|
||||
)
|
||||
|
||||
$node = $EventXml.Event.UserData.DocumentPrinted.$Name
|
||||
if ($null -ne $node) {
|
||||
return [string]$node
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function Get-PrintJobPrinterName {
|
||||
param(
|
||||
[AllowNull()][string]$JobName,
|
||||
[AllowNull()][string]$FallbackPrinterName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($JobName)) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($FallbackPrinterName)) {
|
||||
return $FallbackPrinterName.Trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
$parts = $JobName -split ',', 2
|
||||
if ($parts.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($parts[0])) {
|
||||
return $parts[0].Trim()
|
||||
}
|
||||
|
||||
return $JobName.Trim()
|
||||
}
|
||||
|
||||
function Normalize-OwnerForMatch {
|
||||
param([AllowNull()][string]$Value)
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
|
||||
@@ -515,50 +584,13 @@ function Get-PrintServiceEventSummary {
|
||||
$propertyValues += [string]$prop.Value
|
||||
}
|
||||
|
||||
$xml = $null
|
||||
try {
|
||||
$xml = [xml]$Event.ToXml()
|
||||
}
|
||||
catch {
|
||||
}
|
||||
|
||||
$jobId = ''
|
||||
$documentName = ''
|
||||
$owner = ''
|
||||
$portName = ''
|
||||
$printerName = ''
|
||||
$sizeBytes = ''
|
||||
$pageCount = ''
|
||||
|
||||
if ($xml) {
|
||||
$jobId = Get-EventXmlValue -EventXml $xml -Name 'Param1'
|
||||
$documentName = Get-EventXmlValue -EventXml $xml -Name 'Param2'
|
||||
$owner = Get-EventXmlValue -EventXml $xml -Name 'Param3'
|
||||
$portName = Get-EventXmlValue -EventXml $xml -Name 'Param4'
|
||||
$printerName = Get-EventXmlValue -EventXml $xml -Name 'Param5'
|
||||
$sizeBytes = Get-EventXmlValue -EventXml $xml -Name 'Param7'
|
||||
$pageCount = Get-EventXmlValue -EventXml $xml -Name 'Param8'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($jobId) -and $props.Count -ge 1) { $jobId = [string]$props[0].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($documentName) -and $props.Count -ge 2) { $documentName = [string]$props[1].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($owner) -and $props.Count -ge 3) { $owner = [string]$props[2].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($portName) -and $props.Count -ge 4) { $portName = [string]$props[3].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($printerName) -and $props.Count -ge 5) { $printerName = [string]$props[4].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($sizeBytes) -and $props.Count -ge 7) { $sizeBytes = [string]$props[6].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($pageCount) -and $props.Count -ge 8) { $pageCount = [string]$props[7].Value }
|
||||
|
||||
[pscustomobject]@{
|
||||
RecordId = [string]$Event.RecordId
|
||||
TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' }
|
||||
PropertyCount = $props.Count
|
||||
JobId = $jobId
|
||||
DocumentName = $documentName
|
||||
Owner = $owner
|
||||
PortName = $portName
|
||||
PrinterName = $printerName
|
||||
SizeBytes = $sizeBytes
|
||||
PageCount = $pageCount
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -571,7 +603,7 @@ function Get-PrintServiceDocumentFallback {
|
||||
)
|
||||
|
||||
$preferred = [string]$EventSummary.DocumentName
|
||||
if (-not (Test-DocumentNameNeedsFallback -Value $preferred)) {
|
||||
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
|
||||
return $preferred
|
||||
}
|
||||
|
||||
@@ -582,10 +614,9 @@ function Get-PrintServiceDocumentFallback {
|
||||
$candidate = [string]$value
|
||||
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
|
||||
if ($candidate -eq $preferred) { continue }
|
||||
if ($EventSummary.JobId -and $candidate -eq [string]$EventSummary.JobId) { continue }
|
||||
if ($Owner -and $candidate -like "*$Owner*") { continue }
|
||||
if ($PrinterName -and $candidate -like "*$PrinterName*") { continue }
|
||||
if (Test-DocumentNameNeedsFallback -Value $candidate) { continue }
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }
|
||||
|
||||
if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
|
||||
$pathCandidates.Add($candidate)
|
||||
@@ -630,7 +661,7 @@ function Write-PrintServiceEventTrace {
|
||||
}
|
||||
|
||||
Write-EndpointLog (
|
||||
'printservice-307 этап={0} recordId={1} время={2} владелец={3} принтер={4} документ={5} итоговыйДокумент={6} свойства=[{7}] причина={8}' -f
|
||||
'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,
|
||||
@@ -645,7 +676,6 @@ function Write-PrintServiceEventTrace {
|
||||
|
||||
function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
param(
|
||||
[string]$JobId,
|
||||
[string]$Owner,
|
||||
[string]$PrinterName
|
||||
)
|
||||
@@ -663,41 +693,32 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
$summary = Get-PrintServiceEventSummary -Event $event
|
||||
$resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName
|
||||
|
||||
$jobMatches = if ($JobId) { [string]$summary.JobId -eq [string]$JobId } else { $true }
|
||||
$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 ($JobId -and -not $jobMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-jobid-strict' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
if ($Owner -and -not $ownerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-strict' -ResolvedDocument $resolvedDocument
|
||||
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 'несовпадение-принтера-strict' -ResolvedDocument $resolvedDocument
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($JobId -and (-not $jobMatches) -and $Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-и-принтера-relaxed' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
if ((-not $JobId) -and $Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-и-принтера-relaxed' -ResolvedDocument $resolvedDocument
|
||||
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 ('нет-кандидата-документа-' + $pass) -ResolvedDocument ''
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument ''
|
||||
continue
|
||||
}
|
||||
|
||||
$matchReasonBase = if (Test-DocumentNameNeedsFallback -Value $summary.DocumentName) { 'использован-резервный-вариант' } else { 'напрямую' }
|
||||
$matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument
|
||||
return $resolvedDocument
|
||||
}
|
||||
@@ -710,15 +731,15 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
}
|
||||
|
||||
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
|
||||
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' }
|
||||
$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' }
|
||||
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
|
||||
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
|
||||
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) }
|
||||
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
|
||||
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
|
||||
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'ActivityWatch-Phase2\\incident-artifacts' }
|
||||
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
|
||||
|
||||
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
|
||||
@@ -742,7 +763,7 @@ $script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
|
||||
$script:ScreenshotTypesLoaded = $false
|
||||
|
||||
Load-DlpPolicy -Path $resolvedPolicyPath
|
||||
Write-EndpointLog ("endpoint-коллектор запущен для {0}" -f $script:ApiBase)
|
||||
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
@@ -803,13 +824,13 @@ while ($true) {
|
||||
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
|
||||
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
|
||||
|
||||
$printerName = Get-PrintJobPrinterName -JobName ([string]$job.Name) -FallbackPrinterName ([string]$job.DriverName)
|
||||
$printerName = [string]$job.Name
|
||||
$documentName = [string]$job.Document
|
||||
$owner = [string]$job.Owner
|
||||
$documentNameOriginal = $documentName
|
||||
|
||||
if (Test-DocumentNameNeedsFallback -Value $documentName) {
|
||||
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -JobId $jobId -Owner $owner -PrinterName $printerName
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
|
||||
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
|
||||
if ($eventDocumentName) {
|
||||
$documentName = $eventDocumentName
|
||||
}
|
||||
@@ -820,7 +841,6 @@ while ($true) {
|
||||
documentName = $documentName
|
||||
documentNameOriginal = $documentNameOriginal
|
||||
owner = $owner
|
||||
printJobId = $jobId
|
||||
}
|
||||
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
|
||||
}
|
||||
@@ -884,7 +904,7 @@ while ($true) {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("ошибка коллектора: {0}" -f $_.Exception.Message)
|
||||
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds $resolvedPollSeconds
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
DLP email outbound collector for AWatch-rus (Phase 2.5).
|
||||
Monitors outgoing email via Outlook COM Sent Items polling
|
||||
and/or SMTP network connection detection.
|
||||
|
||||
.DESCRIPTION
|
||||
Two collection modes (configurable, can run simultaneously):
|
||||
- outlook : Polls Outlook Sent Items via COM for new messages.
|
||||
- smtp : Monitors SMTP connections (ports 25/587/465) via
|
||||
Get-NetTCPConnection for any process sending mail.
|
||||
|
||||
Sends heartbeats to AW bucket `aw-email-monitor_<host>`.
|
||||
Evaluates DLP policy rules from `endpoint.email[]` section.
|
||||
Supports enforcement: action="block" moves the email to Drafts
|
||||
(Outlook mode) or logs with enforced=false (SMTP mode).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
|
||||
[string]$ServerHost,
|
||||
[int]$ServerPort,
|
||||
[ValidateSet('http', 'https')]
|
||||
[string]$ServerScheme,
|
||||
[string]$PolicyPath,
|
||||
[string]$LogPath,
|
||||
[int]$PollSeconds,
|
||||
[ValidateSet('outlook', 'smtp', 'both')]
|
||||
[string]$Mode = 'both'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared infrastructure (mirrors other collectors)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Get-DeploymentConfig {
|
||||
param([string]$Path)
|
||||
if ($Path -and (Test-Path -LiteralPath $Path)) {
|
||||
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Write-CollectorLog {
|
||||
param([string]$Message)
|
||||
if (-not $script:LocalAgentLogsEnabled) { return }
|
||||
try {
|
||||
Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
function Invoke-AwJsonPost {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Uri,
|
||||
[Parameter(Mandatory = $true)][string]$Json
|
||||
)
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
|
||||
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
|
||||
}
|
||||
|
||||
function Ensure-Bucket {
|
||||
param(
|
||||
[string]$BucketId,
|
||||
[string]$ClientName,
|
||||
[string]$BucketType
|
||||
)
|
||||
if ($script:KnownBuckets.ContainsKey($BucketId)) { return }
|
||||
$body = @{
|
||||
client = $ClientName
|
||||
type = $BucketType
|
||||
hostname = $script:Hostname
|
||||
} | ConvertTo-Json -Compress
|
||||
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
|
||||
$script:KnownBuckets[$BucketId] = $true
|
||||
}
|
||||
|
||||
function Get-StringHash {
|
||||
param([AllowNull()][string]$Value)
|
||||
if ($null -eq $Value) { return $null }
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes($Value)
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join ''
|
||||
}
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
function Send-EmailHeartbeat {
|
||||
param(
|
||||
[string]$SignalType,
|
||||
[hashtable]$Data
|
||||
)
|
||||
$bucketId = 'aw-email-monitor_' + $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-email-monitor' -BucketType 'aw.dlp.email'
|
||||
$payload = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = @{
|
||||
signalType = $SignalType
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'email-outbound-collector'
|
||||
} + $Data
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
|
||||
}
|
||||
|
||||
function Send-EmailIncidentHeartbeat {
|
||||
param(
|
||||
[string]$RuleId,
|
||||
[string]$Action,
|
||||
[string]$Severity,
|
||||
[string]$Message,
|
||||
[hashtable]$Data
|
||||
)
|
||||
$bucketId = 'aw-dlp-incidents_' + $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident'
|
||||
$payload = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = @{
|
||||
ruleId = $RuleId
|
||||
action = $Action
|
||||
severity = $Severity
|
||||
message = $Message
|
||||
signalType = 'email_outbound'
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'email-outbound-collector'
|
||||
} + $Data
|
||||
} | ConvertTo-Json -Depth 7 -Compress
|
||||
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
|
||||
}
|
||||
|
||||
function Show-EnforcementNotification {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Title,
|
||||
[Parameter(Mandatory = $true)][string]$Body
|
||||
)
|
||||
try {
|
||||
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
|
||||
$icon = New-Object System.Windows.Forms.NotifyIcon
|
||||
$icon.Icon = [System.Drawing.SystemIcons]::Warning
|
||||
$icon.BalloonTipTitle = $Title
|
||||
$icon.BalloonTipText = $Body
|
||||
$icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning
|
||||
$icon.Visible = $true
|
||||
$icon.ShowBalloonTip(5000)
|
||||
Start-Sleep -Milliseconds 200
|
||||
$icon.Dispose()
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DLP Policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Load-EmailPolicy {
|
||||
param([string]$Path)
|
||||
|
||||
$script:Policy = [ordered]@{
|
||||
defaults = [ordered]@{
|
||||
enabled = $true
|
||||
cooldownSeconds = 300
|
||||
action = 'alert'
|
||||
severity = 'medium'
|
||||
}
|
||||
endpoint = [ordered]@{
|
||||
email = @()
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
|
||||
Write-CollectorLog ("policy not found, using defaults: {0}" -f $Path)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
if ($raw.defaults) {
|
||||
if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled }
|
||||
if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds }
|
||||
if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action }
|
||||
if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity }
|
||||
}
|
||||
if ($raw.endpoint -and $raw.endpoint.email) {
|
||||
$script:Policy.endpoint.email = @($raw.endpoint.email)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("policy parse failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
function Should-EmitByCooldown {
|
||||
param(
|
||||
[string]$Fingerprint,
|
||||
[int]$CooldownSeconds
|
||||
)
|
||||
$now = (Get-Date).ToUniversalTime()
|
||||
if ($script:Cooldown.ContainsKey($Fingerprint)) {
|
||||
$last = [datetime]$script:Cooldown[$Fingerprint]
|
||||
if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
$script:Cooldown[$Fingerprint] = $now
|
||||
return $true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Email DLP rule evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Evaluate-EmailRules {
|
||||
param(
|
||||
[string]$Subject,
|
||||
[string]$RecipientsJoined,
|
||||
[string]$SenderAddress,
|
||||
[int]$AttachmentCount,
|
||||
[string]$AttachmentNames,
|
||||
[int]$BodyLength,
|
||||
[string]$MessageId,
|
||||
$OutlookMailItem
|
||||
)
|
||||
|
||||
foreach ($rule in @($script:Policy.endpoint.email)) {
|
||||
if (-not $rule) { continue }
|
||||
if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
|
||||
$ruleId = [string]$rule.id
|
||||
if (-not $ruleId) { continue }
|
||||
|
||||
$matched = $true
|
||||
|
||||
if ($rule.subjectRegex) {
|
||||
$matched = $matched -and ($Subject -match [string]$rule.subjectRegex)
|
||||
}
|
||||
if ($rule.recipientRegex) {
|
||||
$matched = $matched -and ($RecipientsJoined -match [string]$rule.recipientRegex)
|
||||
}
|
||||
if ($rule.senderRegex) {
|
||||
$matched = $matched -and ($SenderAddress -match [string]$rule.senderRegex)
|
||||
}
|
||||
if ($rule.attachmentRegex -and $AttachmentNames) {
|
||||
$matched = $matched -and ($AttachmentNames -match [string]$rule.attachmentRegex)
|
||||
}
|
||||
if ($rule.minAttachments) {
|
||||
$matched = $matched -and ($AttachmentCount -ge [int]$rule.minAttachments)
|
||||
}
|
||||
if ($rule.minBodyLength) {
|
||||
$matched = $matched -and ($BodyLength -ge [int]$rule.minBodyLength)
|
||||
}
|
||||
if ($rule.externalOnly -and [bool]$rule.externalOnly) {
|
||||
$internalDomain = if ($rule.internalDomain) { [string]$rule.internalDomain } else { '' }
|
||||
if ($internalDomain -and $RecipientsJoined -notmatch [regex]::Escape($internalDomain)) {
|
||||
# all recipients are external — continue matching
|
||||
}
|
||||
elseif ($internalDomain) {
|
||||
$matched = $false
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $matched) { continue }
|
||||
|
||||
$cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
|
||||
$fingerprint = "email|$ruleId|$MessageId|$env:USERNAME"
|
||||
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
|
||||
|
||||
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
|
||||
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
|
||||
$message = if ($rule.message) { [string]$rule.message } else { "Email rule matched: $ruleId" }
|
||||
|
||||
$enforced = $false
|
||||
if ($action -eq 'block' -and $null -ne $OutlookMailItem) {
|
||||
$enforced = Invoke-EmailEnforcement -MailItem $OutlookMailItem -RuleId $ruleId
|
||||
Show-EnforcementNotification -Title 'DLP: письмо перемещено в черновики' -Body $message
|
||||
}
|
||||
elseif ($action -eq 'block') {
|
||||
Show-EnforcementNotification -Title 'DLP: обнаружена отправка письма' -Body $message
|
||||
}
|
||||
|
||||
Send-EmailIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -Data @{
|
||||
subject = (Get-StringHash -Value $Subject)
|
||||
recipients = (Get-StringHash -Value $RecipientsJoined)
|
||||
sender = $SenderAddress
|
||||
attachmentCount = $AttachmentCount
|
||||
attachmentNames = $AttachmentNames
|
||||
bodyLength = $BodyLength
|
||||
enforced = $enforced
|
||||
}
|
||||
Write-CollectorLog ("incident email rule={0} action={1} severity={2} enforced={3} subject_hash={4}" -f $ruleId, $action, $severity, $enforced, (Get-StringHash -Value $Subject))
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-EmailEnforcement {
|
||||
[OutputType([bool])]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$MailItem,
|
||||
[string]$RuleId
|
||||
)
|
||||
try {
|
||||
$draftsFolder = $script:OutlookNamespace.GetDefaultFolder(16) # olFolderDrafts
|
||||
$MailItem.Move($draftsFolder) | Out-Null
|
||||
Write-CollectorLog ("enforcement: email moved to Drafts rule={0} subject_hash={1}" -f $RuleId, (Get-StringHash -Value $MailItem.Subject))
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("enforcement: email move to Drafts failed rule={0}: {1}" -f $RuleId, $_.Exception.Message)
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outlook Sent Items polling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Initialize-OutlookCom {
|
||||
try {
|
||||
$script:OutlookApp = New-Object -ComObject Outlook.Application
|
||||
$script:OutlookNamespace = $script:OutlookApp.GetNamespace('MAPI')
|
||||
$script:SentFolder = $script:OutlookNamespace.GetDefaultFolder(5) # olFolderSentMail
|
||||
Write-CollectorLog "Outlook COM initialized, Sent Items folder opened"
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("Outlook COM init failed: {0}" -f $_.Exception.Message)
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-OutlookSentItems {
|
||||
param([datetime]$Since)
|
||||
|
||||
$results = @()
|
||||
try {
|
||||
$items = $script:SentFolder.Items
|
||||
$items.Sort('[SentOn]', $true)
|
||||
|
||||
$filter = "[SentOn] >= '{0}'" -f $Since.ToString('MM/dd/yyyy HH:mm')
|
||||
$restricted = $items.Restrict($filter)
|
||||
|
||||
foreach ($item in $restricted) {
|
||||
try {
|
||||
if ($item.Class -ne 43) { continue } # olMail = 43
|
||||
|
||||
$recipients = @()
|
||||
for ($i = 1; $i -le $item.Recipients.Count; $i++) {
|
||||
$recip = $item.Recipients.Item($i)
|
||||
$recipients += [string]$recip.Address
|
||||
}
|
||||
|
||||
$attachmentNames = @()
|
||||
for ($i = 1; $i -le $item.Attachments.Count; $i++) {
|
||||
$attachmentNames += [string]$item.Attachments.Item($i).FileName
|
||||
}
|
||||
|
||||
$results += [pscustomobject]@{
|
||||
EntryID = [string]$item.EntryID
|
||||
Subject = [string]$item.Subject
|
||||
SenderAddress = [string]$item.SenderEmailAddress
|
||||
SenderName = [string]$item.SenderName
|
||||
Recipients = $recipients
|
||||
RecipientsJoined = ($recipients -join '; ')
|
||||
AttachmentCount = [int]$item.Attachments.Count
|
||||
AttachmentNames = ($attachmentNames -join '; ')
|
||||
BodyLength = if ($item.Body) { $item.Body.Length } else { 0 }
|
||||
SentOn = $item.SentOn
|
||||
MailItem = $item
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("Outlook Sent Items scan failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
return $results
|
||||
}
|
||||
|
||||
function Poll-OutlookSentItems {
|
||||
$items = Get-OutlookSentItems -Since $script:OutlookLastPoll
|
||||
|
||||
foreach ($item in $items) {
|
||||
$entryId = $item.EntryID
|
||||
if ($script:SeenEntryIds.ContainsKey($entryId)) { continue }
|
||||
$script:SeenEntryIds[$entryId] = (Get-Date).ToUniversalTime()
|
||||
|
||||
$subjectHash = Get-StringHash -Value $item.Subject
|
||||
|
||||
Send-EmailHeartbeat -SignalType 'email_sent' -Data @{
|
||||
subject = $subjectHash
|
||||
sender = [string]$item.SenderAddress
|
||||
senderName = [string]$item.SenderName
|
||||
recipientCount = $item.Recipients.Count
|
||||
recipients = (Get-StringHash -Value $item.RecipientsJoined)
|
||||
attachmentCount = [int]$item.AttachmentCount
|
||||
attachmentNames = [string]$item.AttachmentNames
|
||||
bodyLength = [int]$item.BodyLength
|
||||
sentOn = if ($item.SentOn) { $item.SentOn.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } else { '' }
|
||||
collectionMode = 'outlook'
|
||||
}
|
||||
Write-CollectorLog ("email_sent outlook subject_hash={0} to={1} attachments={2}" -f $subjectHash, $item.Recipients.Count, $item.AttachmentCount)
|
||||
|
||||
Evaluate-EmailRules `
|
||||
-Subject $item.Subject `
|
||||
-RecipientsJoined $item.RecipientsJoined `
|
||||
-SenderAddress $item.SenderAddress `
|
||||
-AttachmentCount $item.AttachmentCount `
|
||||
-AttachmentNames $item.AttachmentNames `
|
||||
-BodyLength $item.BodyLength `
|
||||
-MessageId $entryId `
|
||||
-OutlookMailItem $item.MailItem
|
||||
}
|
||||
|
||||
$script:OutlookLastPoll = (Get-Date).AddSeconds(-10)
|
||||
|
||||
# Cleanup old entry IDs (keep last 24h)
|
||||
$cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-24)
|
||||
foreach ($k in @($script:SeenEntryIds.Keys)) {
|
||||
if ([datetime]$script:SeenEntryIds[$k] -lt $cleanupBefore) {
|
||||
$script:SeenEntryIds.Remove($k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SMTP network connection monitoring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Poll-SmtpConnections {
|
||||
try {
|
||||
$smtpPorts = @(25, 587, 465, 2525)
|
||||
$connections = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
|
||||
Where-Object { $smtpPorts -contains $_.RemotePort }
|
||||
|
||||
foreach ($conn in @($connections)) {
|
||||
$processId = [int]$conn.OwningProcess
|
||||
$remoteAddr = [string]$conn.RemoteAddress
|
||||
$remotePort = [int]$conn.RemotePort
|
||||
$fingerprint = "{0}:{1}:{2}" -f $processId, $remoteAddr, $remotePort
|
||||
if ($script:SeenSmtpConnections.ContainsKey($fingerprint)) { continue }
|
||||
$script:SeenSmtpConnections[$fingerprint] = (Get-Date).ToUniversalTime()
|
||||
|
||||
$processName = ''
|
||||
try {
|
||||
$proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
|
||||
$processName = [string]$proc.ProcessName
|
||||
}
|
||||
catch { }
|
||||
|
||||
Send-EmailHeartbeat -SignalType 'smtp_connection' -Data @{
|
||||
remoteAddress = $remoteAddr
|
||||
remotePort = $remotePort
|
||||
processId = $processId
|
||||
processName = $processName
|
||||
localPort = [int]$conn.LocalPort
|
||||
collectionMode = 'smtp'
|
||||
}
|
||||
Write-CollectorLog ("smtp_connection process={0}({1}) remote={2}:{3}" -f $processName, $processId, $remoteAddr, $remotePort)
|
||||
|
||||
Evaluate-EmailRules `
|
||||
-Subject '' `
|
||||
-RecipientsJoined $remoteAddr `
|
||||
-SenderAddress $env:USERNAME `
|
||||
-AttachmentCount 0 `
|
||||
-AttachmentNames '' `
|
||||
-BodyLength 0 `
|
||||
-MessageId $fingerprint `
|
||||
-OutlookMailItem $null
|
||||
}
|
||||
|
||||
# Cleanup old SMTP connections (keep last 8h)
|
||||
$cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8)
|
||||
foreach ($k in @($script:SeenSmtpConnections.Keys)) {
|
||||
if ([datetime]$script:SeenSmtpConnections[$k] -lt $cleanupBefore) {
|
||||
$script:SeenSmtpConnections.Remove($k)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("SMTP poll error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
|
||||
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
|
||||
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
|
||||
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
|
||||
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
|
||||
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 10 }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
|
||||
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("email-outbound-{0}.log" -f $env:USERNAME) }
|
||||
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
|
||||
|
||||
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
|
||||
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
|
||||
$script:Hostname = $env:COMPUTERNAME
|
||||
$script:SessionId = (Get-Process -Id $PID).SessionId
|
||||
$script:KnownBuckets = @{}
|
||||
$script:Cooldown = @{}
|
||||
$script:SeenEntryIds = @{}
|
||||
$script:SeenSmtpConnections = @{}
|
||||
$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30)
|
||||
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
|
||||
$script:LogPath = $resolvedLogPath
|
||||
$script:OutlookApp = $null
|
||||
$script:OutlookNamespace = $null
|
||||
$script:SentFolder = $null
|
||||
$script:OutlookLastPoll = (Get-Date).AddMinutes(-5)
|
||||
|
||||
Load-EmailPolicy -Path $resolvedPolicyPath
|
||||
Write-CollectorLog ("email collector started mode={0} against {1}" -f $Mode, $script:ApiBase)
|
||||
|
||||
$useOutlook = ($Mode -eq 'outlook' -or $Mode -eq 'both')
|
||||
$useSmtp = ($Mode -eq 'smtp' -or $Mode -eq 'both')
|
||||
$outlookReady = $false
|
||||
|
||||
if ($useOutlook) {
|
||||
$outlookReady = Initialize-OutlookCom
|
||||
if (-not $outlookReady -and $Mode -eq 'outlook') {
|
||||
Write-CollectorLog "Outlook COM not available, collector will retry"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
if (-not $script:Policy.defaults.enabled) {
|
||||
Start-Sleep -Seconds $resolvedPollSeconds
|
||||
continue
|
||||
}
|
||||
|
||||
if ($useOutlook) {
|
||||
if (-not $outlookReady) {
|
||||
$outlookReady = Initialize-OutlookCom
|
||||
}
|
||||
if ($outlookReady) {
|
||||
try {
|
||||
Poll-OutlookSentItems
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("outlook poll error: {0}" -f $_.Exception.Message)
|
||||
$outlookReady = $false
|
||||
$script:OutlookApp = $null
|
||||
$script:OutlookNamespace = $null
|
||||
$script:SentFolder = $null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($useSmtp) {
|
||||
try {
|
||||
Poll-SmtpConnections
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("smtp poll error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds $resolvedPollSeconds
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
|
||||
[string]$ServerHost,
|
||||
[int]$ServerPort,
|
||||
[ValidateSet('http', 'https')]
|
||||
[string]$ServerScheme,
|
||||
[string]$PolicyPath,
|
||||
[string]$LogPath,
|
||||
[int]$PollSeconds = 10,
|
||||
[string[]]$WatchPaths = @('Desktop', 'Documents', 'Downloads')
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Force TLS 1.2 and load networking types
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
Add-Type -AssemblyName System.Net.Http
|
||||
|
||||
# Bucket registry
|
||||
$script:KnownBuckets = @{}
|
||||
$script:Hostname = $env:COMPUTERNAME
|
||||
$script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId
|
||||
|
||||
# Настройка логирования
|
||||
$script:LogPath = $LogPath
|
||||
$script:LocalAgentLogsEnabled = [bool]$LogPath
|
||||
|
||||
function Get-DeploymentConfig {
|
||||
param([string]$Path)
|
||||
if ($Path -and (Test-Path -LiteralPath $Path)) {
|
||||
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Write-FileCollectorLog {
|
||||
param([string]$Message)
|
||||
if (-not $script:LocalAgentLogsEnabled) { return }
|
||||
try {
|
||||
Add-Content -LiteralPath $script:LogPath -Value ('{0} [FileCollector] {1}' -f (Get-Date -Format s), $Message)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Invoke-AwJsonPost {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Uri,
|
||||
[Parameter(Mandatory = $true)][string]$Json
|
||||
)
|
||||
try {
|
||||
$httpClient = New-Object System.Net.Http.HttpClient
|
||||
$content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json")
|
||||
$response = $httpClient.PostAsync($Uri, $content).Result
|
||||
$httpClient.Dispose()
|
||||
} catch {
|
||||
Write-FileCollectorLog "POST Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
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-FileOperationEvent {
|
||||
param(
|
||||
[string]$Operation,
|
||||
[string]$FilePath,
|
||||
[string]$OldFilePath = $null,
|
||||
[long]$Size = 0
|
||||
)
|
||||
|
||||
$bucketId = 'aw-file-operations_' + $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation'
|
||||
|
||||
$data = @{
|
||||
operation = $Operation
|
||||
path = $FilePath
|
||||
extension = [System.IO.Path]::GetExtension($FilePath)
|
||||
username = $env:USERNAME
|
||||
hostname = $script:Hostname
|
||||
}
|
||||
if ($OldFilePath) { $data.oldPath = $OldFilePath }
|
||||
if ($Size -gt 0) { $data.size = $Size }
|
||||
|
||||
# Детекция архивации (упрощенная)
|
||||
if ($Operation -eq 'Created' -and $data.extension -match '\.(zip|7z|rar|tar|gz)$') {
|
||||
$data.archiveHint = $true
|
||||
}
|
||||
|
||||
$payload = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = $data
|
||||
} | ConvertTo-Json -Depth 5 -Compress
|
||||
|
||||
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=15" -Json $payload
|
||||
}
|
||||
|
||||
$config = Get-DeploymentConfig -Path $ConfigPath
|
||||
if (-not $config) { throw "Configuration file not found: $ConfigPath" }
|
||||
|
||||
$scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) { $config.server.scheme } else { 'http' }
|
||||
$hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' }
|
||||
$port = if ($ServerPort) { $ServerPort } elseif ($config.server.port) { $config.server.port } else { 5600 }
|
||||
$script:ApiBase = "{0}://{1}:{2}/api/0" -f $scheme, $hostName, $port
|
||||
|
||||
$bucketId = 'aw-file-operations_' + $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation'
|
||||
|
||||
# Resolve paths for monitoring
|
||||
$resolvedPaths = @()
|
||||
foreach ($p in $WatchPaths) {
|
||||
$fullPath = $p
|
||||
if (-not [System.IO.Path]::IsPathRooted($p)) {
|
||||
try {
|
||||
if ($p -eq 'Desktop') { $fullPath = [Environment]::GetFolderPath('Desktop') }
|
||||
elseif ($p -eq 'Documents') { $fullPath = [Environment]::GetFolderPath('MyDocuments') }
|
||||
elseif ($p -eq 'Downloads') { $fullPath = Join-Path $env:USERPROFILE 'Downloads' }
|
||||
} catch {}
|
||||
}
|
||||
if ($fullPath -and (Test-Path -LiteralPath $fullPath)) {
|
||||
$resolvedPaths += $fullPath
|
||||
}
|
||||
}
|
||||
|
||||
if ($resolvedPaths.Count -eq 0) {
|
||||
Write-FileCollectorLog "No valid watch paths found. Exiting."
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-FileCollectorLog "Starting watch on paths: $($resolvedPaths -join ', ')"
|
||||
|
||||
$watchers = @()
|
||||
foreach ($path in $resolvedPaths) {
|
||||
$watcher = New-Object System.IO.FileSystemWatcher
|
||||
$watcher.Path = $path
|
||||
$watcher.IncludeSubdirectories = $true
|
||||
$watcher.EnableRaisingEvents = $true
|
||||
|
||||
$onChanged = Register-ObjectEvent $watcher "Created" -Action {
|
||||
$path = $Event.SourceEventArgs.FullPath
|
||||
$size = 0
|
||||
try { if (Test-Path -LiteralPath $path) { $size = (Get-Item -LiteralPath $path).Length } } catch {}
|
||||
Send-FileOperationEvent -Operation 'Created' -FilePath $path -Size $size
|
||||
}
|
||||
$onDeleted = Register-ObjectEvent $watcher "Deleted" -Action {
|
||||
Send-FileOperationEvent -Operation 'Deleted' -FilePath $Event.SourceEventArgs.FullPath
|
||||
}
|
||||
$onRenamed = Register-ObjectEvent $watcher "Renamed" -Action {
|
||||
Send-FileOperationEvent -Operation 'Renamed' -FilePath $Event.SourceEventArgs.FullPath -OldFilePath $Event.SourceEventArgs.OldFullPath
|
||||
}
|
||||
|
||||
$watchers += $watcher
|
||||
}
|
||||
|
||||
Write-FileCollectorLog "Collector started. Waiting for events..."
|
||||
|
||||
try {
|
||||
while ($true) {
|
||||
Start-Sleep -Seconds $PollSeconds
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Write-FileCollectorLog "Stopping collector..."
|
||||
foreach ($w in $watchers) {
|
||||
$w.EnableRaisingEvents = $false
|
||||
$w.Dispose()
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ param(
|
||||
[int]$RecoveryIntervalSeconds,
|
||||
[bool]$AfkEnabled,
|
||||
[bool]$WindowEnabled,
|
||||
[bool]$FileOpsEnabled,
|
||||
[bool]$LocalAgentLogsEnabled,
|
||||
[bool]$IncidentCaptureEnabled,
|
||||
[bool]$IncidentScreenshotEnabled,
|
||||
@@ -53,6 +54,8 @@ $effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1'
|
||||
$effectiveRecoveryScript = Join-Path $effectiveStateRoot 'recovery-loop.ps1'
|
||||
$effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-collector.ps1'
|
||||
$effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$existingConfig.paths.endpointCollectorScript } else { Join-Path $effectiveStateRoot 'dlp-endpoint-signals-collector.ps1' }
|
||||
$effectiveFileCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$existingConfig.paths.fileCollectorScript } else { Join-Path $effectiveStateRoot 'file-operations-collector.ps1' }
|
||||
$effectiveSessionCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$existingConfig.paths.sessionCollectorScript } else { Join-Path $effectiveStateRoot 'worktime-session-collector.ps1' }
|
||||
$effectiveRules = Join-Path $effectiveStateRoot 'web-category-rules.json'
|
||||
$effectivePolicy = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$existingConfig.paths.policyPath } else { Join-Path $effectiveStateRoot 'dlp-policy.json' }
|
||||
|
||||
@@ -64,6 +67,7 @@ $effectivePulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $
|
||||
$effectiveRecoveryInterval = if ($PSBoundParameters.ContainsKey('RecoveryIntervalSeconds')) { $RecoveryIntervalSeconds } elseif ($existingConfig) { [int]$existingConfig.recovery.intervalSeconds } else { 180 }
|
||||
$effectiveAfkEnabled = if ($PSBoundParameters.ContainsKey('AfkEnabled')) { [bool]$AfkEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$existingConfig.collectors.afkEnabled } else { $true }
|
||||
$effectiveWindowEnabled = if ($PSBoundParameters.ContainsKey('WindowEnabled')) { [bool]$WindowEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$existingConfig.collectors.windowEnabled } else { $true }
|
||||
$effectiveFileOpsEnabled = if ($PSBoundParameters.ContainsKey('FileOpsEnabled')) { [bool]$FileOpsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$existingConfig.collectors.fileOpsEnabled } else { $true }
|
||||
$effectiveLocalAgentLogsEnabled = if ($PSBoundParameters.ContainsKey('LocalAgentLogsEnabled')) { [bool]$LocalAgentLogsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'logging' -and $existingConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$existingConfig.logging.localAgentLogsEnabled } else { $false }
|
||||
$effectiveIncidentCaptureEnabled = if ($PSBoundParameters.ContainsKey('IncidentCaptureEnabled')) { [bool]$IncidentCaptureEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'enabled') { [bool]$existingConfig.incidentCapture.enabled } else { $true }
|
||||
$effectiveIncidentScreenshotEnabled = if ($PSBoundParameters.ContainsKey('IncidentScreenshotEnabled')) { [bool]$IncidentScreenshotEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$existingConfig.incidentCapture.screenshotEnabled } else { $true }
|
||||
@@ -83,6 +87,7 @@ else {
|
||||
|
||||
New-ActivityWatchDirectory -Path $effectiveStateRoot
|
||||
New-ActivityWatchDirectory -Path $effectiveLogsRoot
|
||||
Enable-ActivityWatchPrintTelemetry
|
||||
|
||||
if ($RepairPackage) {
|
||||
$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy'
|
||||
@@ -96,6 +101,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $effectiveInstallRoot | Out-Null
|
||||
$assetResult = Copy-ActivityWatchCollectorAssets `
|
||||
-CollectorScriptSource (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') `
|
||||
-EndpointCollectorScriptSource (Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1') `
|
||||
-FileCollectorScriptSource (Join-Path $PSScriptRoot 'file-operations-collector.ps1') `
|
||||
-SessionCollectorScriptSource (Join-Path $PSScriptRoot 'worktime-session-collector.ps1') `
|
||||
-ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') `
|
||||
-ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') `
|
||||
@@ -116,6 +122,7 @@ $config = New-ActivityWatchDeploymentConfig `
|
||||
-LogsRoot $effectiveLogsRoot `
|
||||
-CollectorScript $effectiveCollector `
|
||||
-EndpointCollectorScript $effectiveEndpointCollector `
|
||||
-FileCollectorScript $effectiveFileCollector `
|
||||
-SessionCollectorScript $effectiveSessionCollector `
|
||||
-RulesPath $effectiveRules `
|
||||
-PolicyPath $effectivePolicy `
|
||||
@@ -124,6 +131,7 @@ $config = New-ActivityWatchDeploymentConfig `
|
||||
-RecoveryIntervalSeconds $effectiveRecoveryInterval `
|
||||
-AfkEnabled $effectiveAfkEnabled `
|
||||
-WindowEnabled $effectiveWindowEnabled `
|
||||
-FileOpsEnabled $effectiveFileOpsEnabled `
|
||||
-LocalAgentLogsEnabled $effectiveLocalAgentLogsEnabled `
|
||||
-IncidentCaptureEnabled $effectiveIncidentCaptureEnabled `
|
||||
-IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled `
|
||||
|
||||
@@ -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'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ $installRoot = [string]$config.paths.installRoot
|
||||
$stateRoot = [string]$config.paths.stateRoot
|
||||
$collectorScript = [string]$config.paths.collectorScript
|
||||
$endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' }
|
||||
$fileCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$config.paths.fileCollectorScript } else { Join-Path $stateRoot 'file-operations-collector.ps1' }
|
||||
$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' }
|
||||
$rulesPath = [string]$config.paths.rulesPath
|
||||
$policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$config.paths.policyPath } else { Join-Path $stateRoot 'dlp-policy.json' }
|
||||
@@ -22,6 +23,21 @@ $recoveryScript = [string]$config.paths.recoveryScript
|
||||
|
||||
$afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true }
|
||||
$windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true }
|
||||
$fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true }
|
||||
$printServiceOperationalEnabled = $false
|
||||
try {
|
||||
$printServiceLog = Get-WinEvent -ListLog 'Microsoft-Windows-PrintService/Operational' -ErrorAction Stop
|
||||
$printServiceOperationalEnabled = [bool]$printServiceLog.IsEnabled
|
||||
}
|
||||
catch {
|
||||
}
|
||||
$printJobTitlePolicyEnabled = $false
|
||||
try {
|
||||
$printPolicy = Get-ItemProperty -LiteralPath 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' -Name 'ShowJobTitleInEventLogs' -ErrorAction Stop
|
||||
$printJobTitlePolicyEnabled = ([int]$printPolicy.ShowJobTitleInEventLogs -eq 1)
|
||||
}
|
||||
catch {
|
||||
}
|
||||
$requiredFiles = @(
|
||||
$collectorScript,
|
||||
$endpointCollectorScript,
|
||||
@@ -32,6 +48,9 @@ $requiredFiles = @(
|
||||
$recoveryScript,
|
||||
$ConfigPath
|
||||
)
|
||||
if ($fileOpsExpected) {
|
||||
$requiredFiles += $fileCollectorScript
|
||||
}
|
||||
if ($afkExpected) {
|
||||
$requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe')
|
||||
}
|
||||
@@ -50,12 +69,14 @@ $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
|
||||
$sessionCollectorProcesses = @(
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and
|
||||
$_.CommandLine -match [Regex]::Escape($sessionCollectorScript)
|
||||
} |
|
||||
Select-Object Name, ProcessId, SessionId, CommandLine
|
||||
)
|
||||
|
||||
$taskNames = @()
|
||||
if ($config.userTasks) {
|
||||
@@ -64,25 +85,28 @@ if ($config.userTasks) {
|
||||
$taskNames += [string]$config.recovery.taskName
|
||||
$taskNames = $taskNames | Sort-Object -Unique
|
||||
|
||||
$tasks = foreach ($taskName in $taskNames) {
|
||||
$task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1
|
||||
if ($task) {
|
||||
[pscustomobject]@{
|
||||
taskName = $task.TaskName
|
||||
state = [string]$task.State
|
||||
present = $true
|
||||
$tasks = @(
|
||||
foreach ($taskName in $taskNames) {
|
||||
$task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1
|
||||
if ($task) {
|
||||
[pscustomobject]@{
|
||||
taskName = $task.TaskName
|
||||
state = [string]$task.State
|
||||
present = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
[pscustomobject]@{
|
||||
taskName = $taskName
|
||||
state = 'Отсутствует'
|
||||
present = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
[pscustomobject]@{
|
||||
taskName = $taskName
|
||||
state = 'Отсутствует'
|
||||
present = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port
|
||||
$uniqueRunningProcessNames = @($runningProcesses | Select-Object -ExpandProperty Name -Unique)
|
||||
$result = [ordered]@{
|
||||
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
|
||||
configPath = $ConfigPath
|
||||
@@ -105,13 +129,18 @@ $result = [ordered]@{
|
||||
ok = [bool](
|
||||
(
|
||||
($processNames.Count -eq 0) -or
|
||||
(($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge $processNames.Count)
|
||||
($uniqueRunningProcessNames.Count -ge $processNames.Count)
|
||||
) -and
|
||||
($sessionCollectorProcesses.Count -ge 1)
|
||||
)
|
||||
}
|
||||
printTelemetry = [ordered]@{
|
||||
operationalLogEnabled = $printServiceOperationalEnabled
|
||||
jobTitlePolicyEnabled = $printJobTitlePolicyEnabled
|
||||
ok = [bool]($printServiceOperationalEnabled -and $printJobTitlePolicyEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok)
|
||||
$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok -and $result.printTelemetry.ok)
|
||||
|
||||
$result
|
||||
|
||||
Reference in New Issue
Block a user