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 ID | DFIR | Обновлено |
' +
+ '| Загрузка... |
' +
'
' +
'' +
'';
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