From 0c88b519ee4bad392501aba936a10d0200c3cc85 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 21 May 2026 20:47:49 +0300 Subject: [PATCH] feat(hayabusa): add auto-case scoring and 6h automation --- ansible/deploy_aw_server.yml | 23 ++ ansible/deploy_aw_windows.yml | 50 ++- ansible/group_vars/all.example.yml | 12 +- ansible/group_vars/all.yml | 14 +- ansible/group_vars/aw_windows.yml | 8 +- ansible/group_vars/windows.example.yml | 8 +- ansible/post_validate_aw_windows.yml | 23 +- aw-server/apply_webui_ru_patch.sh | 12 +- aw-server/aw-hayabusa-drop.service | 5 +- aw-server/aw-server.env.example | 11 +- aw-server/hayabusa/aw-hayabusa-autoprocess.py | 25 +- aw-server/hayabusa/aw-hayabusa-case-alert.py | 334 ++++++++++++++++++ docs/security-analytics-stack-v1.md | 107 ++++++ windows/ActivityWatch.Windows.Common.psm1 | 56 +++ windows/deploy-domain-users.ps1 | 12 + windows/deploy-ensemble.ps1 | 10 + windows/export-evtx-for-hayabusa.ps1 | 10 +- .../export-upload-hayabusa-to-aw-server.ps1 | 37 +- windows/hardening-recovery.ps1 | 14 + .../innosetup/AWatch-rus-InnoSetup.iss | 4 +- windows/installkit/innosetup/BUILD.md | 2 +- windows/validate-deployment.ps1 | 12 + 22 files changed, 757 insertions(+), 32 deletions(-) create mode 100644 aw-server/hayabusa/aw-hayabusa-case-alert.py create mode 100644 docs/security-analytics-stack-v1.md diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index bcea8d5..750a6ad 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -342,6 +342,12 @@ AW_MONITORED_WINDOWS_HOSTNAME={{ aw_monitored_windows_hostname }} AW_RUS_HEALTH_STATE_DIR={{ aw_rus_health_state_dir }} AW_RUS_HEALTH_VALIDATION_DIR={{ aw_rus_health_validation_dir }} + AW_HAYABUSA_AUTO_CASE_ENABLED={{ 'true' if (aw_hayabusa_auto_case_enabled | default(true) | bool) else 'false' }} + AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY={{ aw_hayabusa_auto_case_min_severity | default('medium') }} + AW_HAYABUSA_TELEGRAM_ENABLED={{ 'true' if (aw_hayabusa_telegram_enabled | default(false) | bool) else 'false' }} + AW_HAYABUSA_TELEGRAM_MIN_SEVERITY={{ aw_hayabusa_telegram_min_severity | default('high') }} + AW_HAYABUSA_TELEGRAM_BOT_TOKEN={{ aw_hayabusa_telegram_bot_token | default('') }} + AW_HAYABUSA_TELEGRAM_CHAT_IDS={{ aw_hayabusa_telegram_chat_ids | default('') }} - name: Создать каталог DLP policy engine ansible.builtin.file: @@ -1622,6 +1628,22 @@ group: root mode: "0755" + - name: Установить helper case-alert для Hayabusa + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-case-alert.py" + dest: /usr/local/bin/aw-hayabusa-case-alert + owner: root + group: root + mode: "0755" + + - name: Положить helper case-alert в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-case-alert.py" + dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-case-alert.py + owner: root + group: root + mode: "0755" + - name: Установить systemd unit aw-hayabusa-drop.service ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-hayabusa-drop.service" @@ -1674,6 +1696,7 @@ cmd: /usr/local/bin/aw-rus-healthd.py --json register: aw_post_deploy_health changed_when: false + failed_when: false - name: Показать результат aw-rus-healthd ansible.builtin.debug: diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index 8323143..5cb2dc0 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -9,7 +9,6 @@ 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" @@ -29,9 +28,13 @@ aw_windows_policy_mode: "server" aw_windows_policy_refresh_seconds: 300 aw_windows_policy_engine_enabled: true - aw_windows_policy_engine_host: "{{ aw_windows_server_host }}" aw_windows_policy_engine_port: 5601 aw_windows_policy_engine_scheme: "http" + aw_windows_hayabusa_auto_upload_enabled: true + aw_windows_hayabusa_auto_upload_interval_hours: 6 + aw_windows_hayabusa_auto_upload_hours_back: 6 + aw_windows_hayabusa_auto_upload_mode: "incident" + aw_windows_hayabusa_auto_upload_task_name: "ActivityWatch Hayabusa Upload" aw_windows_afk_enabled_default: true aw_windows_window_enabled_default: true aw_windows_file_ops_enabled: true @@ -62,10 +65,38 @@ aw_windows_migration_report_remote_path: "{{ aw_windows_state_root }}\\aw_migration_ansible.json" tasks: + - name: Вычислить inventory host AW server по умолчанию + ansible.builtin.set_fact: + aw_server_inventory_host_effective: "{{ (groups['aw_server'] | default([]) | first) | default('', true) }}" + + - name: Вычислить effective host для AW server + ansible.builtin.set_fact: + aw_windows_server_host_effective: >- + {{ + aw_windows_server_host + | default( + ( + hostvars[aw_server_inventory_host_effective].ansible_host + | default(aw_server_inventory_host_effective, true) + ) + if (aw_server_inventory_host_effective | length) > 0 + else '', + true + ) + }} + + - name: Вычислить effective host для policy engine + ansible.builtin.set_fact: + aw_windows_policy_engine_host_effective: >- + {{ + aw_windows_policy_engine_host + | default(aw_windows_server_host_effective, true) + }} + - name: Проверить обязательные переменные ansible.builtin.assert: that: - - aw_windows_server_host is defined + - aw_windows_server_host_effective | length > 0 - aw_windows_server_port is defined - aw_windows_server_scheme is defined - aw_windows_domain is defined @@ -160,7 +191,7 @@ $ErrorActionPreference = 'Stop' $params = @{ ServerScheme = "{{ aw_windows_server_scheme }}" - ServerHost = "{{ aw_windows_server_host }}" + ServerHost = "{{ aw_windows_server_host_effective }}" ServerPort = {{ aw_windows_server_port }} Version = "{{ aw_windows_package_version }}" Domain = "{{ aw_windows_domain }}" @@ -179,10 +210,15 @@ LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }} PolicyMode = "{{ aw_windows_policy_mode }}" PolicyEngineEnabled = {{ '$true' if (aw_windows_policy_engine_enabled | bool) else '$false' }} - PolicyEngineHost = "{{ aw_windows_policy_engine_host }}" + PolicyEngineHost = "{{ aw_windows_policy_engine_host_effective }}" PolicyEnginePort = {{ aw_windows_policy_engine_port }} PolicyEngineScheme = "{{ aw_windows_policy_engine_scheme }}" PolicyRefreshSeconds = {{ aw_windows_policy_refresh_seconds }} + HayabusaAutoUploadEnabled = {{ '$true' if (aw_windows_hayabusa_auto_upload_enabled | bool) else '$false' }} + HayabusaAutoUploadIntervalHours = {{ aw_windows_hayabusa_auto_upload_interval_hours | int }} + HayabusaAutoUploadHoursBack = {{ aw_windows_hayabusa_auto_upload_hours_back | int }} + HayabusaAutoUploadMode = "{{ aw_windows_hayabusa_auto_upload_mode }}" + HayabusaAutoUploadTaskName = "{{ aw_windows_hayabusa_auto_upload_task_name }}" CustomRulesPath = "{{ aw_windows_rules_path }}" CustomPolicyPath = "{{ aw_windows_policy_path }}" } @@ -342,7 +378,7 @@ when: - aw_windows_api_smoke_check_enabled | bool 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 }}" + url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host_effective }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}" method: GET status_code: 200 return_content: true @@ -369,7 +405,7 @@ - aw_windows_api_smoke_check_window_enabled_effective | bool - aw_windows_window_enabled_effective | bool ansible.builtin.uri: - url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_window_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}" + url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host_effective }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_window_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}" method: GET status_code: 200 return_content: true diff --git a/ansible/group_vars/all.example.yml b/ansible/group_vars/all.example.yml index fada2ca..68a3baf 100644 --- a/ansible/group_vars/all.example.yml +++ b/ansible/group_vars/all.example.yml @@ -8,7 +8,9 @@ aw_server_db_path: "/var/lib/activitywatch/.local/share/activitywatch/aw-server- aw_server_log_dir: "/var/log/activitywatch" aw_server_user: "activitywatch" aw_server_group: "activitywatch" -aw_worktime_report_base: "http://10.10.10.13:5610" +aw_server_inventory_host: "{{ (groups['aw_server'] | default([]) | first) | default('aw-server', true) }}" +aw_server_public_host: "{{ (hostvars[aw_server_inventory_host].ansible_host | default(aw_server_inventory_host, true)) if (aw_server_inventory_host | length) > 0 else 'aw-server' }}" +aw_worktime_report_base: "http://{{ aw_server_public_host }}:5610" aw_worktime_timezone: "Europe/Moscow" aw_worktime_influx_enabled: false aw_worktime_influx_url: "http://10.10.10.10:8086" @@ -29,6 +31,12 @@ aw_monitored_windows_host: "192.168.100.18" aw_monitored_windows_hostname: "SHARKON2025" aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health" aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation" +aw_hayabusa_auto_case_enabled: true +aw_hayabusa_auto_case_min_severity: "medium" +aw_hayabusa_telegram_enabled: false +aw_hayabusa_telegram_min_severity: "high" +aw_hayabusa_telegram_bot_token: "" +aw_hayabusa_telegram_chat_ids: "" aw_repo_root: "{{ playbook_dir | dirname }}" @@ -41,7 +49,7 @@ 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_public_host }}:5600" - "http://aw-server:5600" # Опциональные значения периода рабочего времени в Web UI. diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index b0362ba..0f4a886 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -8,7 +8,9 @@ aw_server_log_dir: "/var/log/activitywatch" aw_server_db_path: "/var/lib/activitywatch/aw-server-rust/sqlite.db" aw_server_user: "activitywatch" aw_server_group: "activitywatch" -aw_worktime_report_base: "http://10.10.10.13:5610" +aw_server_inventory_host: "{{ (groups['aw_server'] | default([]) | first) | default('aw-server', true) }}" +aw_server_public_host: "{{ (hostvars[aw_server_inventory_host].ansible_host | default(aw_server_inventory_host, true)) if (aw_server_inventory_host | length) > 0 else 'aw-server' }}" +aw_worktime_report_base: "http://{{ aw_server_public_host }}:5610" aw_worktime_timezone: "Europe/Moscow" aw_worktime_influx_enabled: false aw_worktime_influx_url: "http://10.10.10.10:8086" @@ -27,14 +29,20 @@ aw_monitored_windows_host: "192.168.100.18" aw_monitored_windows_hostname: "SHARKON2025" aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health" aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation" +aw_hayabusa_auto_case_enabled: true +aw_hayabusa_auto_case_min_severity: "medium" +aw_hayabusa_telegram_enabled: false +aw_hayabusa_telegram_min_severity: "high" +aw_hayabusa_telegram_bot_token: "" +aw_hayabusa_telegram_chat_ids: "" aw_repo_root: "/mnt/usb_hdd2/Projects/ActivityWatch-Russian" aw_server_cors_origins: - "http://127.0.0.1:5600" - "http://localhost:5600" - - "http://10.10.10.13:5600" - - "http://192.168.100.13:5600" + - "http://{{ aw_server_public_host }}:5600" + - "http://aw-server:5600" - "http://snb-live:5600" aw_apply_worktime_settings: true diff --git a/ansible/group_vars/aw_windows.yml b/ansible/group_vars/aw_windows.yml index ec6d0f1..225790e 100644 --- a/ansible/group_vars/aw_windows.yml +++ b/ansible/group_vars/aw_windows.yml @@ -6,7 +6,8 @@ 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" +# Leave empty to derive from the first host in [aw_server] inventory. +aw_windows_server_host: "" aw_windows_server_port: 5600 aw_windows_package_version: "v0.13.2" @@ -32,6 +33,11 @@ aw_windows_policy_engine_enabled: true aw_windows_policy_engine_host: "{{ aw_windows_server_host }}" aw_windows_policy_engine_port: 5601 aw_windows_policy_engine_scheme: "http" +aw_windows_hayabusa_auto_upload_enabled: true +aw_windows_hayabusa_auto_upload_interval_hours: 6 +aw_windows_hayabusa_auto_upload_hours_back: 6 +aw_windows_hayabusa_auto_upload_mode: "incident" +aw_windows_hayabusa_auto_upload_task_name: "ActivityWatch Hayabusa Upload" aw_windows_afk_enabled: true aw_windows_window_enabled: true diff --git a/ansible/group_vars/windows.example.yml b/ansible/group_vars/windows.example.yml index 4dfe821..ea80fe6 100644 --- a/ansible/group_vars/windows.example.yml +++ b/ansible/group_vars/windows.example.yml @@ -1,8 +1,14 @@ 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" +# Leave empty to derive from the first host in [aw_server] inventory. +aw_windows_server_host: "" aw_windows_server_port: 5600 +aw_windows_hayabusa_auto_upload_enabled: false +aw_windows_hayabusa_auto_upload_interval_hours: 6 +aw_windows_hayabusa_auto_upload_hours_back: 6 +aw_windows_hayabusa_auto_upload_mode: "incident" +aw_windows_hayabusa_auto_upload_task_name: "ActivityWatch Hayabusa Upload" 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: "" diff --git a/ansible/post_validate_aw_windows.yml b/ansible/post_validate_aw_windows.yml index 232456d..7eabd54 100644 --- a/ansible/post_validate_aw_windows.yml +++ b/ansible/post_validate_aw_windows.yml @@ -9,7 +9,6 @@ aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ansible.json" aw_windows_validation_local_dir: "/tmp/aw-rus-validation-{{ lookup('env','USER') | default('ansible', true) }}" aw_windows_server_scheme: "http" - aw_windows_server_host: "10.10.10.13" aw_windows_server_port: 5600 aw_windows_fail_on_validation_error: true aw_windows_launch_task_pattern: "ActivityWatch Launch *" @@ -21,6 +20,26 @@ aw_windows_api_smoke_check_wait_seconds: 20 tasks: + - name: Вычислить inventory host AW server по умолчанию + ansible.builtin.set_fact: + aw_server_inventory_host_effective: "{{ (groups['aw_server'] | default([]) | first) | default('', true) }}" + + - name: Вычислить effective host для AW server + ansible.builtin.set_fact: + aw_windows_server_host_effective: >- + {{ + aw_windows_server_host + | default( + ( + hostvars[aw_server_inventory_host_effective].ansible_host + | default(aw_server_inventory_host_effective, true) + ) + if (aw_server_inventory_host_effective | length) > 0 + else '', + true + ) + }} + - name: Аккуратно запустить ActivityWatch recovery и launch tasks, если они не в Running when: aw_windows_force_task_restart | bool ansible.windows.win_powershell: @@ -60,7 +79,7 @@ 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 }}" + url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host_effective }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}" method: GET status_code: 200 return_content: true diff --git a/aw-server/apply_webui_ru_patch.sh b/aw-server/apply_webui_ru_patch.sh index 343ee06..64e8826 100755 --- a/aw-server/apply_webui_ru_patch.sh +++ b/aw-server/apply_webui_ru_patch.sh @@ -10,7 +10,8 @@ fi source "$ENV_FILE" WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}" -REPORT_BASE="${AW_WORKTIME_REPORT_BASE:-http://10.10.10.13:5610}" +SERVER_PUBLIC_HOST="${AW_SERVER_PUBLIC_HOST:-${AW_SERVER_HOST:-$(hostname -f 2>/dev/null || hostname)}}" +REPORT_BASE="${AW_WORKTIME_REPORT_BASE:-http://${SERVER_PUBLIC_HOST}:5610}" CASE_PORT="${AW_DLP_CASE_PORT:-5602}" CASE_BASE="${AW_DLP_CASE_PUBLIC_BASE:-}" PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js" @@ -62,12 +63,19 @@ worktime_panel_hash="$(sha1sum "$WORKTIME_PANEL_TARGET" | awk '{print substr($1, if [[ -z "$CASE_BASE" ]]; then CASE_BASE="$(python3 - "$REPORT_BASE" "$CASE_PORT" <<'PY' from urllib.parse import urlsplit, urlunsplit +import os +import socket import sys report_base = sys.argv[1] case_port = sys.argv[2] parts = urlsplit(report_base) -hostname = parts.hostname or "10.10.10.13" +hostname = ( + parts.hostname + or os.environ.get("AW_SERVER_PUBLIC_HOST") + or os.environ.get("AW_SERVER_HOST") + or socket.getfqdn() +) scheme = parts.scheme or "http" print(urlunsplit((scheme, f"{hostname}:{case_port}", "", "", ""))) PY diff --git a/aw-server/aw-hayabusa-drop.service b/aw-server/aw-hayabusa-drop.service index f614373..77ee804 100644 --- a/aw-server/aw-hayabusa-drop.service +++ b/aw-server/aw-hayabusa-drop.service @@ -1,10 +1,11 @@ [Unit] Description=AW-RUS Hayabusa auto-process dropped packages -After=network-online.target activitywatch-server.service -Wants=network-online.target activitywatch-server.service +After=network-online.target activitywatch-server.service aw-dlp-case-management.service +Wants=network-online.target activitywatch-server.service aw-dlp-case-management.service [Service] Type=oneshot +EnvironmentFile=-/etc/activitywatch/aw-server.env ExecStart=/usr/bin/python3 /usr/local/bin/aw-hayabusa-autoprocess User=root Group=root diff --git a/aw-server/aw-server.env.example b/aw-server/aw-server.env.example index 1d43206..f0469da 100755 --- a/aw-server/aw-server.env.example +++ b/aw-server/aw-server.env.example @@ -13,7 +13,8 @@ AW_SERVER_USER=activitywatch AW_SERVER_GROUP=activitywatch # Worktime API Configuration -AW_WORKTIME_REPORT_BASE=http://10.10.10.13:5610 +AW_SERVER_PUBLIC_HOST=aw-server +AW_WORKTIME_REPORT_BASE=http://aw-server:5610 AW_WORKTIME_TZ=Europe/Moscow AW_SERVER_URL=http://127.0.0.1:5600 @@ -42,5 +43,13 @@ AW_MONITORED_WINDOWS_HOSTNAME=SHARKON2025 AW_RUS_HEALTH_STATE_DIR=/var/lib/activitywatch/health AW_RUS_HEALTH_VALIDATION_DIR=/var/lib/activitywatch/health/windows-validation +# Hayabusa auto-case / alerting +AW_HAYABUSA_AUTO_CASE_ENABLED=true +AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY=medium +AW_HAYABUSA_TELEGRAM_ENABLED=false +AW_HAYABUSA_TELEGRAM_MIN_SEVERITY=high +AW_HAYABUSA_TELEGRAM_BOT_TOKEN= +AW_HAYABUSA_TELEGRAM_CHAT_IDS= + # Integration Test Configuration AW_INTEGRATION_TEST_ENABLED=false diff --git a/aw-server/hayabusa/aw-hayabusa-autoprocess.py b/aw-server/hayabusa/aw-hayabusa-autoprocess.py index 9b34a5c..d42b733 100644 --- a/aw-server/hayabusa/aw-hayabusa-autoprocess.py +++ b/aw-server/hayabusa/aw-hayabusa-autoprocess.py @@ -12,6 +12,7 @@ DROP_DIR = pathlib.Path('/opt/activitywatch/aw-rus-ops/drop') LOCK_PATH = pathlib.Path('/opt/hayabusa/state/aw-hayabusa-autoprocess.lock') WRAPPER = pathlib.Path('/usr/local/bin/aw-hayabusa') LINKER = pathlib.Path('/usr/local/bin/aw-hayabusa-link-case') +CASE_ALERT = pathlib.Path('/usr/local/bin/aw-hayabusa-case-alert') def run(cmd): @@ -19,6 +20,11 @@ def run(cmd): subprocess.run(cmd, check=True) +def run_capture(cmd): + print('RUN', ' '.join(str(x) for x in cmd), flush=True) + return subprocess.run(cmd, check=False, text=True, capture_output=True) + + def read_latest_intake(): return json.loads(pathlib.Path('/opt/hayabusa/state/latest-intake.json').read_text(encoding='utf-8')) @@ -83,11 +89,22 @@ def process_one(zip_path: pathlib.Path): run([str(WRAPPER), 'process-inbox', '--mode', mode, '--limit', '1']) latest = read_latest_intake() report_dir = pathlib.Path(latest['report_dir']) + case_alert = None + if CASE_ALERT.is_file(): + alert_cmd = [str(CASE_ALERT), '--mode', mode, '--link-source', sidecars['link_source']] + if sidecars['case_id'] is not None: + alert_cmd += ['--case-id', str(sidecars['case_id'])] + result = run_capture(alert_cmd) + case_alert = { + 'returncode': result.returncode, + 'stdout': result.stdout.strip(), + 'stderr': result.stderr.strip(), + } archive_sidecars(report_dir, sidecars) archive_drop_package(report_dir, zip_path) - if sidecars['case_id'] is not None: + if sidecars['case_id'] is not None and not CASE_ALERT.is_file(): run([str(LINKER), '--case-id', str(sidecars['case_id']), '--mode', mode, '--link-source', sidecars['link_source']]) - return latest + return {'latest_intake': latest, 'case_alert': case_alert} def main(): @@ -110,8 +127,8 @@ def main(): print('no zip packages in drop dir') return 0 for zip_path in zips: - latest = process_one(zip_path) - print(json.dumps({'processed': str(zip_path), 'latest_intake': latest}, ensure_ascii=False, indent=2)) + result = process_one(zip_path) + print(json.dumps({'processed': str(zip_path), **result}, ensure_ascii=False, indent=2)) return 0 diff --git a/aw-server/hayabusa/aw-hayabusa-case-alert.py b/aw-server/hayabusa/aw-hayabusa-case-alert.py new file mode 100644 index 0000000..4f12061 --- /dev/null +++ b/aw-server/hayabusa/aw-hayabusa-case-alert.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import csv +import json +import os +import pathlib +import urllib.error +import urllib.parse +import urllib.request +from collections import Counter +from datetime import datetime, timezone +from typing import Any + + +SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4} +LEVEL_NORMALIZATION = { + "informational": "info", + "info": "info", + "low": "low", + "med": "med", + "medium": "med", + "high": "high", + "crit": "crit", + "critical": "crit", +} +LEVEL_WEIGHTS = { + "info": 1, + "low": 4, + "med": 12, + "high": 40, + "crit": 100, +} + + +def env_bool(name: str, default: bool) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def normalize_level(level: str | None) -> str: + if not level: + return "info" + return LEVEL_NORMALIZATION.get(level.strip().lower(), level.strip().lower()) + + +def severity_meets(actual: str, threshold: str) -> bool: + return SEVERITY_ORDER.get(actual, 0) >= SEVERITY_ORDER.get(threshold, 0) + + +def build_hayabusa_payload(intake: dict[str, Any], mode: str, link_source: str) -> dict[str, Any]: + report_dir = pathlib.Path(intake["report_dir"]) + return { + "tool": "hayabusa", + "host": intake["host"], + "mode": mode, + "status": intake["status"], + "intake_id": intake["intake_id"], + "package_path": intake["package_path"], + "sha256": intake["sha256"], + "report_dir": intake["report_dir"], + "summary_html": str(report_dir / "summary.html"), + "timeline_path": str(report_dir / "timeline.jsonl"), + "manifest_path": str(report_dir / "manifest.json"), + "link_source": link_source, + } + + +def post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(url, data=data, method="POST", headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req) as resp: + body = resp.read().decode("utf-8") + return json.loads(body) if body else {} + + +def patch_json(url: str, payload: dict[str, Any]) -> dict[str, Any]: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(url, data=data, method="PATCH", headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req) as resp: + body = resp.read().decode("utf-8") + return json.loads(body) if body else {} + + +def get_json(url: str) -> dict[str, Any]: + with urllib.request.urlopen(url) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def parse_timestamp(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def read_csv_rows(path: pathlib.Path) -> int: + if not path.is_file(): + return 0 + with path.open("r", encoding="utf-8-sig", newline="") as fh: + reader = csv.reader(fh) + rows = list(reader) + if not rows: + return 0 + return max(0, len(rows) - 1) + + +def analyze_report(report_dir: pathlib.Path) -> dict[str, Any]: + timeline_path = report_dir / "timeline.jsonl" + level_counts: Counter[str] = Counter() + title_counts: Counter[str] = Counter() + first_ts: datetime | None = None + last_ts: datetime | None = None + total_events = 0 + + if timeline_path.is_file(): + with timeline_path.open("r", encoding="utf-8") as fh: + for raw_line in fh: + line = raw_line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + total_events += 1 + level = normalize_level(str(event.get("Level", ""))) + level_counts[level] += 1 + title = str(event.get("RuleTitle") or "").strip() or "Unknown rule" + title_counts[title] += 1 + ts = parse_timestamp(event.get("Timestamp")) + if ts is not None: + first_ts = ts if first_ts is None or ts < first_ts else first_ts + last_ts = ts if last_ts is None or ts > last_ts else last_ts + + failed_logons = read_csv_rows(report_dir / "logon-summary-failed.csv") + successful_logons = read_csv_rows(report_dir / "logon-summary-successful.csv") + + suspicious_pwsh = sum( + count + for title, count in title_counts.items() + if "pwsh" in title.lower() or "powershell" in title.lower() or "obfuscation" in title.lower() + ) + credential_events = sum(count for title, count in title_counts.items() if "credential" in title.lower()) + timestomp_events = sum(count for title, count in title_counts.items() if "timestomp" in title.lower()) + logon_failure_events = sum(count for title, count in title_counts.items() if "logon failure" in title.lower()) + + score = ( + sum(LEVEL_WEIGHTS.get(level, 0) * count for level, count in level_counts.items()) + + min(failed_logons, 200) * 2 + + suspicious_pwsh * 6 + + credential_events * 8 + + timestomp_events * 12 + + logon_failure_events * 2 + ) + + crit_count = level_counts.get("crit", 0) + high_count = level_counts.get("high", 0) + med_count = level_counts.get("med", 0) + + if crit_count >= 1 or score >= 240 or (high_count >= 4 and suspicious_pwsh >= 4): + severity = "critical" + elif high_count >= 1 or score >= 120 or suspicious_pwsh >= 8 or credential_events >= 5: + severity = "high" + elif med_count >= 1 or score >= 40 or failed_logons >= 10: + severity = "medium" + else: + severity = "low" + + return { + "severity": severity, + "score": score, + "events_total": total_events, + "level_counts": dict(level_counts), + "top_rules": [{"title": title, "count": count} for title, count in title_counts.most_common(5)], + "first_timestamp": first_ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") if first_ts else None, + "last_timestamp": last_ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") if last_ts else None, + "failed_logon_rows": failed_logons, + "successful_logon_rows": successful_logons, + "suspicious_pwsh": suspicious_pwsh, + "credential_events": credential_events, + "timestomp_events": timestomp_events, + "logon_failure_events": logon_failure_events, + } + + +def build_case_title(host: str, summary: dict[str, Any]) -> str: + top_rule = summary.get("top_rules") or [] + suffix = top_rule[0]["title"] if top_rule else "No dominant rule" + return f"Hayabusa {summary['severity'].upper()} · {host} · {suffix}" + + +def build_case_payload(intake: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]: + return { + "incident_id": f"hayabusa:{intake['host']}:{intake['intake_id']}", + "host": intake["host"], + "title": build_case_title(intake["host"], summary), + "severity": summary["severity"], + "evidence": { + "hayabusa": { + "intake_id": intake["intake_id"], + "package_path": intake["package_path"], + "sha256": intake["sha256"], + "report_dir": intake["report_dir"], + "summary": summary, + } + }, + } + + +def build_comment(summary: dict[str, Any], intake: dict[str, Any]) -> str: + top = ", ".join(f"{item['title']} ({item['count']})" for item in summary.get("top_rules", [])[:3]) or "n/a" + return ( + f"Hayabusa auto-summary\n" + f"Severity: {summary['severity']} (score={summary['score']})\n" + f"Host: {intake['host']}\n" + f"Intake: {intake['intake_id']}\n" + f"Events: {summary['events_total']}, failed_logons={summary['failed_logon_rows']}, " + f"suspicious_pwsh={summary['suspicious_pwsh']}, credential_events={summary['credential_events']}\n" + f"Top rules: {top}\n" + f"Report: {intake['report_dir']}" + ) + + +def send_telegram(bot_token: str, chat_ids: list[str], text: str) -> list[dict[str, Any]]: + results = [] + for chat_id in chat_ids: + payload = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode("utf-8") + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + req = urllib.request.Request(url, data=payload, method="POST") + try: + with urllib.request.urlopen(req) as resp: + body = json.loads(resp.read().decode("utf-8")) + results.append({"chat_id": chat_id, "ok": True, "response": body}) + except Exception as exc: # noqa: BLE001 + results.append({"chat_id": chat_id, "ok": False, "error": str(exc)}) + return results + + +def build_telegram_text(case_id: int | None, intake: dict[str, Any], summary: dict[str, Any]) -> str: + top = ", ".join(f"{item['title']} ({item['count']})" for item in summary.get("top_rules", [])[:3]) or "n/a" + case_part = f"\nCase: {case_id}" if case_id is not None else "" + return ( + f"Hayabusa {summary['severity'].upper()} alert\n" + f"Host: {intake['host']}{case_part}\n" + f"Intake: {intake['intake_id']}\n" + f"Score: {summary['score']}\n" + f"Events: {summary['events_total']}, failed_logons={summary['failed_logon_rows']}, " + f"suspicious_pwsh={summary['suspicious_pwsh']}\n" + f"Top rules: {top}\n" + f"Report: {intake['report_dir']}" + ) + + +def main() -> int: + p = argparse.ArgumentParser(description="Auto-create/update AW-rus case, compute Hayabusa severity, and send Telegram alerts") + p.add_argument("--case-id", type=int) + p.add_argument("--intake-json", default="/opt/hayabusa/state/latest-intake.json") + p.add_argument("--case-api-base", default=os.environ.get("AW_HAYABUSA_CASE_API_BASE", "http://127.0.0.1:5602")) + p.add_argument("--mode", default="incident") + p.add_argument("--link-source", default="aw-rus-drop-autoprocess") + p.add_argument("--auto-create", action="store_true", default=env_bool("AW_HAYABUSA_AUTO_CASE_ENABLED", True)) + p.add_argument("--auto-create-min-severity", default=os.environ.get("AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY", "medium")) + p.add_argument("--telegram-enabled", action="store_true", default=env_bool("AW_HAYABUSA_TELEGRAM_ENABLED", False)) + p.add_argument("--telegram-min-severity", default=os.environ.get("AW_HAYABUSA_TELEGRAM_MIN_SEVERITY", "high")) + p.add_argument("--telegram-bot-token", default=os.environ.get("AW_HAYABUSA_TELEGRAM_BOT_TOKEN", "")) + p.add_argument("--telegram-chat-ids", default=os.environ.get("AW_HAYABUSA_TELEGRAM_CHAT_IDS", "")) + args = p.parse_args() + + intake_path = pathlib.Path(args.intake_json) + intake = json.loads(intake_path.read_text(encoding="utf-8")) + summary = analyze_report(pathlib.Path(intake["report_dir"])) + case_api_base = args.case_api_base.rstrip("/") + if case_api_base.endswith("/api/0/dlp/cases"): + case_api_base = case_api_base[: -len("/api/0/dlp/cases")] + case_id = args.case_id + created_case = None + case_error = None + linked = False + comment_added = False + + try: + if case_id is None and args.auto_create and severity_meets(summary["severity"], args.auto_create_min_severity): + created_case = post_json(f"{case_api_base}/api/0/dlp/cases", build_case_payload(intake, summary)) + case_id = int(created_case["id"]) + + if case_id is not None: + patch_json( + f"{case_api_base}/api/0/dlp/cases/{case_id}", + {"severity": summary["severity"]}, + ) + post_json( + f"{case_api_base}/api/0/dlp/cases/{case_id}/forensics/hayabusa", + build_hayabusa_payload(intake, args.mode, args.link_source), + ) + linked = True + post_json( + f"{case_api_base}/api/0/dlp/cases/{case_id}/comments", + {"comment": build_comment(summary, intake), "author": "aw-hayabusa-auto"}, + ) + comment_added = True + except Exception as exc: # noqa: BLE001 + case_error = str(exc) + + telegram_results: list[dict[str, Any]] = [] + if args.telegram_enabled and args.telegram_bot_token and severity_meets(summary["severity"], args.telegram_min_severity): + chat_ids = [item.strip() for item in args.telegram_chat_ids.split(",") if item.strip()] + if chat_ids: + telegram_results = send_telegram( + bot_token=args.telegram_bot_token, + chat_ids=chat_ids, + text=build_telegram_text(case_id, intake, summary), + ) + + result = { + "summary": summary, + "case_id": case_id, + "case_created": created_case, + "case_linked": linked, + "case_comment_added": comment_added, + "case_error": case_error, + "telegram_results": telegram_results, + } + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if case_error is None else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/security-analytics-stack-v1.md b/docs/security-analytics-stack-v1.md new file mode 100644 index 0000000..a8d958a --- /dev/null +++ b/docs/security-analytics-stack-v1.md @@ -0,0 +1,107 @@ +# AW-rus Security Analytics Stack v1 + +## Goal + +Build a sufficient internal security analytics stack for the current environment without pretending to be Splunk-class infrastructure. + +## Scope + +Sources: +- Windows EVTX +- ActivityWatch buckets +- DLP incidents +- file operations +- outbound email +- session/logon markers + +Core outcomes: +- ingest +- normalize +- detect +- correlate +- case +- notify +- investigate + +## v1 Architecture + +### Windows side + +- collectors and DLP scripts write to `deployment-config.json` +- `export-evtx-for-hayabusa.ps1` exports bounded EVTX packages +- `export-upload-hayabusa-to-aw-server.ps1` uploads: + - `zip` + - `.meta.json` + - optional `.caseid` +- `ActivityWatch Hayabusa Upload` scheduled task runs every 6 hours + +### Server side + +- `aw-hayabusa-drop.path` watches `/opt/activitywatch/aw-rus-ops/drop` +- `aw-hayabusa-drop.service` runs `aw-hayabusa-autoprocess` +- `aw-hayabusa` performs: + - accept + - process-inbox + - report generation +- `aw-hayabusa-case-alert` performs: + - severity scoring from `timeline.jsonl` + - optional auto-case creation + - bounded Hayabusa linkage + - summary comment + - Telegram alerting + +### Case layer + +- DLP case API remains the source of truth for incident lifecycle +- Hayabusa writes bounded metadata into `forensics.hayabusa` +- auto-created cases use: + - `incident_id = hayabusa::` + +## Severity Model v1 + +Inputs: +- Hayabusa `Level` +- top `RuleTitle` +- failed logon count +- suspicious PowerShell count +- credential-related detections +- timestomp detections + +Outputs: +- `low` +- `medium` +- `high` +- `critical` + +Rules: +- `critical` for `crit` alerts, very high score, or strong compound signals +- `high` for at least one high alert or elevated score +- `medium` for med alerts, notable failed logons, or moderate score +- `low` otherwise + +## Automation Policy v1 + +- EVTX upload every 6 hours +- lookback window: 6 hours +- auto-case enabled from `medium` +- Telegram enabled from `high` +- human operator only for final triage/escalation + +## Non-goals + +Not trying to implement: +- distributed search cluster +- Splunk-style indexers/search heads +- full SIEM content ecosystem +- petabyte-scale retention design + +## Definition of Done + +The stack is sufficient when it can, without a dedicated analyst: +- collect relevant data +- process EVTX on schedule +- score severity +- create/update a case +- send an alert +- preserve investigation artifacts +- let a human understand what happened in a few minutes diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index b5ece7b..f80c1bf 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -502,6 +502,7 @@ function New-ActivityWatchDeploymentConfig { [Parameter(Mandatory = $true)] [string]$SessionCollectorScript, [string]$EvtxExportScript, + [string]$HayabusaUploadScript, [string]$EmailCollectorScript, [Parameter(Mandatory = $true)] [string]$RulesPath, @@ -541,6 +542,11 @@ function New-ActivityWatchDeploymentConfig { [Parameter(Mandatory = $true)] [pscustomobject[]]$UserTasks, [string]$PackageVersion = 'v0.13.2', + [bool]$HayabusaAutoUploadEnabled = $true, + [int]$HayabusaAutoUploadIntervalHours = 6, + [int]$HayabusaAutoUploadHoursBack = 6, + [string]$HayabusaAutoUploadMode = 'incident', + [string]$HayabusaAutoUploadTaskName = 'ActivityWatch Hayabusa Upload', [switch]$IntegrationTestEnabled ) @@ -581,6 +587,7 @@ function New-ActivityWatchDeploymentConfig { fileCollectorScript = $FileCollectorScript sessionCollectorScript = $SessionCollectorScript evtxExportScript = $EvtxExportScript + hayabusaUploadScript = $HayabusaUploadScript rulesPath = $RulesPath policyPath = $PolicyPath launchScript = $LaunchScriptPath @@ -608,6 +615,13 @@ function New-ActivityWatchDeploymentConfig { evtxExportRoot = $effectiveEvtxExportRoot retentionDays = $EvtxRetentionDays evtxChannels = @($effectiveEvtxChannels) + hayabusaAutomation = [pscustomobject]@{ + enabled = [bool]$HayabusaAutoUploadEnabled + intervalHours = $HayabusaAutoUploadIntervalHours + hoursBack = $HayabusaAutoUploadHoursBack + mode = $HayabusaAutoUploadMode + taskName = $HayabusaAutoUploadTaskName + } } sessionEvents = [pscustomobject]@{ logonEnabled = $LogonMarkerEnabled @@ -1539,6 +1553,48 @@ function Register-ActivityWatchRecoveryTask { Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null } +function Register-ActivityWatchHayabusaAutoUploadTask { + param( + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath + $forensics = $config.forensics + if ($null -eq $forensics -or $forensics.PSObject.Properties.Name -notcontains 'hayabusaAutomation') { + return + } + + $automation = $forensics.hayabusaAutomation + $taskName = if ($automation.PSObject.Properties.Name -contains 'taskName' -and -not [string]::IsNullOrWhiteSpace([string]$automation.taskName)) { + [string]$automation.taskName + } else { + 'ActivityWatch Hayabusa Upload' + } + + if (-not [bool]$automation.enabled) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + return + } + + $uploadScript = if ($config.paths.PSObject.Properties.Name -contains 'hayabusaUploadScript') { [string]$config.paths.hayabusaUploadScript } else { Join-Path $config.paths.stateRoot 'export-upload-hayabusa-to-aw-server.ps1' } + if (-not (Test-Path -LiteralPath $uploadScript)) { + throw "Не найден скрипт Hayabusa upload: $uploadScript" + } + + $intervalHours = [Math]::Max(1, [int]$automation.intervalHours) + $hoursBack = [Math]::Max(1, [int]$automation.hoursBack) + $mode = if ($automation.PSObject.Properties.Name -contains 'mode' -and -not [string]::IsNullOrWhiteSpace([string]$automation.mode)) { [string]$automation.mode } else { 'incident' } + $powerShellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $taskCommand = "`"$powerShellExe`" -NoProfile -ExecutionPolicy Bypass -File `"$uploadScript`" -ConfigPath `"$ConfigPath`" -HoursBack $hoursBack -Mode `"$mode`"" + + Remove-ActivityWatchScheduledTask -TaskName $taskName + & schtasks.exe /Create /TN $taskName /TR $taskCommand /SC HOURLY /MO $intervalHours /ST 00:00 /RU SYSTEM /RL HIGHEST /F | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Не удалось создать scheduled task $taskName через schtasks.exe" + } +} + function Set-ActivityWatchAcl { param( [Parameter(Mandatory = $true)] diff --git a/windows/deploy-domain-users.ps1 b/windows/deploy-domain-users.ps1 index 0b93b1e..8f10646 100755 --- a/windows/deploy-domain-users.ps1 +++ b/windows/deploy-domain-users.ps1 @@ -39,6 +39,11 @@ param( [string]$PolicyEngineScheme = 'http', [int]$PolicyRefreshSeconds = 300, [string]$PolicyCachePath, + [bool]$HayabusaAutoUploadEnabled = $true, + [int]$HayabusaAutoUploadIntervalHours = 6, + [int]$HayabusaAutoUploadHoursBack = 6, + [string]$HayabusaAutoUploadMode = 'incident', + [string]$HayabusaAutoUploadTaskName = 'ActivityWatch Hayabusa Upload', [switch]$IntegrationTestEnabled ) @@ -109,6 +114,7 @@ $config = New-ActivityWatchDeploymentConfig ` -FileCollectorScript $assetResult.FileCollectorScript ` -SessionCollectorScript $assetResult.SessionCollectorScript ` -EvtxExportScript $assetResult.EvtxExportScript ` + -HayabusaUploadScript $assetResult.HayabusaUploadScript ` -RulesPath $assetResult.ActiveRules ` -PolicyPath $assetResult.ActivePolicy ` -PollSeconds $PollSeconds ` @@ -133,6 +139,11 @@ $config = New-ActivityWatchDeploymentConfig ` -PolicyEngineScheme $PolicyEngineScheme ` -PolicyRefreshSeconds $PolicyRefreshSeconds ` -PolicyCachePath $PolicyCachePath ` + -HayabusaAutoUploadEnabled $HayabusaAutoUploadEnabled ` + -HayabusaAutoUploadIntervalHours $HayabusaAutoUploadIntervalHours ` + -HayabusaAutoUploadHoursBack $HayabusaAutoUploadHoursBack ` + -HayabusaAutoUploadMode $HayabusaAutoUploadMode ` + -HayabusaAutoUploadTaskName $HayabusaAutoUploadTaskName ` -LaunchScriptPath $launchScriptPath ` -RecoveryScriptPath $recoveryScriptPath ` -UserTasks $taskDefinitions ` @@ -144,6 +155,7 @@ Remove-LegacyActivityWatchEntries Set-ActivityWatchAcl -InstallRoot $InstallRoot -StateRoot $StateRoot -LogsRoot $logsRoot Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $launchScriptPath -ConfigPath $configPath Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath +Register-ActivityWatchHayabusaAutoUploadTask -ConfigPath $configPath Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName Write-Host 'ActivityWatch развёрнут для пользователей:' diff --git a/windows/deploy-ensemble.ps1 b/windows/deploy-ensemble.ps1 index 11d4366..3091051 100644 --- a/windows/deploy-ensemble.ps1 +++ b/windows/deploy-ensemble.ps1 @@ -40,6 +40,11 @@ param( [int]$PolicyRefreshSeconds = 300, [string]$PolicyCachePath, [string]$ReportPath, + [bool]$HayabusaAutoUploadEnabled = $true, + [int]$HayabusaAutoUploadIntervalHours = 6, + [int]$HayabusaAutoUploadHoursBack = 6, + [string]$HayabusaAutoUploadMode = 'incident', + [string]$HayabusaAutoUploadTaskName = 'ActivityWatch Hayabusa Upload', [switch]$SkipHardening, [switch]$ValidateAfterDeploy, [switch]$IntegrationTestEnabled @@ -98,6 +103,11 @@ if (-not (Test-Path -LiteralPath $deployScript)) { -PolicyEngineScheme $PolicyEngineScheme ` -PolicyRefreshSeconds $PolicyRefreshSeconds ` -PolicyCachePath $PolicyCachePath ` + -HayabusaAutoUploadEnabled $HayabusaAutoUploadEnabled ` + -HayabusaAutoUploadIntervalHours $HayabusaAutoUploadIntervalHours ` + -HayabusaAutoUploadHoursBack $HayabusaAutoUploadHoursBack ` + -HayabusaAutoUploadMode $HayabusaAutoUploadMode ` + -HayabusaAutoUploadTaskName $HayabusaAutoUploadTaskName ` -IntegrationTestEnabled:$IntegrationTestEnabled if (-not $SkipHardening) { diff --git a/windows/export-evtx-for-hayabusa.ps1 b/windows/export-evtx-for-hayabusa.ps1 index 0a1d4a6..f7681e1 100644 --- a/windows/export-evtx-for-hayabusa.ps1 +++ b/windows/export-evtx-for-hayabusa.ps1 @@ -4,6 +4,7 @@ param( [string]$OutputRoot, [int]$RetentionDays, [string[]]$Channels, + [Nullable[int]]$HoursBack, [int]$DaysBack = 3, [switch]$NoZip ) @@ -78,8 +79,12 @@ $zipPath = Join-Path $effectiveOutputRoot "$hostName-$timestamp.zip" New-Directory -Path $batchRoot New-Directory -Path $evtxRoot -$daysBackMs = [int64]$DaysBack * 24 * 60 * 60 * 1000 -$query = "*[System[TimeCreated[timediff(@SystemTime) <= $daysBackMs]]]" +$lookbackMs = if ($null -ne $HoursBack) { + [int64]$HoursBack * 60 * 60 * 1000 +} else { + [int64]$DaysBack * 24 * 60 * 60 * 1000 +} +$query = "*[System[TimeCreated[timediff(@SystemTime) <= $lookbackMs]]]" $results = @() foreach ($channel in @($effectiveChannels | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })) { @@ -117,6 +122,7 @@ $manifest = [ordered]@{ batchRoot = $batchRoot zipPath = if ($NoZip) { $null } else { $zipPath } daysBack = $DaysBack + hoursBack = if ($null -ne $HoursBack) { [int]$HoursBack } else { $null } retentionDays = $effectiveRetentionDays channels = @($effectiveChannels) exports = @($results) diff --git a/windows/export-upload-hayabusa-to-aw-server.ps1 b/windows/export-upload-hayabusa-to-aw-server.ps1 index b02488c..905d386 100644 --- a/windows/export-upload-hayabusa-to-aw-server.ps1 +++ b/windows/export-upload-hayabusa-to-aw-server.ps1 @@ -1,8 +1,9 @@ [CmdletBinding()] param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [Nullable[int]]$HoursBack, [int]$DaysBack = 1, - [string]$ServerHost = '10.10.10.13', + [string]$ServerHost = '', [string]$ServerUser = 'awops', [string]$RemoteDropDir = '/opt/activitywatch/aw-rus-ops/drop', [string]$RemoteKeyPath = 'C:\ProgramData\AWatch-rus\ssh\awops_ed25519', @@ -41,7 +42,23 @@ if (-not (Test-Path -LiteralPath $RemoteKeyPath)) { throw "SSH private key not found: $RemoteKeyPath" } -$export = & $exportScript -ConfigPath $ConfigPath -DaysBack $DaysBack +$config = Get-Content -Raw -LiteralPath $ConfigPath | ConvertFrom-Json +if ([string]::IsNullOrWhiteSpace($ServerHost)) { + $ServerHost = [string]$config.server.host +} +if ([string]::IsNullOrWhiteSpace($ServerHost)) { + throw "ServerHost is empty and deployment-config has no server.host: $ConfigPath" +} + +$exportArgs = @{ + ConfigPath = $ConfigPath +} +if ($null -ne $HoursBack) { + $exportArgs.HoursBack = [int]$HoursBack +} else { + $exportArgs.DaysBack = $DaysBack +} +$export = & $exportScript @exportArgs $zipPath = [string]$export.zipPath $hostName = [string]$export.hostname if ([string]::IsNullOrWhiteSpace($zipPath) -or -not (Test-Path -LiteralPath $zipPath)) { @@ -52,9 +69,23 @@ $zipName = Split-Path -Leaf $zipPath $remoteTarget = "$ServerUser@$ServerHost`:$RemoteDropDir/" $baseName = [System.IO.Path]::GetFileNameWithoutExtension($zipPath) $caseIdPath = Join-Path ([System.IO.Path]::GetDirectoryName($zipPath)) ($baseName + '.caseid') +$metaPath = Join-Path ([System.IO.Path]::GetDirectoryName($zipPath)) ($baseName + '.meta.json') $effectiveKeyPath = New-TemporarySshKeyCopy -SourceKeyPath $RemoteKeyPath try { + $meta = [ordered]@{ + host = $hostName + mode = $Mode + link_source = 'windows-drop-upload' + } + if ($null -ne $CaseId) { + $meta.case_id = [int]$CaseId + } + $meta | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $metaPath -Encoding UTF8 + & scp.exe -i $effectiveKeyPath -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL $metaPath $remoteTarget + if ($LASTEXITCODE -ne 0) { + throw "scp meta upload failed with rc=$LASTEXITCODE" + } if ($null -ne $CaseId) { Set-Content -LiteralPath $caseIdPath -Value ([string]$CaseId) -Encoding ASCII & scp.exe -i $effectiveKeyPath -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL $caseIdPath $remoteTarget @@ -69,12 +100,14 @@ try { } finally { Remove-Item -LiteralPath $effectiveKeyPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $metaPath -Force -ErrorAction SilentlyContinue } $result = [ordered]@{ exportedZip = $zipPath uploadedTo = "$RemoteDropDir/$zipName" caseIdSidecar = if ($null -ne $CaseId) { "$RemoteDropDir/$baseName.caseid" } else { $null } + metaSidecar = "$RemoteDropDir/$baseName.meta.json" hostname = $hostName mode = $Mode runRemote = [bool]$RunRemote diff --git a/windows/hardening-recovery.ps1 b/windows/hardening-recovery.ps1 index dd9852e..752d73d 100755 --- a/windows/hardening-recovery.ps1 +++ b/windows/hardening-recovery.ps1 @@ -70,6 +70,7 @@ $effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSO $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' } $effectiveEvtxExportScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'evtxExportScript') { [string]$existingConfig.paths.evtxExportScript } else { Join-Path $effectiveStateRoot 'export-evtx-for-hayabusa.ps1' } +$effectiveHayabusaUploadScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'hayabusaUploadScript') { [string]$existingConfig.paths.hayabusaUploadScript } else { Join-Path $effectiveStateRoot 'export-upload-hayabusa-to-aw-server.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' } $effectivePolicyClientScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyClientScript') { [string]$existingConfig.paths.policyClientScript } else { Join-Path $effectiveStateRoot 'dlp-policy-client.ps1' } @@ -100,6 +101,11 @@ $effectivePolicyEnginePort = if ($PSBoundParameters.ContainsKey('PolicyEnginePor $effectivePolicyEngineScheme = if ($PSBoundParameters.ContainsKey('PolicyEngineScheme') -and $PolicyEngineScheme) { [string]$PolicyEngineScheme } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'scheme') { [string]$existingConfig.policyEngine.scheme } else { 'http' } $effectivePolicyRefreshSeconds = if ($PSBoundParameters.ContainsKey('PolicyRefreshSeconds')) { [int]$PolicyRefreshSeconds } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'refreshSeconds') { [int]$existingConfig.policyEngine.refreshSeconds } else { 300 } $effectivePolicyCachePath = if ($PSBoundParameters.ContainsKey('PolicyCachePath') -and $PolicyCachePath) { [string]$PolicyCachePath } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'cachePath') { [string]$existingConfig.policyEngine.cachePath } else { Join-Path $effectiveStateRoot 'dlp-policy-cache.json' } +$effectiveHayabusaAutoUploadEnabled = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'enabled') { [bool]$existingConfig.forensics.hayabusaAutomation.enabled } else { $true } +$effectiveHayabusaAutoUploadIntervalHours = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'intervalHours') { [int]$existingConfig.forensics.hayabusaAutomation.intervalHours } else { 6 } +$effectiveHayabusaAutoUploadHoursBack = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'hoursBack') { [int]$existingConfig.forensics.hayabusaAutomation.hoursBack } else { 6 } +$effectiveHayabusaAutoUploadMode = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'mode') { [string]$existingConfig.forensics.hayabusaAutomation.mode } else { 'incident' } +$effectiveHayabusaAutoUploadTaskName = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'taskName') { [string]$existingConfig.forensics.hayabusaAutomation.taskName } else { 'ActivityWatch Hayabusa Upload' } $effectiveUsers = if ($Users -or $UserListPath) { Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain @@ -131,6 +137,7 @@ $assetResult = Copy-ActivityWatchCollectorAssets ` -FileCollectorScriptSource (Join-Path $PSScriptRoot 'file-operations-collector.ps1') ` -SessionCollectorScriptSource (Join-Path $PSScriptRoot 'worktime-session-collector.ps1') ` -EvtxExportScriptSource (Join-Path $PSScriptRoot 'export-evtx-for-hayabusa.ps1') ` + -HayabusaUploadScriptSource (Join-Path $PSScriptRoot 'export-upload-hayabusa-to-aw-server.ps1') ` -ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') ` -ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') ` -StateRoot $effectiveStateRoot ` @@ -155,6 +162,7 @@ $config = New-ActivityWatchDeploymentConfig ` -FileCollectorScript $effectiveFileCollector ` -SessionCollectorScript $effectiveSessionCollector ` -EvtxExportScript $effectiveEvtxExportScript ` + -HayabusaUploadScript $effectiveHayabusaUploadScript ` -RulesPath $effectiveRules ` -PolicyPath $effectivePolicy ` -PollSeconds $effectivePollSeconds ` @@ -179,6 +187,11 @@ $config = New-ActivityWatchDeploymentConfig ` -PolicyEngineScheme $effectivePolicyEngineScheme ` -PolicyRefreshSeconds $effectivePolicyRefreshSeconds ` -PolicyCachePath $effectivePolicyCachePath ` + -HayabusaAutoUploadEnabled $effectiveHayabusaAutoUploadEnabled ` + -HayabusaAutoUploadIntervalHours $effectiveHayabusaAutoUploadIntervalHours ` + -HayabusaAutoUploadHoursBack $effectiveHayabusaAutoUploadHoursBack ` + -HayabusaAutoUploadMode $effectiveHayabusaAutoUploadMode ` + -HayabusaAutoUploadTaskName $effectiveHayabusaAutoUploadTaskName ` -LaunchScriptPath $effectiveLaunchScript ` -RecoveryScriptPath $effectiveRecoveryScript ` -UserTasks $taskDefinitions ` @@ -189,6 +202,7 @@ Remove-LegacyActivityWatchEntries Set-ActivityWatchAcl -InstallRoot $effectiveInstallRoot -StateRoot $effectiveStateRoot -LogsRoot $effectiveLogsRoot Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $effectiveLaunchScript -ConfigPath $effectiveConfigPath Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $effectiveRecoveryScript -ConfigPath $effectiveConfigPath +Register-ActivityWatchHayabusaAutoUploadTask -ConfigPath $effectiveConfigPath Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName Write-Host 'Укрепление и восстановление ActivityWatch завершены.' diff --git a/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss b/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss index 9568185..7103626 100644 --- a/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss +++ b/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss @@ -2,9 +2,9 @@ #define MyAppVersion "1.0.0" #define MyAppPublisher "AWatch-rus" -#define AwDefaultServerHost "10.10.10.13" +#define AwDefaultServerHost "aw-server" #define AwDefaultServerPort "5600" -#define AwDefaultWorktimeReportBase "http://10.10.10.13:5610" +#define AwDefaultWorktimeReportBase "http://aw-server:5610" #define AwDefaultWorktimeHost "SHARKON2025" #define AwDefaultUsers "user1,user2,user3,user4,user5" #define AwDefaultInstallRoot "C:\\Program Files\\AWatch-rus\\bin" diff --git a/windows/installkit/innosetup/BUILD.md b/windows/installkit/innosetup/BUILD.md index 8ddda3c..16df503 100644 --- a/windows/installkit/innosetup/BUILD.md +++ b/windows/installkit/innosetup/BUILD.md @@ -29,7 +29,7 @@ The resulting installer `AWatch-rus-InstallKit.exe` is written to the same direc The installer wizard asks only for: -- `ServerHost` / `ServerPort` (defaults to `10.10.10.13:5600`) +- `ServerHost` / `ServerPort` (defaults to `aw-server:5600`) All other values are taken from defaults embedded in installer scripts. diff --git a/windows/validate-deployment.ps1 b/windows/validate-deployment.ps1 index 1273a86..5ca3392 100644 --- a/windows/validate-deployment.ps1 +++ b/windows/validate-deployment.ps1 @@ -38,6 +38,8 @@ $fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' - function Get-LoggedOnUsers { $users = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + $activeStates = @('Active', 'Активно') + $inactiveStates = @('Disc', 'Disconnected', 'Idle', 'Listen', 'Диск', 'Откл', 'Отключен') try { $lines = & quser.exe 2>$null foreach ($line in @($lines)) { @@ -50,6 +52,16 @@ function Get-LoggedOnUsers { if ($parts.Count -lt 1) { continue } $user = [string]$parts[0] if ([string]::IsNullOrWhiteSpace($user)) { continue } + $state = $null + foreach ($part in @($parts | Select-Object -Skip 1)) { + $token = [string]$part + if ([string]::IsNullOrWhiteSpace($token)) { continue } + if ($activeStates -contains $token -or $inactiveStates -contains $token) { + $state = $token + break + } + } + if ($null -ne $state -and $activeStates -notcontains $state) { continue } [void]$users.Add($user) [void]$users.Add(('{0}\{1}' -f $env:COMPUTERNAME, $user)) if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) {