From 0cce6fd08e7d9d18720e4a3f7484f88868860f31 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 14 May 2026 15:04:07 +0300 Subject: [PATCH] feat(dfir): add hayabusa forensic workflow integration --- ansible/deploy_aw_server.yml | 93 ++++ ansible/deploy_aw_windows.yml | 10 + ansible/deploy_tsj_guardian_bot_proxmox.yml | 60 +- ansible/group_vars/aw_server.yml | 18 + ansible/group_vars/aw_windows.yml | 9 + ansible/group_vars/proxmox-bot.example.yml | 3 + ansible/group_vars/windows.example.yml | 9 + aw-server/aw-ru-patch.js | 18 +- aw-server/dlp-case-management/case_schema.py | 18 +- aw-server/dlp-case-management/case_service.py | 10 +- aw-server/dlp-case-management/case_storage.py | 76 ++- .../dlp-case-management/test_case_storage.py | 51 ++ aw-server/hayabusa/aw-hayabusa.sh | 522 ++++++++++++++++++ docs/hayabusa-artifact-workflow-2026-05-14.md | 76 +++ .../hayabusa-aw-rus-integration-2026-05-14.md | 76 +++ docs/hayabusa-operator-ib-guide-2026-05-14.md | 163 ++++++ docs/hayabusa-server-runner-2026-05-14.md | 124 +++++ docs/hayabusa-source-packaging-2026-05-14.md | 59 ++ docs/wiki/Home.md | 2 + docs/windows-hayabusa-evtx-export.md | 78 +++ proxmox/tsj_guardian_bot.py | 234 +++++++- windows/ActivityWatch.Windows.Common.psm1 | 29 + windows/deploy-domain-users.ps1 | 9 + windows/deploy-ensemble.ps1 | 9 + windows/deploy-single-user.ps1 | 9 + windows/export-evtx-for-hayabusa.ps1 | 141 +++++ windows/hardening-recovery.ps1 | 10 + windows/validate-deployment.ps1 | 16 +- 28 files changed, 1907 insertions(+), 25 deletions(-) create mode 100644 aw-server/dlp-case-management/test_case_storage.py create mode 100644 aw-server/hayabusa/aw-hayabusa.sh create mode 100644 docs/hayabusa-artifact-workflow-2026-05-14.md create mode 100644 docs/hayabusa-aw-rus-integration-2026-05-14.md create mode 100644 docs/hayabusa-operator-ib-guide-2026-05-14.md create mode 100644 docs/hayabusa-server-runner-2026-05-14.md create mode 100644 docs/hayabusa-source-packaging-2026-05-14.md create mode 100644 docs/windows-hayabusa-evtx-export.md create mode 100644 windows/export-evtx-for-hayabusa.ps1 diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 7f6aad5..313fb29 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -1314,6 +1314,99 @@ msg: "{{ aw_hayabusa_ioc_refresh_result.stdout }}" when: aw_hayabusa_ioc_refresh_result.stdout is defined + - name: Установить server-side Hayabusa runner + when: aw_hayabusa_runner_enabled | default(false) | bool + block: + - name: Создать каталоги Hayabusa + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0755" + loop: + - "{{ aw_hayabusa_root }}" + - "{{ aw_hayabusa_root }}/releases" + - "{{ aw_hayabusa_release_dir }}" + - "{{ aw_hayabusa_reports_dir }}" + - "{{ aw_hayabusa_state_dir }}" + - "{{ aw_hayabusa_inbox_dir }}" + - "{{ aw_hayabusa_archive_dir }}" + - "{{ aw_hayabusa_incoming_dir }}" + - "{{ aw_hayabusa_staging_dir }}" + - "{{ aw_hayabusa_archive_packages_dir }}" + - "{{ aw_hayabusa_archive_extracted_dir }}" + - "{{ aw_hayabusa_logs_dir }}" + + - name: Скачать архив Hayabusa + ansible.builtin.get_url: + url: "{{ aw_hayabusa_download_url }}" + dest: "{{ aw_hayabusa_archive_path }}" + mode: "0644" + + - name: Распаковать pinned Hayabusa release + ansible.builtin.unarchive: + src: "{{ aw_hayabusa_archive_path }}" + dest: "{{ aw_hayabusa_release_dir }}" + remote_src: true + creates: "{{ aw_hayabusa_release_dir }}/{{ aw_hayabusa_binary_name }}" + + - name: Нормализовать права release Hayabusa + ansible.builtin.file: + path: "{{ aw_hayabusa_release_dir }}" + state: directory + owner: root + group: root + mode: "0755" + recurse: true + + - name: Сделать бинарь Hayabusa исполняемым + ansible.builtin.file: + path: "{{ aw_hayabusa_release_dir }}/{{ aw_hayabusa_binary_name }}" + owner: root + group: root + mode: "0755" + state: file + + - name: Создать canonical symlink на текущий релиз Hayabusa + ansible.builtin.file: + src: "{{ aw_hayabusa_release_dir }}" + dest: "{{ aw_hayabusa_current_link }}" + state: link + force: true + + - name: Создать canonical symlink на бинарь Hayabusa + ansible.builtin.file: + src: "{{ aw_hayabusa_binary_name }}" + dest: "{{ aw_hayabusa_release_dir }}/hayabusa" + state: link + force: true + + - name: Установить wrapper aw-hayabusa + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa.sh" + dest: /usr/local/bin/aw-hayabusa + owner: root + group: root + mode: "0755" + + - name: Проверить server-side runner через doctor + ansible.builtin.command: + cmd: /usr/local/bin/aw-hayabusa doctor + changed_when: false + + - name: Проверить загрузку profiles Hayabusa + ansible.builtin.shell: /usr/local/bin/aw-hayabusa profiles > /tmp/aw-hayabusa-profiles.txt + args: + executable: /bin/bash + changed_when: false + + - name: Проверить наличие standard profile у Hayabusa + ansible.builtin.shell: "grep -q 'standard:' /tmp/aw-hayabusa-profiles.txt" + args: + executable: /bin/bash + changed_when: false + - name: Post-deploy health gate (aw-health-check) when: - not ansible_check_mode diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index d0c1d12..288802b 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -38,6 +38,7 @@ 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_forensics_root: "{{ aw_windows_state_root }}\\forensics\\evtx-exports" 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" @@ -169,6 +170,8 @@ IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_enabled | bool) else '$false' }} IncidentScreenshotEnabled = {{ '$true' if (aw_windows_incident_screenshot_enabled | bool) else '$false' }} IncidentArtifactsRoot = "{{ aw_windows_incident_artifacts_root }}" + EvtxExportRoot = "{{ aw_windows_forensics_root }}" + EvtxRetentionDays = {{ aw_windows_evtx_retention_days | int }} 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' }} @@ -185,6 +188,13 @@ {% if (aw_windows_package_zip_path | default('') | string | length) > 0 %} $params.PackageZipPath = "{{ aw_windows_package_zip_path }}" {% endif %} + {% if (aw_windows_evtx_channels | default([]) | length) > 0 %} + $params.EvtxChannels = @( + {% for channel in aw_windows_evtx_channels %} + "{{ channel }}"{% if not loop.last %},{% endif %} + {% endfor %} + ) + {% endif %} {% if (aw_windows_hostname_override | default('') | string | length) > 0 %} $params.AwHostname = "{{ aw_windows_hostname_override }}" {% endif %} diff --git a/ansible/deploy_tsj_guardian_bot_proxmox.yml b/ansible/deploy_tsj_guardian_bot_proxmox.yml index 7b4fb06..8c4081d 100644 --- a/ansible/deploy_tsj_guardian_bot_proxmox.yml +++ b/ansible/deploy_tsj_guardian_bot_proxmox.yml @@ -18,14 +18,24 @@ tsj_bot_default_chat_id: "{{ telegram_default_chat_id | default(telegram_allowed_chat_ids.split(',')[0]) }}" pre_tasks: + - name: Проверить наличие существующего .env бота на хосте + ansible.builtin.stat: + path: "{{ tsj_bot_env_path }}" + register: tsj_bot_existing_env + - name: Проверить обязательные переменные ansible.builtin.assert: that: - - telegram_bot_token is defined - - telegram_bot_token | length > 20 - - telegram_allowed_chat_ids is defined - - telegram_allowed_chat_ids | length > 0 - fail_msg: "Задайте telegram_bot_token и telegram_allowed_chat_ids (см. group_vars/proxmox-bot.example.yml)." + - > + ( + telegram_bot_token is defined and + (telegram_bot_token | string | length) > 20 and + telegram_allowed_chat_ids is defined and + (telegram_allowed_chat_ids | string | length) > 0 + ) + or + (tsj_bot_existing_env.stat.exists | default(false)) + fail_msg: "Задайте telegram_bot_token и telegram_allowed_chat_ids или оставьте на хосте существующий {{ tsj_bot_env_path }}." - name: Проверить наличие исходного файла бота на контроллере ansible.builtin.stat: @@ -70,7 +80,12 @@ mode: "0750" notify: Restart tsj bot - - name: Сгенерировать .env бота + - name: Сгенерировать полный .env бота + when: + - telegram_bot_token is defined + - (telegram_bot_token | string | length) > 20 + - telegram_allowed_chat_ids is defined + - (telegram_allowed_chat_ids | string | length) > 0 ansible.builtin.copy: dest: "{{ tsj_bot_env_path }}" owner: "{{ tsj_bot_user }}" @@ -133,11 +148,44 @@ AW_RUS_WORKTIME_BASE={{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }} AW_RUS_WORKTIME_HEAL_CMD={{ tsj_bot_aw_rus_worktime_heal_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl start aw-worktime-ui-bridge.service'") }} AW_RUS_DLP_HEAL_CMD={{ tsj_bot_aw_rus_dlp_heal_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'") }} + AW_RUS_CASE_API_BASE={{ tsj_bot_aw_rus_case_api_base | default('http://10.10.10.13:5602') }} + AW_RUS_HAYABUSA_ENABLED={{ tsj_bot_aw_rus_hayabusa_enabled | default('true') }} + AW_RUS_HAYABUSA_SSH_CMD={{ tsj_bot_aw_rus_hayabusa_ssh_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13") }} AW_RUS_HOST={{ tsj_bot_aw_rus_host | default('SHARKON2025') }} AW_RUS_PRIMARY_USER={{ tsj_bot_aw_rus_primary_user | default('USER1') }} AW_RUS_STALE_SEC={{ tsj_bot_aw_rus_stale_sec | default(900) }} notify: Restart tsj bot + - name: Обновить только AW-Rus/Hayabusa env ключи в существующем .env + when: + - not ( + telegram_bot_token is defined and + (telegram_bot_token | string | length) > 20 and + telegram_allowed_chat_ids is defined and + (telegram_allowed_chat_ids | string | length) > 0 + ) + - tsj_bot_existing_env.stat.exists | default(false) + ansible.builtin.lineinfile: + path: "{{ tsj_bot_env_path }}" + regexp: "^{{ item.key }}=" + line: "{{ item.key }}={{ item.value }}" + create: false + owner: "{{ tsj_bot_user }}" + group: "{{ tsj_bot_group }}" + mode: "0640" + loop: + - { key: "AW_RUS_API_BASE", value: "{{ tsj_bot_aw_rus_api_base | default('http://10.10.10.13:5600/api/0') }}" } + - { key: "AW_RUS_WORKTIME_BASE", value: "{{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }}" } + - { key: "AW_RUS_WORKTIME_HEAL_CMD", value: "{{ tsj_bot_aw_rus_worktime_heal_cmd | default(\"sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl start aw-worktime-ui-bridge.service'\") }}" } + - { key: "AW_RUS_DLP_HEAL_CMD", value: "{{ tsj_bot_aw_rus_dlp_heal_cmd | default(\"sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'\") }}" } + - { key: "AW_RUS_CASE_API_BASE", value: "{{ tsj_bot_aw_rus_case_api_base | default('http://10.10.10.13:5602') }}" } + - { key: "AW_RUS_HAYABUSA_ENABLED", value: "{{ tsj_bot_aw_rus_hayabusa_enabled | default('true') }}" } + - { key: "AW_RUS_HAYABUSA_SSH_CMD", value: "{{ tsj_bot_aw_rus_hayabusa_ssh_cmd | default(\"sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13\") }}" } + - { key: "AW_RUS_HOST", value: "{{ tsj_bot_aw_rus_host | default('SHARKON2025') }}" } + - { key: "AW_RUS_PRIMARY_USER", value: "{{ tsj_bot_aw_rus_primary_user | default('USER1') }}" } + - { key: "AW_RUS_STALE_SEC", value: "{{ tsj_bot_aw_rus_stale_sec | default(900) }}" } + notify: Restart tsj bot + - name: Установить systemd unit бота ansible.builtin.copy: dest: "/etc/systemd/system/{{ tsj_bot_service_name }}" diff --git a/ansible/group_vars/aw_server.yml b/ansible/group_vars/aw_server.yml index d4013db..6241ea6 100644 --- a/ansible/group_vars/aw_server.yml +++ b/ansible/group_vars/aw_server.yml @@ -12,6 +12,24 @@ ansible_become_password: "{{ lookup('env', 'AW_SUDO_PASSWORD') | default(lookup( aw_hayabusa_ioc_refresh_enabled: false aw_hayabusa_rules_root: "/mnt/usb_hdd1/Projects/hayabusa/rules" aw_hayabusa_ioc_output_dir: "{{ aw_server_data_dir }}/dlp-ioc" +aw_hayabusa_runner_enabled: true +aw_hayabusa_version: "v3.9.0" +aw_hayabusa_asset_name: "hayabusa-3.9.0-lin-x64-gnu.zip" +aw_hayabusa_binary_name: "hayabusa-3.9.0-lin-x64-gnu" +aw_hayabusa_download_url: "https://github.com/Yamato-Security/hayabusa/releases/download/v3.9.0/hayabusa-3.9.0-lin-x64-gnu.zip" +aw_hayabusa_root: "/opt/hayabusa" +aw_hayabusa_release_dir: "{{ aw_hayabusa_root }}/releases/{{ aw_hayabusa_version }}" +aw_hayabusa_current_link: "{{ aw_hayabusa_root }}/current" +aw_hayabusa_archive_path: "/tmp/{{ aw_hayabusa_asset_name }}" +aw_hayabusa_reports_dir: "{{ aw_hayabusa_root }}/reports" +aw_hayabusa_state_dir: "{{ aw_hayabusa_root }}/state" +aw_hayabusa_inbox_dir: "{{ aw_hayabusa_root }}/inbox" +aw_hayabusa_archive_dir: "{{ aw_hayabusa_root }}/archive" +aw_hayabusa_incoming_dir: "{{ aw_hayabusa_inbox_dir }}/incoming" +aw_hayabusa_staging_dir: "{{ aw_hayabusa_inbox_dir }}/staging" +aw_hayabusa_archive_packages_dir: "{{ aw_hayabusa_archive_dir }}/packages" +aw_hayabusa_archive_extracted_dir: "{{ aw_hayabusa_archive_dir }}/extracted" +aw_hayabusa_logs_dir: "{{ aw_hayabusa_state_dir }}/logs" aw_dlp_policy_engine_enabled: true aw_dlp_policy_engine_bind_host: "0.0.0.0" aw_dlp_policy_engine_port: 5601 diff --git a/ansible/group_vars/aw_windows.yml b/ansible/group_vars/aw_windows.yml index 433b886..284497c 100644 --- a/ansible/group_vars/aw_windows.yml +++ b/ansible/group_vars/aw_windows.yml @@ -41,6 +41,15 @@ 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_forensics_root: "{{ aw_windows_state_root }}\\forensics\\evtx-exports" +aw_windows_evtx_retention_days: 14 +aw_windows_evtx_channels: + - Security + - System + - Application + - Microsoft-Windows-PowerShell/Operational + - Microsoft-Windows-TerminalServices-LocalSessionManager/Operational + - Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational aw_windows_logon_marker_enabled: true aw_windows_skip_hardening: false diff --git a/ansible/group_vars/proxmox-bot.example.yml b/ansible/group_vars/proxmox-bot.example.yml index 7fd97d9..e73db07 100644 --- a/ansible/group_vars/proxmox-bot.example.yml +++ b/ansible/group_vars/proxmox-bot.example.yml @@ -42,6 +42,9 @@ tsj_bot_aw_rus_api_base: "http://10.10.10.13:5600/api/0" tsj_bot_aw_rus_worktime_base: "http://10.10.10.13:5610" tsj_bot_aw_rus_worktime_heal_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl start aw-worktime-ui-bridge.service'" tsj_bot_aw_rus_dlp_heal_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'" +tsj_bot_aw_rus_case_api_base: "http://10.10.10.13:5602" +tsj_bot_aw_rus_hayabusa_enabled: "true" +tsj_bot_aw_rus_hayabusa_ssh_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13" tsj_bot_aw_rus_host: "SHARKON2025" tsj_bot_aw_rus_primary_user: "USER1" tsj_bot_aw_rus_stale_sec: 900 diff --git a/ansible/group_vars/windows.example.yml b/ansible/group_vars/windows.example.yml index 9021eb6..bb3af3d8d 100644 --- a/ansible/group_vars/windows.example.yml +++ b/ansible/group_vars/windows.example.yml @@ -29,6 +29,15 @@ 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_forensics_root: "{{ aw_windows_state_root }}\\forensics\\evtx-exports" +aw_windows_evtx_retention_days: 14 +aw_windows_evtx_channels: + - Security + - System + - Application + - Microsoft-Windows-PowerShell/Operational + - Microsoft-Windows-TerminalServices-LocalSessionManager/Operational + - Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational aw_windows_logon_marker_enabled: true aw_windows_skip_hardening: false diff --git a/aw-server/aw-ru-patch.js b/aw-server/aw-ru-patch.js index de91dcb..be2b911 100755 --- a/aw-server/aw-ru-patch.js +++ b/aw-server/aw-ru-patch.js @@ -1149,6 +1149,15 @@ if (!tbody) return; try { const cases = await caseApi("/api/0/dlp/cases?host=" + encodeURIComponent(host) + "&limit=100", { method: "GET" }); + function renderCaseDfir(c) { + const hayabusa = c && c.forensics && c.forensics.hayabusa; + if (!hayabusa) return ""; + const status = String(hayabusa.status || ""); + const mode = String(hayabusa.mode || ""); + const reportDir = String(hayabusa.report_dir || ""); + const title = reportDir ? ' title="' + escapeHtml(reportDir) + '"' : ""; + return 'Hayabusa ' + escapeHtml(status) + (mode ? " · " + escapeHtml(mode) : "") + ''; + } const rows = (cases || []).map(function (c) { return ( "" + @@ -1158,15 +1167,16 @@ "" + escapeHtml(String(c.title || "")) + "" + "" + escapeHtml(String(c.assignee || "")) + "" + "" + escapeHtml(String(c.incident_id || "")) + "" + + "" + renderCaseDfir(c) + "" + "" + escapeHtml(String(c.updated_at || c.created_at || "")) + "" + "" ); }); - tbody.innerHTML = rows.length ? rows.join("") : 'Кейсов нет.'; + tbody.innerHTML = rows.length ? rows.join("") : 'Кейсов нет.'; const status = center.querySelector("[data-aw-ru-dlp-cases-status]"); if (status) status.textContent = "Кейсов: " + (cases || []).length; } catch (error) { - tbody.innerHTML = 'Ошибка загрузки кейсов: ' + escapeHtml(error.message) + ''; + tbody.innerHTML = 'Ошибка загрузки кейсов: ' + escapeHtml(error.message) + ''; const status = center.querySelector("[data-aw-ru-dlp-cases-status]"); if (status) status.textContent = "Кейсы недоступны"; } @@ -1388,8 +1398,8 @@ '
Кейсов: 0
' + '' + '' + - '' + - '' + + '' + + '' + '
IDСтатусSeverityЗаголовокИсполнительIncident IDОбновлено
Загрузка...
IDСтатусSeverityЗаголовокИсполнительIncident IDDFIRОбновлено
Загрузка...
' + '' + '
'; diff --git a/aw-server/dlp-case-management/case_schema.py b/aw-server/dlp-case-management/case_schema.py index 0dc2eba..ec6493d 100644 --- a/aw-server/dlp-case-management/case_schema.py +++ b/aw-server/dlp-case-management/case_schema.py @@ -9,6 +9,22 @@ from pydantic import BaseModel, Field CaseStatus = Literal["open", "investigating", "resolved", "closed"] +class CaseHayabusaLink(BaseModel): + tool: Literal["hayabusa"] = "hayabusa" + host: str = Field(min_length=1, max_length=128) + mode: str = Field(min_length=1, max_length=32) + status: str = Field(min_length=1, max_length=64) + intake_id: str | None = Field(default=None, max_length=256) + package_path: str | None = Field(default=None, max_length=1024) + sha256: str | None = Field(default=None, max_length=128) + report_dir: str | None = Field(default=None, max_length=1024) + summary_html: str | None = Field(default=None, max_length=1024) + timeline_path: str | None = Field(default=None, max_length=1024) + manifest_path: str | None = Field(default=None, max_length=1024) + linked_at: str | None = Field(default=None, max_length=64) + link_source: str | None = Field(default=None, max_length=64) + + class CaseCreate(BaseModel): incident_id: str = Field(min_length=1, max_length=256) host: str | None = Field(default=None, max_length=128) @@ -51,6 +67,6 @@ class CaseRecord(BaseModel): source_bucket: str | None source_event_ts: str | None evidence: dict | None + forensics: dict | None created_at: datetime updated_at: datetime - diff --git a/aw-server/dlp-case-management/case_service.py b/aw-server/dlp-case-management/case_service.py index f01927f..62e7d84 100644 --- a/aw-server/dlp-case-management/case_service.py +++ b/aw-server/dlp-case-management/case_service.py @@ -8,7 +8,7 @@ from typing import Any from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from case_schema import CaseCommentCreate, CaseCreate, CaseUpdate +from case_schema import CaseCommentCreate, CaseCreate, CaseHayabusaLink, CaseUpdate from case_storage import CaseStorage DB = Path(os.environ.get("AW_DLP_CASE_DB_PATH", "/opt/activitywatch/dlp-case-management/cases.db")) @@ -76,3 +76,11 @@ def add_comment(case_id: int, payload: CaseCommentCreate) -> dict[str, Any]: @APP.get("/api/0/dlp/cases/{case_id}/comments") def list_comments(case_id: int, limit: int = Query(default=200, ge=1, le=2000)) -> list[dict[str, Any]]: return STORE.list_comments(case_id=case_id, limit=limit) + + +@APP.post("/api/0/dlp/cases/{case_id}/forensics/hayabusa") +def link_hayabusa(case_id: int, payload: CaseHayabusaLink) -> dict[str, Any]: + try: + return STORE.link_hayabusa(case_id=case_id, payload=payload.model_dump(exclude_none=True), actor="api") + except KeyError: + raise HTTPException(status_code=404, detail="case not found") diff --git a/aw-server/dlp-case-management/case_storage.py b/aw-server/dlp-case-management/case_storage.py index 429be99..43702f3 100644 --- a/aw-server/dlp-case-management/case_storage.py +++ b/aw-server/dlp-case-management/case_storage.py @@ -43,6 +43,7 @@ class CaseStorage: source_bucket TEXT, source_event_ts TEXT, evidence_json TEXT, + forensics_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -69,20 +70,35 @@ class CaseStorage: ); """ ) + self._ensure_column(c, "cases", "forensics_json", "TEXT") c.commit() + @staticmethod + def _ensure_column(c: sqlite3.Connection, table: str, column: str, definition: str) -> None: + columns = { + str(row["name"]) + for row in c.execute(f"PRAGMA table_info({table})").fetchall() + } + if column not in columns: + c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}") + @staticmethod def _now() -> str: return datetime.now(timezone.utc).isoformat() @staticmethod - def _to_case_dict(row: sqlite3.Row) -> dict[str, Any]: - evidence = None - if row["evidence_json"]: - try: - evidence = json.loads(row["evidence_json"]) - except Exception: - evidence = None + def _load_json_field(raw: Any) -> dict[str, Any] | None: + if not raw: + return None + try: + return json.loads(raw) + except Exception: + return None + + @classmethod + def _to_case_dict(cls, row: sqlite3.Row) -> dict[str, Any]: + evidence = cls._load_json_field(row["evidence_json"]) + forensics = cls._load_json_field(row["forensics_json"]) return { "id": int(row["id"]), "incident_id": row["incident_id"], @@ -94,6 +110,7 @@ class CaseStorage: "source_bucket": row["source_bucket"], "source_event_ts": row["source_event_ts"], "evidence": evidence, + "forensics": forensics, "created_at": row["created_at"], "updated_at": row["updated_at"], } @@ -114,8 +131,8 @@ class CaseStorage: """ INSERT INTO cases ( incident_id, host, title, severity, assignee, status, - source_bucket, source_event_ts, evidence_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?) + source_bucket, source_event_ts, evidence_json, forensics_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?) """, ( payload["incident_id"], @@ -126,6 +143,7 @@ class CaseStorage: payload.get("source_bucket"), payload.get("source_event_ts"), json.dumps(normalized_evidence, ensure_ascii=False) if normalized_evidence is not None else None, + None, now, now, ), @@ -195,6 +213,46 @@ class CaseStorage: c.commit() return self.get_case(case_id, c) + def link_hayabusa(self, case_id: int, payload: dict[str, Any], actor: str | None = None) -> dict[str, Any]: + now = self._now() + with self.conn() as c: + existing = self.get_case(case_id, c) + forensics = existing.get("forensics") or {} + forensics["hayabusa"] = { + "tool": "hayabusa", + "host": payload["host"], + "mode": payload["mode"], + "status": payload["status"], + "intake_id": payload.get("intake_id"), + "package_path": payload.get("package_path"), + "sha256": payload.get("sha256"), + "report_dir": payload.get("report_dir"), + "summary_html": payload.get("summary_html"), + "timeline_path": payload.get("timeline_path"), + "manifest_path": payload.get("manifest_path"), + "linked_at": payload.get("linked_at") or now, + "link_source": payload.get("link_source") or "api", + } + c.execute( + "UPDATE cases SET forensics_json = ?, updated_at = ? WHERE id = ?", + (json.dumps(forensics, ensure_ascii=False), now, int(case_id)), + ) + self._insert_audit( + c, + case_id=case_id, + action="link_hayabusa", + actor=actor, + details={ + "host": payload["host"], + "mode": payload["mode"], + "status": payload["status"], + "intake_id": payload.get("intake_id"), + "report_dir": payload.get("report_dir"), + }, + ) + c.commit() + return self.get_case(case_id, c) + def add_comment(self, case_id: int, comment: str, author: str | None = None) -> dict[str, Any]: now = self._now() with self.conn() as c: diff --git a/aw-server/dlp-case-management/test_case_storage.py b/aw-server/dlp-case-management/test_case_storage.py new file mode 100644 index 0000000..24e131d --- /dev/null +++ b/aw-server/dlp-case-management/test_case_storage.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from case_storage import CaseStorage + + +class CaseStorageHayabusaLinkTest(unittest.TestCase): + def test_link_hayabusa_metadata(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "cases.db" + storage = CaseStorage(db_path) + created = storage.create_case( + { + "incident_id": "inc-1", + "host": "SHARKON2025", + "title": "DLP print incident", + "severity": "high", + }, + actor="test", + ) + linked = storage.link_hayabusa( + case_id=int(created["id"]), + payload={ + "host": "SHARKON2025", + "mode": "incident", + "status": "ok", + "intake_id": "pkg-1", + "report_dir": "/opt/hayabusa/reports/SHARKON2025/run-1", + "package_path": "/opt/hayabusa/archive/packages/SHARKON2025/pkg-1.zip", + "sha256": "abc123", + "link_source": "unit-test", + }, + actor="test", + ) + hayabusa = (linked.get("forensics") or {}).get("hayabusa") or {} + self.assertEqual(hayabusa.get("tool"), "hayabusa") + self.assertEqual(hayabusa.get("host"), "SHARKON2025") + self.assertEqual(hayabusa.get("mode"), "incident") + self.assertEqual(hayabusa.get("status"), "ok") + self.assertEqual(hayabusa.get("intake_id"), "pkg-1") + self.assertEqual(hayabusa.get("link_source"), "unit-test") + audit = storage.list_audit(int(created["id"])) + self.assertTrue(any(row.get("action") == "link_hayabusa" for row in audit)) + + +if __name__ == "__main__": + unittest.main() diff --git a/aw-server/hayabusa/aw-hayabusa.sh b/aw-server/hayabusa/aw-hayabusa.sh new file mode 100644 index 0000000..263d6dc --- /dev/null +++ b/aw-server/hayabusa/aw-hayabusa.sh @@ -0,0 +1,522 @@ +#!/usr/bin/env bash +set -euo pipefail + +HAYA_ROOT="${AW_HAYABUSA_ROOT:-/opt/hayabusa}" +HAYA_CURRENT="${HAYA_ROOT}/current" +HAYA_BIN="${HAYA_CURRENT}/hayabusa" +HAYA_RULES="${HAYA_CURRENT}/rules" +HAYA_CONFIG="${HAYA_CURRENT}/config" +HAYA_REPORTS_ROOT="${AW_HAYABUSA_REPORTS_ROOT:-${HAYA_ROOT}/reports}" +HAYA_STATE_ROOT="${AW_HAYABUSA_STATE_ROOT:-${HAYA_ROOT}/state}" +HAYA_INCOMING_DIR="${AW_HAYABUSA_INCOMING_DIR:-${HAYA_ROOT}/inbox/incoming}" +HAYA_STAGING_DIR="${AW_HAYABUSA_STAGING_DIR:-${HAYA_ROOT}/inbox/staging}" +HAYA_ARCHIVE_PACKAGES_DIR="${AW_HAYABUSA_ARCHIVE_PACKAGES_DIR:-${HAYA_ROOT}/archive/packages}" +HAYA_ARCHIVE_EXTRACTED_DIR="${AW_HAYABUSA_ARCHIVE_EXTRACTED_DIR:-${HAYA_ROOT}/archive/extracted}" +HAYA_LOGS_DIR="${AW_HAYABUSA_LOGS_DIR:-${HAYA_ROOT}/state/logs}" +LAST_REPORT_DIR="" + +usage() { + cat <<'EOF' +Usage: + aw-hayabusa doctor + aw-hayabusa inventory + aw-hayabusa accept --package [--host HOST] + aw-hayabusa process-inbox [--mode ] [--limit N] + aw-hayabusa profiles + aw-hayabusa version + aw-hayabusa --input [--host HOST] [--label LABEL] [--output-root DIR] [--threads N] + +Modes: + quick Fast CSV triage with HTML summary and logon summary + incident Rich JSONL timeline for incident review with HTML summary and logon summary + full Broad JSONL timeline with all rule families enabled, HTML summary and logon summary +EOF +} + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +sanitize() { + printf '%s' "$1" | tr ' /:@' '_' | tr -cd 'A-Za-z0-9._-' +} + +ensure_layout() { + [ -x "${HAYA_BIN}" ] || fail "Hayabusa binary not found at ${HAYA_BIN}" + [ -d "${HAYA_RULES}" ] || fail "Hayabusa rules directory not found at ${HAYA_RULES}" + [ -d "${HAYA_CONFIG}" ] || fail "Hayabusa config directory not found at ${HAYA_CONFIG}" + mkdir -p \ + "${HAYA_REPORTS_ROOT}" \ + "${HAYA_STATE_ROOT}" \ + "${HAYA_LOGS_DIR}" \ + "${HAYA_ROOT}/inbox" \ + "${HAYA_ROOT}/archive" \ + "${HAYA_INCOMING_DIR}" \ + "${HAYA_STAGING_DIR}" \ + "${HAYA_ARCHIVE_PACKAGES_DIR}" \ + "${HAYA_ARCHIVE_EXTRACTED_DIR}" +} + +run_logged() { + local log_file="$1" + shift + { + printf '[%s] CMD:' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf ' %q' "$@" + printf '\n' + } | tee -a "${log_file}" + "$@" 2>&1 | tee -a "${log_file}" + return "${PIPESTATUS[0]}" +} + +write_manifest() { + local manifest_path="$1" + local mode="$2" + local host="$3" + local input_path="$4" + local report_dir="$5" + local status="$6" + local output_format="$7" + cat >"${manifest_path}" <"${manifest_path}" < "${state_path}" +} + +run_mode() { + local mode="$1" + shift + + local input_path="" + local host="" + local label="" + local output_root="${HAYA_REPORTS_ROOT}" + local threads="" + + while [ "$#" -gt 0 ]; do + case "$1" in + --input|-i) + [ "$#" -ge 2 ] || fail "--input requires a value" + input_path="$2" + shift 2 + ;; + --host) + [ "$#" -ge 2 ] || fail "--host requires a value" + host="$2" + shift 2 + ;; + --label) + [ "$#" -ge 2 ] || fail "--label requires a value" + label="$2" + shift 2 + ;; + --output-root) + [ "$#" -ge 2 ] || fail "--output-root requires a value" + output_root="$2" + shift 2 + ;; + --threads) + [ "$#" -ge 2 ] || fail "--threads requires a value" + threads="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac + done + + [ -n "${input_path}" ] || fail "--input is required" + [ -e "${input_path}" ] || fail "Input path does not exist: ${input_path}" + + ensure_layout + mkdir -p "${output_root}" + + if [ -z "${host}" ]; then + host="$(basename "${input_path}")" + if [ "${host}" = "." ] || [ "${host}" = "/" ]; then + host="unknown" + fi + fi + host="$(sanitize "${host}")" + [ -n "${host}" ] || host="unknown" + + local label_suffix="" + if [ -n "${label}" ]; then + label_suffix="_$(sanitize "${label}")" + fi + local run_ts + run_ts="$(date -u +%Y%m%dT%H%M%SZ)" + local report_dir="${output_root}/${host}/${run_ts}_${mode}${label_suffix}" + local log_file="${report_dir}/run.log" + local manifest_file="${report_dir}/manifest.json" + local html_file="${report_dir}/summary.html" + local timeline_file="" + local output_format="" + local -a input_args=() + local -a common_args=("-w" "-q" "-C" "-r" "${HAYA_RULES}" "-c" "${HAYA_CONFIG}" "-O") + local -a mode_args=() + local -a command=() + local -a logon_command=() + + mkdir -p "${report_dir}" + + if [ -d "${input_path}" ]; then + input_args=("-d" "${input_path}") + else + input_args=("-f" "${input_path}") + fi + if [ -n "${threads}" ]; then + common_args+=("-t" "${threads}") + fi + + case "${mode}" in + quick) + timeline_file="${report_dir}/timeline.csv" + output_format="csv" + mode_args=("-E" "-P" "-m" "medium" "-o" "${timeline_file}" "-H" "${html_file}") + command=("${HAYA_BIN}" "csv-timeline" "${input_args[@]}" "${common_args[@]}" "${mode_args[@]}") + ;; + incident) + timeline_file="${report_dir}/timeline.jsonl" + output_format="jsonl" + mode_args=("-L" "-m" "low" "-o" "${timeline_file}" "-H" "${html_file}") + command=("${HAYA_BIN}" "json-timeline" "${input_args[@]}" "${common_args[@]}" "${mode_args[@]}") + ;; + full) + timeline_file="${report_dir}/timeline.jsonl" + output_format="jsonl" + mode_args=("-L" "-A" "-D" "-n" "-u" "-m" "informational" "-o" "${timeline_file}" "-H" "${html_file}") + command=("${HAYA_BIN}" "json-timeline" "${input_args[@]}" "${common_args[@]}" "${mode_args[@]}") + ;; + *) + fail "Unsupported mode: ${mode}" + ;; + esac + + logon_command=("${HAYA_BIN}" "logon-summary" "${input_args[@]}" "-q" "-C" "-c" "${HAYA_CONFIG}" "-O" "-o" "${report_dir}/logon-summary") + + { + echo "mode=${mode}" + echo "host=${host}" + echo "input=${input_path}" + echo "report_dir=${report_dir}" + echo "output_format=${output_format}" + } | tee -a "${log_file}" >/dev/null + + local status="ok" + if ! run_logged "${log_file}" "${command[@]}"; then + status="failed" + fi + if ! run_logged "${log_file}" "${logon_command[@]}"; then + status="failed" + fi + + write_manifest "${manifest_file}" "${mode}" "${host}" "${input_path}" "${report_dir}" "${status}" "${output_format}" + ln -sfn "${report_dir}" "${HAYA_STATE_ROOT}/latest-run" + ln -sfn "${report_dir}" "${HAYA_STATE_ROOT}/latest-${host}" + LAST_REPORT_DIR="${report_dir}" + + echo "Report directory: ${report_dir}" + if [ "${status}" != "ok" ]; then + echo "ERROR: Hayabusa run failed; see ${log_file}" >&2 + return 1 + fi +} + +inventory() { + ensure_layout + local incoming_count staged_count archived_pkg_count archived_extract_count + incoming_count=$(find "${HAYA_INCOMING_DIR}" -maxdepth 1 -type f -name '*.zip' | wc -l) + staged_count=$(find "${HAYA_STAGING_DIR}" -mindepth 1 -maxdepth 1 -type d | wc -l) + archived_pkg_count=$(find "${HAYA_ARCHIVE_PACKAGES_DIR}" -type f -name '*.zip' | wc -l) + archived_extract_count=$(find "${HAYA_ARCHIVE_EXTRACTED_DIR}" -mindepth 2 -maxdepth 2 -type d | wc -l) + echo "aw-hayabusa inventory" + echo "incoming_zip=${incoming_count}" + echo "staged_dirs=${staged_count}" + echo "archived_packages=${archived_pkg_count}" + echo "archived_payloads=${archived_extract_count}" + if [ -L "${HAYA_STATE_ROOT}/latest-run" ]; then + echo "latest_run=$(readlink -f "${HAYA_STATE_ROOT}/latest-run")" + fi +} + +accept_package() { + local package_path="" + local host="" + while [ "$#" -gt 0 ]; do + case "$1" in + --package) + [ "$#" -ge 2 ] || fail "--package requires a value" + package_path="$2" + shift 2 + ;; + --host) + [ "$#" -ge 2 ] || fail "--host requires a value" + host="$2" + shift 2 + ;; + *) + fail "Unknown argument: $1" + ;; + esac + done + [ -n "${package_path}" ] || fail "--package is required" + [ -f "${package_path}" ] || fail "Package not found: ${package_path}" + ensure_layout + + local ts base_name safe_base dest_path sha256 + ts="$(date -u +%Y%m%dT%H%M%SZ)" + base_name="$(basename "${package_path}")" + safe_base="$(sanitize "${base_name}")" + [ -n "${safe_base}" ] || safe_base="incoming.zip" + dest_path="${HAYA_INCOMING_DIR}/${ts}_${safe_base}" + cp -f "${package_path}" "${dest_path}" + sha256="$(sha256sum "${dest_path}" | awk '{print $1}')" + printf '%s %s\n' "${sha256}" "$(basename "${dest_path}")" > "${dest_path}.sha256" + if [ -n "${host}" ]; then + write_state_json "${dest_path}.host" "${host}" + fi + echo "Accepted package: ${dest_path}" +} + +find_manifest_path() { + local stage_dir="$1" + find "${stage_dir}" -type f -name 'manifest.json' | head -n 1 +} + +find_evtx_root() { + local stage_dir="$1" + if [ -d "${stage_dir}/evtx" ]; then + printf '%s' "${stage_dir}/evtx" + return 0 + fi + find "${stage_dir}" -type d -name evtx | head -n 1 +} + +process_one_package() { + local package_path="$1" + local mode="$2" + local forced_host="${3:-}" + + ensure_layout + + local package_name package_base intake_id stage_dir package_sha256 + package_name="$(basename "${package_path}")" + package_base="${package_name%.zip}" + intake_id="$(sanitize "${package_base}")" + stage_dir="${HAYA_STAGING_DIR}/${intake_id}" + mkdir -p "${stage_dir}" + + package_sha256="$(sha256sum "${package_path}" | awk '{print $1}')" + unzip -q -o "${package_path}" -d "${stage_dir}" + + local manifest_path host evtx_root archive_pkg_dir archive_pkg_path archive_extract_dir status report_dir + manifest_path="$(find_manifest_path "${stage_dir}")" + host="${forced_host}" + if [ -z "${host}" ] && [ -f "${package_path}.host" ]; then + host="$(cat "${package_path}.host" 2>/dev/null || true)" + fi + if [ -z "${host}" ] && [ -n "${manifest_path}" ]; then + host="$(detect_host_from_manifest "${manifest_path}")" + fi + if [ -z "${host}" ]; then + host="${package_base%%-*}" + fi + host="$(sanitize "${host}")" + [ -n "${host}" ] || host="unknown" + + archive_pkg_dir="${HAYA_ARCHIVE_PACKAGES_DIR}/${host}" + archive_extract_dir="${HAYA_ARCHIVE_EXTRACTED_DIR}/${host}/${intake_id}" + mkdir -p "${archive_pkg_dir}" "${archive_extract_dir}" + + evtx_root="$(find_evtx_root "${stage_dir}")" + status="ok" + report_dir="" + if [ -z "${evtx_root}" ] || ! find "${evtx_root}" -type f \( -iname '*.evtx' -o -iname '*.json' -o -iname '*.jsonl' \) | grep -q .; then + status="failed-no-evtx" + else + if run_mode "${mode}" --input "${evtx_root}" --host "${host}" --label "${package_base}"; then + report_dir="${LAST_REPORT_DIR}" + status="ok" + else + report_dir="${LAST_REPORT_DIR}" + status="failed-analysis" + fi + fi + + mv "${package_path}" "${archive_pkg_dir}/${intake_id}.zip" + [ -f "${package_path}.sha256" ] && mv "${package_path}.sha256" "${archive_pkg_dir}/${intake_id}.zip.sha256" + [ -f "${package_path}.host" ] && mv "${package_path}.host" "${archive_pkg_dir}/${intake_id}.host" + mv "${stage_dir}" "${archive_extract_dir}/payload" + write_package_manifest "${archive_extract_dir}/intake.json" "${archive_pkg_dir}/${intake_id}.zip" "${host}" "${intake_id}" "${package_sha256}" "${status}" "${archive_extract_dir}/payload" "${report_dir}" + write_state_json "${HAYA_STATE_ROOT}/latest-intake.json" "$(cat "${archive_extract_dir}/intake.json")" + echo "Processed package: ${archive_pkg_dir}/${intake_id}.zip" + echo "Archive payload: ${archive_extract_dir}/payload" + if [ -n "${report_dir}" ]; then + echo "Report directory: ${report_dir}" + fi + [ "${status}" = "ok" ] || fail "Package workflow ended with status=${status}; archived for inspection" +} + +process_inbox() { + local mode="incident" + local limit="0" + while [ "$#" -gt 0 ]; do + case "$1" in + --mode) + [ "$#" -ge 2 ] || fail "--mode requires a value" + mode="$2" + shift 2 + ;; + --limit) + [ "$#" -ge 2 ] || fail "--limit requires a value" + limit="$2" + shift 2 + ;; + *) + fail "Unknown argument: $1" + ;; + esac + done + case "${mode}" in + quick|incident|full) ;; + *) fail "Unsupported mode for process-inbox: ${mode}" ;; + esac + ensure_layout + + local count=0 pkg + while IFS= read -r pkg; do + process_one_package "${pkg}" "${mode}" + count=$((count + 1)) + if [ "${limit}" -gt 0 ] && [ "${count}" -ge "${limit}" ]; then + break + fi + done < <(find "${HAYA_INCOMING_DIR}" -maxdepth 1 -type f -name '*.zip' | sort) + [ "${count}" -gt 0 ] || echo "No packages in ${HAYA_INCOMING_DIR}" +} + +main() { + local subcommand="${1:-}" + case "${subcommand}" in + doctor) + ensure_layout + echo "aw-hayabusa doctor: OK" + echo "root=${HAYA_ROOT}" + echo "current=${HAYA_CURRENT}" + echo "binary=${HAYA_BIN}" + echo "rules=${HAYA_RULES}" + echo "config=${HAYA_CONFIG}" + echo "reports=${HAYA_REPORTS_ROOT}" + echo "state=${HAYA_STATE_ROOT}" + echo "incoming=${HAYA_INCOMING_DIR}" + echo "staging=${HAYA_STAGING_DIR}" + echo "archive_packages=${HAYA_ARCHIVE_PACKAGES_DIR}" + echo "archive_extracted=${HAYA_ARCHIVE_EXTRACTED_DIR}" + echo "logs=${HAYA_LOGS_DIR}" + ;; + inventory) + inventory + ;; + accept) + shift + accept_package "$@" + ;; + process-inbox) + shift + process_inbox "$@" + ;; + profiles) + ensure_layout + cd "${HAYA_CURRENT}" + exec "${HAYA_BIN}" list-profiles + ;; + version) + ensure_layout + cd "${HAYA_CURRENT}" + exec "${HAYA_BIN}" help + ;; + quick|incident|full) + shift + cd "${HAYA_CURRENT}" + run_mode "${subcommand}" "$@" + ;; + ""|-h|--help|help) + usage + ;; + *) + fail "Unknown subcommand: ${subcommand}" + ;; + esac +} + +main "$@" diff --git a/docs/hayabusa-artifact-workflow-2026-05-14.md b/docs/hayabusa-artifact-workflow-2026-05-14.md new file mode 100644 index 0000000..db08213 --- /dev/null +++ b/docs/hayabusa-artifact-workflow-2026-05-14.md @@ -0,0 +1,76 @@ +# Hayabusa Artifact Workflow 2026-05-14 + +This document records the server-side EVTX intake and archive workflow on `10.10.10.13`. + +## Directories + +- incoming packages: + - `/opt/hayabusa/inbox/incoming` +- transient staging: + - `/opt/hayabusa/inbox/staging` +- generated reports: + - `/opt/hayabusa/reports` +- archived raw packages: + - `/opt/hayabusa/archive/packages//` +- archived extracted payloads: + - `/opt/hayabusa/archive/extracted///payload/` +- state: + - `/opt/hayabusa/state/latest-intake.json` + - `/opt/hayabusa/state/latest-run` + - `/opt/hayabusa/state/latest-` + - `/opt/hayabusa/state/logs` + +## Operator flow + +1. Accept a package into server inbox: + +```bash +aw-hayabusa accept --package /path/to/HOST-YYYYMMDD-HHMMSS.zip --host HOST +``` + +2. Inspect queue: + +```bash +aw-hayabusa inventory +``` + +3. Process queued packages: + +```bash +aw-hayabusa process-inbox --mode incident +``` + +## Processing behavior + +- the package is extracted into staging; +- host is resolved from explicit `--host`, sidecar `.host`, embedded `manifest.json`, or package name fallback; +- if EVTX payload exists, Hayabusa analysis is launched through the existing runner modes; +- regardless of success, the package and extracted payload are moved into archive; +- `intake.json` records: + - package path + - host + - intake id + - sha256 + - status + - extracted payload path + - report directory + - processed timestamp + +## Failure semantics + +- malformed or empty packages are not lost; +- the workflow archives them with `status=failed-*`; +- the operator can inspect archived payloads without touching AW runtime storage. + +## Validation evidence + +- `aw-hayabusa inventory` shows queue and archive counts +- a synthetic package was accepted, archived, and recorded with: + - `status=failed-no-evtx` +- synthetic artifacts were removed after validation so production storage stayed clean + +## Boundaries + +- this phase does not yet move packages from Windows automatically +- this phase does not yet attach reports to AW-rus incidents or cases +- successful report generation from real EVTX remains a later validation phase diff --git a/docs/hayabusa-aw-rus-integration-2026-05-14.md b/docs/hayabusa-aw-rus-integration-2026-05-14.md new file mode 100644 index 0000000..d651177 --- /dev/null +++ b/docs/hayabusa-aw-rus-integration-2026-05-14.md @@ -0,0 +1,76 @@ +# Hayabusa AW-rus Integration 2026-05-14 + +This document defines the bounded integration between Hayabusa DFIR and the normal AW-rus operator path. + +## Purpose + +Hayabusa is used as DFIR enrichment after incidents, not as a new real-time detector. + +## When to use Hayabusa follow-up + +Recommended triggers: + +- high-severity DLP incidents that justify host-side forensic review; +- repeated incidents on the same host or user; +- suspicious print, USB, email, or document-export activity that needs Windows event corroboration; +- operator-driven escalation where case review needs EVTX-based timeline evidence. + +Not recommended: + +- routine low-signal incidents; +- replacing normal AW-rus health/runtime checks; +- pushing raw Sigma detections into AW buckets. + +## Operator path + +1. Export EVTX package on Windows with `export-evtx-for-hayabusa.ps1`. +2. Transfer the resulting zip package to `10.10.10.13`. +3. Run one of: + +```bash +aw-hayabusa accept --package /path/to/HOST-YYYYMMDD-HHMMSS.zip --host HOST +aw-hayabusa process-inbox --mode incident +``` + +or from Telegram bot: + +```text +/aw_dfir /path/to/HOST-YYYYMMDD-HHMMSS.zip HOST [CASE_ID] [MODE] +``` + +Default mode is `incident`. + +## What gets linked to a case + +Case management stores only bounded metadata: + +- `tool=hayabusa` +- `host` +- `mode` +- `status` +- `intake_id` +- `package_path` +- `sha256` +- `report_dir` +- `summary_html` +- `timeline_path` +- `manifest_path` +- `linked_at` +- `link_source` + +The raw Sigma output, full timelines, and extracted payloads stay under `/opt/hayabusa`, not inside AW buckets or case comments. + +## UI behavior + +Case Management shows a short `DFIR` field: + +- `Hayabusa ok · incident` +- `Hayabusa failed-* · incident` + +This is intentionally short; report paths remain operator-facing metadata, not primary UI content. + +## Boundaries + +- No raw forensic output is copied into normal AW runtime buckets. +- No automatic case creation from Hayabusa findings. +- Hayabusa remains an enrichment layer around incidents and investigations. diff --git a/docs/hayabusa-operator-ib-guide-2026-05-14.md b/docs/hayabusa-operator-ib-guide-2026-05-14.md new file mode 100644 index 0000000..61cdfd5 --- /dev/null +++ b/docs/hayabusa-operator-ib-guide-2026-05-14.md @@ -0,0 +1,163 @@ +# Hayabusa Operator and IB Guide 2026-05-14 + +This document explains the role of Hayabusa inside `AW-rus` for operators and IB. + +## What Hayabusa adds + +Hayabusa adds a bounded DFIR layer for Windows Event Log analysis: + +- EVTX-based timeline review +- Sigma-based detection enrichment +- logon and activity context around an already interesting host or incident +- forensic artifacts that can be attached to case review + +It is useful when `AW-rus` or DLP already surfaced something worth investigating further. + +## What Hayabusa does not replace + +Hayabusa is not: + +- a replacement for normal `AW-rus` runtime monitoring +- a replacement for DLP policy enforcement +- a real-time SIEM +- a reason to copy raw Sigma output into AW buckets or case comments + +The normal operational path remains: + +- `AW-rus` for activity/runtime visibility +- DLP collectors and policy engine for signal generation +- case management for operator workflow +- Hayabusa for bounded forensic enrichment + +## When operators should run it + +Recommended cases: + +- high-severity DLP incidents +- repeated suspicious incidents on one host or user +- print, USB, file export, or email activity that needs Windows event corroboration +- investigation requests from IB after an incident is already known + +Do not run it for every minor signal. It is meant for escalation and investigation, not daily noise. + +## Operator workflow + +1. Export EVTX package on Windows: + +```powershell +powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-evtx-for-hayabusa.ps1 +``` + +2. Transfer the resulting zip package to `10.10.10.13`. + +3. Run server-side processing: + +```bash +aw-hayabusa accept --package /path/to/HOST-YYYYMMDD-HHMMSS.zip --host HOST +aw-hayabusa process-inbox --mode incident +``` + +Or use the Telegram operator path: + +```text +/aw_dfir /path/to/HOST-YYYYMMDD-HHMMSS.zip HOST [CASE_ID] [MODE] +``` + +4. If a case already exists, link only bounded metadata to the case. + +## Where artifacts live + +Windows export staging: + +- `C:\ProgramData\AWatch-rus\forensics\evtx-exports` + +Server-side intake and reports: + +- incoming packages: + - `/opt/hayabusa/inbox/incoming` +- transient staging: + - `/opt/hayabusa/inbox/staging` +- archived raw packages: + - `/opt/hayabusa/archive/packages//` +- archived extracted payloads: + - `/opt/hayabusa/archive/extracted///payload/` +- reports: + - `/opt/hayabusa/reports//_[_LABEL]/` +- run state and logs: + - `/opt/hayabusa/state` + +## What is stored in AW-rus + +Only bounded metadata is attached to a case: + +- tool +- host +- mode +- status +- intake id +- package path +- sha256 +- report directory +- summary path +- timeline path +- manifest path +- linked timestamp +- link source + +Raw forensic output stays under `/opt/hayabusa`. + +## Retention and storage notes + +Windows-side export retention: + +- controlled by `aw_windows_evtx_retention_days` +- default: `14` days + +Windows-side export channels: + +- controlled by `aw_windows_evtx_channels` +- default set: + - `Security` + - `System` + - `Application` + - `Microsoft-Windows-PowerShell/Operational` + - `Microsoft-Windows-TerminalServices-LocalSessionManager/Operational` + - `Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational` + +Server-side storage: + +- kept outside standard AW buckets +- kept outside normal DLP screenshot artifacts +- intended for forensic review, not for routine dashboarding + +## IB view + +From an IB perspective, Hayabusa in this project is: + +- a post-incident enrichment layer +- useful for Windows event corroboration and timeline reconstruction +- intentionally separated from the main activity-monitoring data plane + +This design keeps the main operator UI readable while preserving forensic detail when needed. + +## Limits and false expectations to avoid + +- Sigma detections depend on the quality and completeness of Windows logging. +- Missing or weak audit policy reduces value immediately. +- No EVTX means no meaningful Hayabusa result. +- A successful Hayabusa run does not prove malicious activity by itself. +- A clean Hayabusa run does not prove the absence of suspicious behavior. +- This contour is deliberately not an always-on detector and not a SIEM replacement. + +## Canonical companion docs + +- source and packaging: + - `docs/hayabusa-source-packaging-2026-05-14.md` +- server runner: + - `docs/hayabusa-server-runner-2026-05-14.md` +- artifact workflow: + - `docs/hayabusa-artifact-workflow-2026-05-14.md` +- AW-rus integration: + - `docs/hayabusa-aw-rus-integration-2026-05-14.md` +- Windows EVTX export: + - `docs/windows-hayabusa-evtx-export.md` diff --git a/docs/hayabusa-server-runner-2026-05-14.md b/docs/hayabusa-server-runner-2026-05-14.md new file mode 100644 index 0000000..9858767 --- /dev/null +++ b/docs/hayabusa-server-runner-2026-05-14.md @@ -0,0 +1,124 @@ +# Hayabusa Server-Side Runner 2026-05-14 + +This document records the production runner model for Hayabusa on `10.10.10.13`. + +## Install layout + +- root: `/opt/hayabusa` +- pinned release: `/opt/hayabusa/releases/v3.9.0` +- active symlink: `/opt/hayabusa/current` +- operator entrypoint: `/usr/local/bin/aw-hayabusa` + +## Runtime directories + +- inbox: `/opt/hayabusa/inbox` +- archive: `/opt/hayabusa/archive` +- reports: `/opt/hayabusa/reports` +- state: `/opt/hayabusa/state` + +## Operator entrypoint + +Supported helper subcommands: + +- `aw-hayabusa doctor` +- `aw-hayabusa inventory` +- `aw-hayabusa accept --package [--host HOST]` +- `aw-hayabusa process-inbox [--mode incident] [--limit N]` +- `aw-hayabusa profiles` +- `aw-hayabusa version` + +Supported analysis modes: + +- `aw-hayabusa quick --input [--host HOST]` +- `aw-hayabusa incident --input [--host HOST]` +- `aw-hayabusa full --input [--host HOST]` + +## Mode intent + +- `quick` + - fast CSV timeline + - HTML summary + - logon summary + - intended for first-pass triage + +- `incident` + - JSONL timeline + - HTML summary + - logon summary + - intended for normal incident review + +- `full` + - JSONL timeline + - deprecated/noisy/unsupported rules enabled + - HTML summary + - logon summary + - intended for deeper DFIR review + +## Output naming + +Reports are stored under: + +- `/opt/hayabusa/reports//_[_LABEL]/` + +Typical contents: + +- `timeline.csv` or `timeline.jsonl` +- `summary.html` +- `logon-summary-*.csv` +- `run.log` +- `manifest.json` + +Latest-run symlinks: + +- `/opt/hayabusa/state/latest-run` +- `/opt/hayabusa/state/latest-` + +## Intake and archive workflow + +Incoming packages: + +- `/opt/hayabusa/inbox/incoming/*.zip` + +Transient staging: + +- `/opt/hayabusa/inbox/staging//` + +Archived raw packages: + +- `/opt/hayabusa/archive/packages//.zip` + +Archived extracted payloads: + +- `/opt/hayabusa/archive/extracted///payload/` +- intake metadata: + - `/opt/hayabusa/archive/extracted///intake.json` + +State/log helpers: + +- `/opt/hayabusa/state/latest-intake.json` +- `/opt/hayabusa/state/logs/` + +## Minimal operator flow + +1. Drop or copy an export package: + - `aw-hayabusa accept --package /path/to/HOST-YYYYMMDD-HHMMSS.zip` +2. Check queue: + - `aw-hayabusa inventory` +3. Process packages: + - `aw-hayabusa process-inbox --mode incident` + +## Validation baseline + +Minimum server-side validation: + +```bash +aw-hayabusa doctor +aw-hayabusa profiles +aw-hayabusa inventory +``` + +## Boundaries + +- Hayabusa is not deployed as a daemon. +- No AW bucket ingestion happens in this phase. +- EVTX intake orchestration remains a later phase. diff --git a/docs/hayabusa-source-packaging-2026-05-14.md b/docs/hayabusa-source-packaging-2026-05-14.md new file mode 100644 index 0000000..f724128 --- /dev/null +++ b/docs/hayabusa-source-packaging-2026-05-14.md @@ -0,0 +1,59 @@ +# Hayabusa Source and Packaging Decision 2026-05-14 + +## Decision + +- upstream source of truth: `Yamato-Security/hayabusa` +- fork policy: do not use a fork unless a concrete required patch exists and is documented +- runtime role in this project: `DFIR enrichment` + +## Pinned release + +- release tag: `v3.9.0` +- server target platform: `x86_64`, `debian 13` +- selected asset: + - `hayabusa-3.9.0-lin-x64-gnu.zip` +- selected asset URL: + - `https://github.com/Yamato-Security/hayabusa/releases/download/v3.9.0/hayabusa-3.9.0-lin-x64-gnu.zip` + +## Packaging model + +- analysis host: `10.10.10.13` +- install root: `/opt/hayabusa` +- versioned release root: `/opt/hayabusa/releases/v3.9.0` +- active symlink target: + - `/opt/hayabusa/current` +- suggested executable path: + - `/opt/hayabusa/current/hayabusa` +- suggested wrapper path: + - `/usr/local/bin/aw-hayabusa` + +## Artifact boundaries + +- raw incoming EVTX: + - `/opt/hayabusa/inbox` +- processed EVTX archive: + - `/opt/hayabusa/archive` +- generated reports: + - `/opt/hayabusa/reports` +- run logs / metadata: + - `/opt/hayabusa/state` + +These paths are intentionally outside normal ActivityWatch buckets and outside ordinary DLP artifact roots. + +## Integrity note + +The official release currently does not publish a separate checksum asset in the GitHub release asset list. + +Therefore the deployment model should: + +1. download the pinned asset URL; +2. calculate `sha256` locally during automation; +3. store the computed value in deployment logs or a local manifest; +4. fail deployment if the downloaded asset name or pinned tag does not match expectations. + +## Why this model + +- no dependency on an unreviewed fork; +- reproducible server-side installation; +- no attempt to run Hayabusa as a real-time daemon; +- clean separation between `AW-rus` runtime data and forensic artifacts. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index db1f53e..72ac6bb 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -11,6 +11,8 @@ - [ИБ-профиль DLP](../dlp-security-functional-spec-ru.md) - подробное описание реализованного DLP/monitoring-контура для службы ИБ - [Runtime status: DLP chain](../dlp-runtime-chain-status-2026-05-13.md) - фактический live-статус policy/cases/integrations/compliance - [Runtime status: Content analysis](../dlp-content-analysis-runtime-status-2026-05-13.md) - фактический live-статус dictionary/regex/OCR/IOC +- [Hayabusa AW-rus integration](../hayabusa-aw-rus-integration-2026-05-14.md) - bounded DFIR enrichment path для incidents/cases/operator flow +- [Hayabusa operator and IB guide](../hayabusa-operator-ib-guide-2026-05-14.md) - когда запускать forensic path, где лежат артефакты и какие у него границы ### Компоненты - [DLP Endpoint Monitoring](DLP-Endpoint-Monitoring) - мониторинг clipboard, печати, USB diff --git a/docs/windows-hayabusa-evtx-export.md b/docs/windows-hayabusa-evtx-export.md new file mode 100644 index 0000000..f4258ed --- /dev/null +++ b/docs/windows-hayabusa-evtx-export.md @@ -0,0 +1,78 @@ +# Windows EVTX Export for Hayabusa + +This document defines the Windows-side export path for Hayabusa DFIR enrichment. + +## Purpose + +Windows hosts do not analyze EVTX locally for this contour. + +They export selected event logs into a bounded forensic staging area, and the server-side Hayabusa workflow on `10.10.10.13` analyzes those artifacts later. + +## Export script + +- script: `windows/export-evtx-for-hayabusa.ps1` +- deployed path on Windows host: + - `\export-evtx-for-hayabusa.ps1` + +Default config path: + +- `C:\ProgramData\AWatch-rus\deployment-config.json` + +## Default export root + +- `\forensics\evtx-exports` +- Ansible override variable: `aw_windows_forensics_root` +- retention override variable: `aw_windows_evtx_retention_days` +- channel override variable: `aw_windows_evtx_channels` + +Example: + +- `C:\ProgramData\AWatch-rus\forensics\evtx-exports` + +Each run creates: + +- `\-\evtx\*.evtx` +- `\-\manifest.json` +- optional zip: + - `\-.zip` + +## Default channel set + +- `Security` +- `System` +- `Application` +- `Microsoft-Windows-PowerShell/Operational` +- `Microsoft-Windows-TerminalServices-LocalSessionManager/Operational` +- `Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational` + +Notes: + +- `Sysmon` is intentionally not assumed by default. +- If `Sysmon` exists in the environment, it should be added later as an explicit extension. +- the channel list is now carried through deployment config and validation, not left as an implicit script default. + +## Retention + +- default retention: `14` days +- cleanup is local to the forensic export root +- old export directories and zip packages are removed after the retention cutoff +- retention is now exposed as `aw_windows_evtx_retention_days` in Ansible vars + +## Example run + +```powershell +powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-evtx-for-hayabusa.ps1 +``` + +Example with custom window: + +```powershell +powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-evtx-for-hayabusa.ps1 -DaysBack 1 +``` + +## Boundaries + +- output stays outside standard AW buckets +- output stays outside normal DLP screenshot artifacts +- this phase only defines and validates Windows export +- transfer to `10.10.10.13` and Hayabusa execution belong to later phases diff --git a/proxmox/tsj_guardian_bot.py b/proxmox/tsj_guardian_bot.py index 3ade17f..58ba82c 100644 --- a/proxmox/tsj_guardian_bot.py +++ b/proxmox/tsj_guardian_bot.py @@ -319,6 +319,7 @@ class TSJGuardianBot: BTN_PFSENSE_CONFIRM = "Подтвердить pfSense: шаг 1" BTN_PFSENSE_CANCEL = "Отменить pfSense изменение" BTN_AW_DLP_CHECK = "Проверка AW-Rus + DLP" + BTN_AW_DFIR = "Hayabusa DFIR" BTN_AI_CHAT_ALIASES = ("AI чат", "Чат с поддержкой", "Техподдержка", "Тех поддержка") BTN_OVPN_CERTS_ALIASES = ("OpenVPN certs", "OpenVPN cert", "OpenVPN серты", "OpenVPN сертификат") PFSENSE_ENV_PATH = "/home/codex/infra-admin/vendor/pfsense-mcp-server/.env.readonly" @@ -361,6 +362,12 @@ class TSJGuardianBot: "AW_RUS_DLP_HEAL_CMD", "", ).strip() + self.aw_rus_case_api_base = os.getenv("AW_RUS_CASE_API_BASE", "http://10.10.10.13:5602").strip() + self.aw_rus_hayabusa_enabled = env_bool("AW_RUS_HAYABUSA_ENABLED", True) + self.aw_rus_hayabusa_ssh_cmd = os.getenv( + "AW_RUS_HAYABUSA_SSH_CMD", + "sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13", + ).strip() self.aw_rus_host = os.getenv("AW_RUS_HOST", "SHARKON2025").strip() self.aw_rus_primary_user = os.getenv("AW_RUS_PRIMARY_USER", "USER1").strip() self.aw_rus_stale_sec = max(60, env_int("AW_RUS_STALE_SEC", 900)) @@ -491,6 +498,7 @@ class TSJGuardianBot: "keyboard": [ [self.BTN_STATUS, self.BTN_CHECK, self.BTN_HEAL], [self.BTN_AW_DLP_CHECK], + [self.BTN_AW_DFIR], [self.BTN_ACK, self.BTN_RESOLVE], [self.BTN_AI, self.BTN_FALLBACK], [self.BTN_AI_CHAT], @@ -1988,6 +1996,11 @@ class TSJGuardianBot: "- Проверяет AW-Rus и DLP по свежести bucket-данных и сегодняшнему worktime.\n" "- Формирует операторский итог OK/DEGRADED прямо в чате.\n" "\n" + f"{self.BTN_AW_DFIR}\n" + "- Показывает, как запустить bounded DFIR-путь через Hayabusa.\n" + "- Рабочий формат: `/aw_dfir /path/to/package.zip HOST [CASE_ID] [MODE]`.\n" + "- В кейс пишется только metadata/linkage, без raw Sigma output.\n" + "\n" f"{self.BTN_HEAL}\n" "- Пробует автоматическое лечение проблем (рестарт нужных сервисов).\n" "- Для критичного заполнения ФС авто-очистка не выполняется, нужен разбор причины.\n" @@ -2088,7 +2101,7 @@ class TSJGuardianBot: "1) Нажмите кнопку создания/восстановления или используйте `/proxmox_snapshot TARGET`.\n" "2) Для восстановления после выбора узла отправьте `/proxmox_restore_apply CODE`.\n" "\n" - "Резервные slash-команды: /status /check /aw_dlp_check /heal /ack /resolve /run ... /openvpn_certs [filter] /openvpn_expiring /openvpn_config USER /openvpn_config_confirm /openvpn_config_cancel /openvpn_config_apply CODE /pfsense_confirm /pfsense_cancel /pfsense_apply CODE /proxmox_snapshot TARGET /proxmox_restore TARGET /proxmox_restore_apply CODE /proxmox_restore_cancel /proxmox_selection_cancel" + "Резервные slash-команды: /status /check /aw_dlp_check /aw_dfir PACKAGE HOST [CASE_ID] [MODE] /heal /ack /resolve /run ... /openvpn_certs [filter] /openvpn_expiring /openvpn_config USER /openvpn_config_confirm /openvpn_config_cancel /openvpn_config_apply CODE /pfsense_confirm /pfsense_cancel /pfsense_apply CODE /proxmox_snapshot TARGET /proxmox_restore TARGET /proxmox_restore_apply CODE /proxmox_restore_cancel /proxmox_selection_cancel" ) def _cmd_status(self) -> str: @@ -2205,12 +2218,59 @@ class TSJGuardianBot: except Exception as exc: return None, f"error:{exc}" + def load_worktime_activity() -> Tuple[Optional[Dict[str, Dict[str, Optional[int]]]], Optional[str]]: + try: + r = requests.get(f"{base}/buckets", timeout=20) + r.raise_for_status() + buckets = r.json() + except Exception as exc: + return None, f"error:{exc}" + + prefix = "aw-worktime-sessions_" + activity: Dict[str, Dict[str, Optional[int]]] = {} + for bucket_id in sorted(key for key in buckets if key.startswith(prefix)): + bucket_host = bucket_id[len(prefix):] + latest_ts = None + latest_active = False + try: + r = requests.get(f"{base}/buckets/{bucket_id}/events?limit=20", timeout=20) + r.raise_for_status() + events = r.json() + except Exception as exc: + activity[bucket_host] = { + "active": False, + "age_seconds": None, + "error": str(exc), + } + continue + + if isinstance(events, list): + for event in events: + raw_ts = event.get("timestamp") + if not raw_ts: + continue + try: + event_ts = datetime.fromisoformat(raw_ts.replace("Z", "+00:00")).astimezone(timezone.utc) + except Exception: + continue + if latest_ts is None or event_ts > latest_ts: + latest_ts = event_ts + latest_active = bool((event.get("data") or {}).get("active")) + + age_seconds = None + if latest_ts is not None: + age_seconds = max(0, int((now - latest_ts).total_seconds())) + activity[bucket_host] = { + "active": bool(latest_ts and latest_active and (age_seconds or 0) <= self.aw_rus_stale_sec), + "age_seconds": age_seconds, + } + + return activity, None + checks = [ (f"aw-watcher-window_{host}", "watcher-window"), (f"aw-watcher-afk_{host}", "watcher-afk"), (f"aw-dlp-endpoint-signals_{host}", "dlp-endpoint"), - (f"aw-file-operations_{host}", "dlp-fileops-host"), - ("aw-file-operations_10.10.10.13", "dlp-fileops-server"), ] lines = ["Проверка AW-Rus + DLP:"] @@ -2227,6 +2287,46 @@ class TSJGuardianBot: else: lines.append(f"- {label}: OK age={age}s end={tail}") + worktime_activity, worktime_error = load_worktime_activity() + fileops_checks = [ + (f"aw-file-operations_{host}", "dlp-fileops-host", host), + ("aw-file-operations_10.10.10.13", "dlp-fileops-server", "10.10.10.13"), + ] + for bucket_id, label, bucket_host in fileops_checks: + if worktime_activity is None: + age, tail = bucket_age(bucket_id) + if age is None: + lines.append(f"- {label}: FAIL (worktime-map {worktime_error}; bucket {tail})") + failures.append(label) + continue + if age > self.aw_rus_stale_sec: + lines.append(f"- {label}: STALE age={age}s end={tail} (worktime-map unavailable)") + failures.append(label) + else: + lines.append(f"- {label}: OK age={age}s end={tail} (worktime-map unavailable)") + continue + + host_meta = worktime_activity.get(bucket_host) + if host_meta is None: + lines.append(f"- {label}: OK unmanaged host={bucket_host}") + continue + if not host_meta.get("active"): + age_seconds = host_meta.get("age_seconds") + age_tail = f" age={age_seconds}s" if age_seconds is not None else "" + lines.append(f"- {label}: OK inactive host={bucket_host}{age_tail}") + continue + + age, tail = bucket_age(bucket_id) + if age is None: + lines.append(f"- {label}: FAIL (active host bucket missing or unreadable: {tail})") + failures.append(label) + continue + if age > self.aw_rus_stale_sec: + lines.append(f"- {label}: STALE age={age}s end={tail}") + failures.append(label) + else: + lines.append(f"- {label}: OK age={age}s end={tail}") + try: r = requests.get(f"{worktime_base}/reports/worktime/today?format=csv", timeout=20) r.raise_for_status() @@ -2411,6 +2511,111 @@ class TSJGuardianBot: out.extend(after_lines) return "\n".join(out) + def _aw_rus_hayabusa_usage_text(self) -> str: + return ( + "Hayabusa DFIR:\n" + "- bounded forensic path для EVTX package -> Hayabusa -> case linkage.\n" + "- рабочий запуск: /aw_dfir /path/to/package.zip HOST [CASE_ID] [MODE]\n" + "- MODE по умолчанию: incident\n" + "- в кейс пишутся только metadata и ссылки на артефакты, без raw Sigma output." + ) + + @staticmethod + def _extract_marked_block(text: str, begin_marker: str, end_marker: str) -> str: + start = text.find(begin_marker) + end = text.find(end_marker) + if start < 0 or end < 0 or end <= start: + return "" + return text[start + len(begin_marker):end].strip() + + def _aw_rus_case_link_hayabusa(self, case_id: int, payload: Dict) -> None: + response = requests.post( + f"{self.aw_rus_case_api_base.rstrip('/')}/api/0/dlp/cases/{int(case_id)}/forensics/hayabusa", + json=payload, + timeout=20, + ) + response.raise_for_status() + + def _aw_rus_hayabusa_run( + self, + package_path: str, + host: str | None = None, + case_id: int | None = None, + mode: str = "incident", + ) -> str: + if not self.aw_rus_hayabusa_enabled: + return "Hayabusa DFIR trigger отключён." + package_path = (package_path or "").strip() + host = (host or "").strip() or None + mode = (mode or "incident").strip().lower() or "incident" + if not package_path: + return self._aw_rus_hayabusa_usage_text() + if mode not in {"quick", "incident", "full"}: + return f"Неверный mode: {mode}. Допустимо: quick, incident, full." + + accept_cmd = f"sudo /usr/local/bin/aw-hayabusa accept --package {shlex.quote(package_path)}" + if host: + accept_cmd += f" --host {shlex.quote(host)}" + remote_script = ( + "set -eu\n" + f"{accept_cmd}\n" + "process_rc=0\n" + f"sudo /usr/local/bin/aw-hayabusa process-inbox --mode {shlex.quote(mode)} --limit 1 || process_rc=$?\n" + "echo '__AW_HAYA_INTAKE_JSON_BEGIN__'\n" + "sudo cat /opt/hayabusa/state/latest-intake.json\n" + "echo '__AW_HAYA_INTAKE_JSON_END__'\n" + "exit \"$process_rc\"\n" + ) + cmd = f"{self.aw_rus_hayabusa_ssh_cmd} bash -lc {shlex.quote(remote_script)}" + rc, out = self._run_shell(cmd, timeout_sec=900) + json_block = self._extract_marked_block(out, "__AW_HAYA_INTAKE_JSON_BEGIN__", "__AW_HAYA_INTAKE_JSON_END__") + if not json_block: + tail = "\n".join((out or "").splitlines()[-20:]) + return f"Hayabusa DFIR: не удалось получить intake metadata.\nrc={rc}\n{tail}" + try: + intake = json.loads(json_block) + except Exception as exc: + tail = "\n".join((out or "").splitlines()[-20:]) + return f"Hayabusa DFIR: intake metadata повреждены ({exc}).\nrc={rc}\n{tail}" + + report_dir = intake.get("report_dir") or "" + report_dir = str(report_dir) + status = str(intake.get("status") or ("ok" if rc == 0 else f"rc-{rc}")) + link_payload = { + "host": str(intake.get("host") or host or self.aw_rus_host), + "mode": mode, + "status": status, + "intake_id": intake.get("intake_id"), + "package_path": intake.get("package_path"), + "sha256": intake.get("sha256"), + "report_dir": report_dir or None, + "summary_html": f"{report_dir}/summary.html" if report_dir else None, + "timeline_path": f"{report_dir}/timeline.jsonl" if report_dir else None, + "manifest_path": f"{report_dir}/manifest.json" if report_dir else None, + "link_source": "telegram-bot", + } + linked_line = "- case linkage: skipped" + if case_id is not None: + self._aw_rus_case_link_hayabusa(case_id, link_payload) + linked_line = f"- case linkage: OK case_id={case_id}" + + verdict = "OK" if rc == 0 else f"DEGRADED rc={rc}" + lines = [ + f"Hayabusa DFIR: {verdict}", + f"- host: {link_payload['host']}", + f"- mode: {mode}", + f"- status: {status}", + f"- intake_id: {intake.get('intake_id') or '-'}", + f"- package: {intake.get('package_path') or package_path}", + f"- report_dir: {report_dir or '-'}", + linked_line, + ] + if rc != 0: + tail = "\n".join((out or "").splitlines()[-20:]) + lines.append("- runner tail:") + lines.append(tail) + return "\n".join(lines) + def _pfsense_security_status_lines(self) -> str: cmd = "/usr/bin/python3 /home/codex/infra-admin/scripts/pfsense_security_status.py" try: @@ -2519,6 +2724,29 @@ class TSJGuardianBot: if text.startswith("/aw_dlp_check") or text == self.BTN_AW_DLP_CHECK: self._send_text(chat_id, self._run_operator_action("aw-dlp-check")) return + if text == self.BTN_AW_DFIR: + self._send_text(chat_id, self._aw_rus_hayabusa_usage_text()) + return + if text.strip() == "/aw_dfir": + self._send_text(chat_id, self._aw_rus_hayabusa_usage_text()) + return + if text.startswith("/aw_dfir "): + parts = text.split() + if len(parts) < 3: + self._send_text(chat_id, self._aw_rus_hayabusa_usage_text()) + return + package_path = parts[1] + host = parts[2] + case_id = None + mode = "incident" + if len(parts) >= 4 and parts[3].isdigit(): + case_id = int(parts[3]) + if len(parts) >= 5: + mode = parts[4] + elif len(parts) >= 4: + mode = parts[3] + self._send_text(chat_id, self._aw_rus_hayabusa_run(package_path=package_path, host=host, case_id=case_id, mode=mode)) + return if text.startswith("/heal") or text == self.BTN_HEAL: self._send_text(chat_id, self._run_operator_action("heal")) return diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index ac620b9..b5ea1cd 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -399,6 +399,7 @@ function Copy-ActivityWatchCollectorAssets { [string]$FileCollectorScriptSource, [Parameter(Mandatory = $true)] [string]$SessionCollectorScriptSource, + [string]$EvtxExportScriptSource, [string]$EmailCollectorScriptSource, [Parameter(Mandatory = $true)] [string]$ExampleRulesSource, @@ -417,6 +418,7 @@ function Copy-ActivityWatchCollectorAssets { $policyClientTarget = Join-Path $StateRoot 'dlp-policy-client.ps1' $fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1' $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' + $evtxExportTarget = Join-Path $StateRoot 'export-evtx-for-hayabusa.ps1' $emailCollectorTarget = Join-Path $StateRoot 'email-outbound-collector.ps1' $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' @@ -430,6 +432,9 @@ function Copy-ActivityWatchCollectorAssets { } Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force + if ($EvtxExportScriptSource -and (Test-Path -LiteralPath $EvtxExportScriptSource)) { + Copy-Item -LiteralPath $EvtxExportScriptSource -Destination $evtxExportTarget -Force + } if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) { Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force } @@ -458,6 +463,7 @@ function Copy-ActivityWatchCollectorAssets { PolicyClientScript = $policyClientTarget FileCollectorScript = $fileCollectorTarget SessionCollectorScript = $sessionCollectorTarget + EvtxExportScript = $evtxExportTarget EmailCollectorScript = $emailCollectorTarget ExampleRules = $exampleRulesTarget ActiveRules = $rulesTarget @@ -489,6 +495,7 @@ function New-ActivityWatchDeploymentConfig { [string]$FileCollectorScript, [Parameter(Mandatory = $true)] [string]$SessionCollectorScript, + [string]$EvtxExportScript, [string]$EmailCollectorScript, [Parameter(Mandatory = $true)] [string]$RulesPath, @@ -507,6 +514,9 @@ function New-ActivityWatchDeploymentConfig { [bool]$IncidentCaptureEnabled = $true, [bool]$IncidentScreenshotEnabled = $true, [string]$IncidentArtifactsRoot, + [string]$EvtxExportRoot, + [int]$EvtxRetentionDays = 14, + [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, [Parameter(Mandatory = $true)] [string]$LaunchScriptPath, @@ -529,6 +539,19 @@ function New-ActivityWatchDeploymentConfig { ) $effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' } + $effectiveEvtxExportRoot = if ($EvtxExportRoot) { $EvtxExportRoot } else { Join-Path $StateRoot 'forensics\evtx-exports' } + $effectiveEvtxChannels = if ($EvtxChannels -and $EvtxChannels.Count -gt 0) { + @($EvtxChannels) + } else { + @( + 'Security', + 'System', + 'Application', + 'Microsoft-Windows-PowerShell/Operational', + 'Microsoft-Windows-TerminalServices-LocalSessionManager/Operational', + 'Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational' + ) + } $effectivePolicyEngineHost = if ([string]::IsNullOrWhiteSpace($PolicyEngineHost)) { $ServerHost } else { $PolicyEngineHost } $effectivePolicyCachePath = if ([string]::IsNullOrWhiteSpace($PolicyCachePath)) { Join-Path $StateRoot 'dlp-policy-cache.json' } else { $PolicyCachePath } @@ -551,6 +574,7 @@ function New-ActivityWatchDeploymentConfig { emailCollectorScript = $EmailCollectorScript fileCollectorScript = $FileCollectorScript sessionCollectorScript = $SessionCollectorScript + evtxExportScript = $EvtxExportScript rulesPath = $RulesPath policyPath = $PolicyPath launchScript = $LaunchScriptPath @@ -574,6 +598,11 @@ function New-ActivityWatchDeploymentConfig { screenshotEnabled = $IncidentScreenshotEnabled artifactsRoot = $effectiveIncidentArtifactsRoot } + forensics = [pscustomobject]@{ + evtxExportRoot = $effectiveEvtxExportRoot + retentionDays = $EvtxRetentionDays + evtxChannels = @($effectiveEvtxChannels) + } sessionEvents = [pscustomobject]@{ logonEnabled = $LogonMarkerEnabled bucketPrefix = 'aw-session-events' diff --git a/windows/deploy-domain-users.ps1 b/windows/deploy-domain-users.ps1 index d27bfef..0f9cb1f 100755 --- a/windows/deploy-domain-users.ps1 +++ b/windows/deploy-domain-users.ps1 @@ -23,6 +23,9 @@ param( [bool]$IncidentCaptureEnabled = $true, [bool]$IncidentScreenshotEnabled = $true, [string]$IncidentArtifactsRoot, + [string]$EvtxExportRoot, + [int]$EvtxRetentionDays = 14, + [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, [string]$AwHostname, [string]$CustomRulesPath, @@ -60,6 +63,7 @@ $policyClientSource = Join-Path $PSScriptRoot 'dlp-policy-client.ps1' $emailCollectorSource = Join-Path $PSScriptRoot 'email-outbound-collector.ps1' $fileCollectorSource = Join-Path $PSScriptRoot 'file-operations-collector.ps1' $sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1' +$evtxExportScriptSource = Join-Path $PSScriptRoot 'export-evtx-for-hayabusa.ps1' $exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json' $examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json' @@ -78,6 +82,7 @@ $assetResult = Copy-ActivityWatchCollectorAssets ` -EmailCollectorScriptSource $emailCollectorSource ` -FileCollectorScriptSource $fileCollectorSource ` -SessionCollectorScriptSource $sessionCollectorSource ` + -EvtxExportScriptSource $evtxExportScriptSource ` -ExampleRulesSource $exampleRulesSource ` -ExamplePolicySource $examplePolicySource ` -StateRoot $StateRoot ` @@ -101,6 +106,7 @@ $config = New-ActivityWatchDeploymentConfig ` -EmailCollectorScript $assetResult.EmailCollectorScript ` -FileCollectorScript $assetResult.FileCollectorScript ` -SessionCollectorScript $assetResult.SessionCollectorScript ` + -EvtxExportScript $assetResult.EvtxExportScript ` -RulesPath $assetResult.ActiveRules ` -PolicyPath $assetResult.ActivePolicy ` -PollSeconds $PollSeconds ` @@ -113,6 +119,9 @@ $config = New-ActivityWatchDeploymentConfig ` -IncidentCaptureEnabled $IncidentCaptureEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentArtifactsRoot $IncidentArtifactsRoot ` + -EvtxExportRoot $EvtxExportRoot ` + -EvtxRetentionDays $EvtxRetentionDays ` + -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` -AwHostname $AwHostname ` -PolicyMode $PolicyMode ` diff --git a/windows/deploy-ensemble.ps1 b/windows/deploy-ensemble.ps1 index 16c8891..11d4366 100644 --- a/windows/deploy-ensemble.ps1 +++ b/windows/deploy-ensemble.ps1 @@ -23,6 +23,9 @@ param( [bool]$IncidentCaptureEnabled = $true, [bool]$IncidentScreenshotEnabled = $true, [string]$IncidentArtifactsRoot, + [string]$EvtxExportRoot, + [int]$EvtxRetentionDays = 14, + [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, [string]$AwHostname, [string]$CustomRulesPath, @@ -81,6 +84,9 @@ if (-not (Test-Path -LiteralPath $deployScript)) { -IncidentCaptureEnabled $IncidentCaptureEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentArtifactsRoot $IncidentArtifactsRoot ` + -EvtxExportRoot $EvtxExportRoot ` + -EvtxRetentionDays $EvtxRetentionDays ` + -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` -AwHostname $AwHostname ` -CustomRulesPath $CustomRulesPath ` @@ -113,6 +119,9 @@ if (-not $SkipHardening) { -IncidentCaptureEnabled $IncidentCaptureEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentArtifactsRoot $IncidentArtifactsRoot ` + -EvtxExportRoot $EvtxExportRoot ` + -EvtxRetentionDays $EvtxRetentionDays ` + -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` -AwHostname $AwHostname ` -CustomRulesPath $CustomRulesPath ` diff --git a/windows/deploy-single-user.ps1 b/windows/deploy-single-user.ps1 index 04c70e7..c970c13 100755 --- a/windows/deploy-single-user.ps1 +++ b/windows/deploy-single-user.ps1 @@ -21,6 +21,9 @@ param( [bool]$IncidentCaptureEnabled = $true, [bool]$IncidentScreenshotEnabled = $true, [string]$IncidentArtifactsRoot, + [string]$EvtxExportRoot, + [int]$EvtxRetentionDays = 14, + [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, [string]$AwHostname, [string]$CustomRulesPath, @@ -45,6 +48,7 @@ $collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1 $endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1' $emailCollectorSource = Join-Path $PSScriptRoot 'email-outbound-collector.ps1' $sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1' +$evtxExportScriptSource = Join-Path $PSScriptRoot 'export-evtx-for-hayabusa.ps1' $exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json' $examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json' @@ -60,6 +64,7 @@ $assetResult = Copy-ActivityWatchCollectorAssets ` -EndpointCollectorScriptSource $endpointCollectorSource ` -EmailCollectorScriptSource $emailCollectorSource ` -SessionCollectorScriptSource $sessionCollectorSource ` + -EvtxExportScriptSource $evtxExportScriptSource ` -ExampleRulesSource $exampleRulesSource ` -ExamplePolicySource $examplePolicySource ` -StateRoot $StateRoot ` @@ -81,6 +86,7 @@ $config = New-ActivityWatchDeploymentConfig ` -EndpointCollectorScript $assetResult.EndpointCollectorScript ` -EmailCollectorScript $assetResult.EmailCollectorScript ` -SessionCollectorScript $assetResult.SessionCollectorScript ` + -EvtxExportScript $assetResult.EvtxExportScript ` -RulesPath $assetResult.ActiveRules ` -PolicyPath $assetResult.ActivePolicy ` -PollSeconds $PollSeconds ` @@ -92,6 +98,9 @@ $config = New-ActivityWatchDeploymentConfig ` -IncidentCaptureEnabled $IncidentCaptureEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentArtifactsRoot $IncidentArtifactsRoot ` + -EvtxExportRoot $EvtxExportRoot ` + -EvtxRetentionDays $EvtxRetentionDays ` + -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` -AwHostname $AwHostname ` -LaunchScriptPath $launchScriptPath ` diff --git a/windows/export-evtx-for-hayabusa.ps1 b/windows/export-evtx-for-hayabusa.ps1 new file mode 100644 index 0000000..0a1d4a6 --- /dev/null +++ b/windows/export-evtx-for-hayabusa.ps1 @@ -0,0 +1,141 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$OutputRoot, + [int]$RetentionDays, + [string[]]$Channels, + [int]$DaysBack = 3, + [switch]$NoZip +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function New-Directory { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -ItemType Directory -Path $Path -Force | Out-Null + } +} + +function Get-ConfigValue { + param( + [object]$Config, + [string]$Section, + [string]$Name, + $DefaultValue + ) + if ($null -eq $Config) { return $DefaultValue } + if ($Config.PSObject.Properties.Name -notcontains $Section) { return $DefaultValue } + $sectionValue = $Config.$Section + if ($null -eq $sectionValue) { return $DefaultValue } + if ($sectionValue.PSObject.Properties.Name -notcontains $Name) { return $DefaultValue } + return $sectionValue.$Name +} + +$config = $null +if (Test-Path -LiteralPath $ConfigPath) { + $config = Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json +} + +$effectiveOutputRoot = if ($OutputRoot) { + $OutputRoot +} else { + [string](Get-ConfigValue -Config $config -Section 'forensics' -Name 'evtxExportRoot' -DefaultValue 'C:\ProgramData\AWatch-rus\forensics\evtx-exports') +} +$effectiveRetentionDays = if ($PSBoundParameters.ContainsKey('RetentionDays')) { + $RetentionDays +} else { + [int](Get-ConfigValue -Config $config -Section 'forensics' -Name 'retentionDays' -DefaultValue 14) +} +$effectiveChannels = if ($Channels -and $Channels.Count -gt 0) { + @($Channels) +} else { + @(Get-ConfigValue -Config $config -Section 'forensics' -Name 'evtxChannels' -DefaultValue @( + 'Security', + 'System', + 'Application', + 'Microsoft-Windows-PowerShell/Operational', + 'Microsoft-Windows-TerminalServices-LocalSessionManager/Operational', + 'Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational' + )) +} + +New-Directory -Path $effectiveOutputRoot + +$hostName = if ($config -and $config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$config.awHostname)) { + [string]$config.awHostname +} else { + [string]$env:COMPUTERNAME +} + +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$batchRoot = Join-Path $effectiveOutputRoot "$hostName-$timestamp" +$evtxRoot = Join-Path $batchRoot 'evtx' +$metaPath = Join-Path $batchRoot 'manifest.json' +$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]]]" +$results = @() + +foreach ($channel in @($effectiveChannels | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })) { + $safeName = (($channel -replace '[\\/:*?""<>| ]', '_').Trim('_')) + $targetPath = Join-Path $evtxRoot ($safeName + '.evtx') + try { + & wevtutil.exe epl $channel $targetPath /ow:true /q:$query | Out-Null + $exists = Test-Path -LiteralPath $targetPath + $size = if ($exists) { (Get-Item -LiteralPath $targetPath).Length } else { 0 } + $results += [pscustomobject]@{ + channel = $channel + path = $targetPath + exported = $exists + size = $size + status = if ($exists) { 'ok' } else { 'empty' } + } + } + catch { + $results += [pscustomobject]@{ + channel = $channel + path = $targetPath + exported = $false + size = 0 + status = 'error' + error = $_.Exception.Message + } + } +} + +$manifest = [ordered]@{ + generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + hostname = $hostName + configPath = $ConfigPath + outputRoot = $effectiveOutputRoot + batchRoot = $batchRoot + zipPath = if ($NoZip) { $null } else { $zipPath } + daysBack = $DaysBack + retentionDays = $effectiveRetentionDays + channels = @($effectiveChannels) + exports = @($results) +} +$manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $metaPath -Encoding UTF8 + +if (-not $NoZip) { + if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue + } + Compress-Archive -Path (Join-Path $batchRoot '*') -DestinationPath $zipPath -Force +} + +$cutoff = (Get-Date).AddDays(-1 * [Math]::Max(1, $effectiveRetentionDays)) +Get-ChildItem -LiteralPath $effectiveOutputRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -lt $cutoff } | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue } +Get-ChildItem -LiteralPath $effectiveOutputRoot -File -Filter '*.zip' -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -lt $cutoff } | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } + +$manifest diff --git a/windows/hardening-recovery.ps1 b/windows/hardening-recovery.ps1 index 1ed4e84..46fbfc6 100755 --- a/windows/hardening-recovery.ps1 +++ b/windows/hardening-recovery.ps1 @@ -20,6 +20,7 @@ param( [bool]$IncidentCaptureEnabled, [bool]$IncidentScreenshotEnabled, [string]$IncidentArtifactsRoot, + [string]$EvtxExportRoot, [bool]$LogonMarkerEnabled, [string]$AwHostname, [string]$CustomRulesPath, @@ -66,6 +67,7 @@ $effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-coll $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' } +$effectiveEvtxExportScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'evtxExportScript') { [string]$existingConfig.paths.evtxExportScript } else { Join-Path $effectiveStateRoot 'export-evtx-for-hayabusa.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' } @@ -83,6 +85,9 @@ $effectiveLocalAgentLogsEnabled = if ($PSBoundParameters.ContainsKey('LocalAgent $effectiveIncidentCaptureEnabled = if ($PSBoundParameters.ContainsKey('IncidentCaptureEnabled')) { [bool]$IncidentCaptureEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'enabled') { [bool]$existingConfig.incidentCapture.enabled } else { $true } $effectiveIncidentScreenshotEnabled = if ($PSBoundParameters.ContainsKey('IncidentScreenshotEnabled')) { [bool]$IncidentScreenshotEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$existingConfig.incidentCapture.screenshotEnabled } else { $true } $effectiveIncidentArtifactsRoot = if ($PSBoundParameters.ContainsKey('IncidentArtifactsRoot') -and $IncidentArtifactsRoot) { $IncidentArtifactsRoot } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$existingConfig.incidentCapture.artifactsRoot } else { Join-Path $effectiveStateRoot 'incident-artifacts' } +$effectiveEvtxExportRoot = if ($PSBoundParameters.ContainsKey('EvtxExportRoot') -and $EvtxExportRoot) { $EvtxExportRoot } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'evtxExportRoot') { [string]$existingConfig.forensics.evtxExportRoot } else { Join-Path $effectiveStateRoot 'forensics\evtx-exports' } +$effectiveEvtxRetentionDays = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'retentionDays') { [int]$existingConfig.forensics.retentionDays } else { 14 } +$effectiveEvtxChannels = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'evtxChannels') { @($existingConfig.forensics.evtxChannels) } else { @() } $effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true } $effectiveAwHostname = if ($PSBoundParameters.ContainsKey('AwHostname') -and -not [string]::IsNullOrWhiteSpace($AwHostname)) { [string]$AwHostname } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$existingConfig.awHostname)) { [string]$existingConfig.awHostname } else { [string]$env:COMPUTERNAME } $effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' } @@ -123,6 +128,7 @@ $assetResult = Copy-ActivityWatchCollectorAssets ` -EmailCollectorScriptSource (Join-Path $PSScriptRoot 'email-outbound-collector.ps1') ` -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') ` -ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') ` -ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') ` -StateRoot $effectiveStateRoot ` @@ -146,6 +152,7 @@ $config = New-ActivityWatchDeploymentConfig ` -EmailCollectorScript $assetResult.EmailCollectorScript ` -FileCollectorScript $effectiveFileCollector ` -SessionCollectorScript $effectiveSessionCollector ` + -EvtxExportScript $effectiveEvtxExportScript ` -RulesPath $effectiveRules ` -PolicyPath $effectivePolicy ` -PollSeconds $effectivePollSeconds ` @@ -158,6 +165,9 @@ $config = New-ActivityWatchDeploymentConfig ` -IncidentCaptureEnabled $effectiveIncidentCaptureEnabled ` -IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled ` -IncidentArtifactsRoot $effectiveIncidentArtifactsRoot ` + -EvtxExportRoot $effectiveEvtxExportRoot ` + -EvtxRetentionDays $effectiveEvtxRetentionDays ` + -EvtxChannels $effectiveEvtxChannels ` -LogonMarkerEnabled $effectiveLogonMarkerEnabled ` -AwHostname $effectiveAwHostname ` -PolicyMode $effectivePolicyMode ` diff --git a/windows/validate-deployment.ps1 b/windows/validate-deployment.ps1 index 99e642d..67c9414 100644 --- a/windows/validate-deployment.ps1 +++ b/windows/validate-deployment.ps1 @@ -16,6 +16,7 @@ $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' } +$evtxExportScript = if ($config.paths.PSObject.Properties.Name -contains 'evtxExportScript') { [string]$config.paths.evtxExportScript } else { Join-Path $stateRoot 'export-evtx-for-hayabusa.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' } $policyClientScript = if ($config.paths.PSObject.Properties.Name -contains 'policyClientScript') { [string]$config.paths.policyClientScript } else { Join-Path $stateRoot 'dlp-policy-client.ps1' } @@ -43,6 +44,7 @@ $requiredFiles = @( $collectorScript, $endpointCollectorScript, $sessionCollectorScript, + $evtxExportScript, $rulesPath, $policyPath, $policyClientScript, @@ -202,8 +204,20 @@ $result = [ordered]@{ jobTitlePolicyEnabled = $printJobTitlePolicyEnabled ok = [bool]($printServiceOperationalEnabled -and $printJobTitlePolicyEnabled) } + forensics = [ordered]@{ + evtxExportRoot = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'evtxExportRoot') { [string]$config.forensics.evtxExportRoot } else { $null } + retentionDays = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'retentionDays') { [int]$config.forensics.retentionDays } else { $null } + evtxChannels = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'evtxChannels') { @($config.forensics.evtxChannels) } else { @() } + ok = [bool]( + ($config.PSObject.Properties.Name -contains 'forensics') -and + ($config.forensics.PSObject.Properties.Name -contains 'evtxExportRoot') -and + ($config.forensics.PSObject.Properties.Name -contains 'retentionDays') -and + ($config.forensics.PSObject.Properties.Name -contains 'evtxChannels') -and + (@($config.forensics.evtxChannels).Count -gt 0) + ) + } } -$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok -and $result.printTelemetry.ok) +$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok -and $result.printTelemetry.ok -and $result.forensics.ok) $result