feat(hayabusa): add auto-case scoring and 6h automation

This commit is contained in:
igor04091968
2026-05-21 20:47:49 +03:00
parent 4e4898828b
commit 0c88b519ee
22 changed files with 757 additions and 32 deletions
+10 -2
View File
@@ -10,7 +10,8 @@ fi
source "$ENV_FILE"
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}"
REPORT_BASE="${AW_WORKTIME_REPORT_BASE:-http://10.10.10.13:5610}"
SERVER_PUBLIC_HOST="${AW_SERVER_PUBLIC_HOST:-${AW_SERVER_HOST:-$(hostname -f 2>/dev/null || hostname)}}"
REPORT_BASE="${AW_WORKTIME_REPORT_BASE:-http://${SERVER_PUBLIC_HOST}:5610}"
CASE_PORT="${AW_DLP_CASE_PORT:-5602}"
CASE_BASE="${AW_DLP_CASE_PUBLIC_BASE:-}"
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
@@ -62,12 +63,19 @@ worktime_panel_hash="$(sha1sum "$WORKTIME_PANEL_TARGET" | awk '{print substr($1,
if [[ -z "$CASE_BASE" ]]; then
CASE_BASE="$(python3 - "$REPORT_BASE" "$CASE_PORT" <<'PY'
from urllib.parse import urlsplit, urlunsplit
import os
import socket
import sys
report_base = sys.argv[1]
case_port = sys.argv[2]
parts = urlsplit(report_base)
hostname = parts.hostname or "10.10.10.13"
hostname = (
parts.hostname
or os.environ.get("AW_SERVER_PUBLIC_HOST")
or os.environ.get("AW_SERVER_HOST")
or socket.getfqdn()
)
scheme = parts.scheme or "http"
print(urlunsplit((scheme, f"{hostname}:{case_port}", "", "", "")))
PY
+3 -2
View File
@@ -1,10 +1,11 @@
[Unit]
Description=AW-RUS Hayabusa auto-process dropped packages
After=network-online.target activitywatch-server.service
Wants=network-online.target activitywatch-server.service
After=network-online.target activitywatch-server.service aw-dlp-case-management.service
Wants=network-online.target activitywatch-server.service aw-dlp-case-management.service
[Service]
Type=oneshot
EnvironmentFile=-/etc/activitywatch/aw-server.env
ExecStart=/usr/bin/python3 /usr/local/bin/aw-hayabusa-autoprocess
User=root
Group=root
+10 -1
View File
@@ -13,7 +13,8 @@ AW_SERVER_USER=activitywatch
AW_SERVER_GROUP=activitywatch
# Worktime API Configuration
AW_WORKTIME_REPORT_BASE=http://10.10.10.13:5610
AW_SERVER_PUBLIC_HOST=aw-server
AW_WORKTIME_REPORT_BASE=http://aw-server:5610
AW_WORKTIME_TZ=Europe/Moscow
AW_SERVER_URL=http://127.0.0.1:5600
@@ -42,5 +43,13 @@ AW_MONITORED_WINDOWS_HOSTNAME=SHARKON2025
AW_RUS_HEALTH_STATE_DIR=/var/lib/activitywatch/health
AW_RUS_HEALTH_VALIDATION_DIR=/var/lib/activitywatch/health/windows-validation
# Hayabusa auto-case / alerting
AW_HAYABUSA_AUTO_CASE_ENABLED=true
AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY=medium
AW_HAYABUSA_TELEGRAM_ENABLED=false
AW_HAYABUSA_TELEGRAM_MIN_SEVERITY=high
AW_HAYABUSA_TELEGRAM_BOT_TOKEN=
AW_HAYABUSA_TELEGRAM_CHAT_IDS=
# Integration Test Configuration
AW_INTEGRATION_TEST_ENABLED=false
+21 -4
View File
@@ -12,6 +12,7 @@ DROP_DIR = pathlib.Path('/opt/activitywatch/aw-rus-ops/drop')
LOCK_PATH = pathlib.Path('/opt/hayabusa/state/aw-hayabusa-autoprocess.lock')
WRAPPER = pathlib.Path('/usr/local/bin/aw-hayabusa')
LINKER = pathlib.Path('/usr/local/bin/aw-hayabusa-link-case')
CASE_ALERT = pathlib.Path('/usr/local/bin/aw-hayabusa-case-alert')
def run(cmd):
@@ -19,6 +20,11 @@ def run(cmd):
subprocess.run(cmd, check=True)
def run_capture(cmd):
print('RUN', ' '.join(str(x) for x in cmd), flush=True)
return subprocess.run(cmd, check=False, text=True, capture_output=True)
def read_latest_intake():
return json.loads(pathlib.Path('/opt/hayabusa/state/latest-intake.json').read_text(encoding='utf-8'))
@@ -83,11 +89,22 @@ def process_one(zip_path: pathlib.Path):
run([str(WRAPPER), 'process-inbox', '--mode', mode, '--limit', '1'])
latest = read_latest_intake()
report_dir = pathlib.Path(latest['report_dir'])
case_alert = None
if CASE_ALERT.is_file():
alert_cmd = [str(CASE_ALERT), '--mode', mode, '--link-source', sidecars['link_source']]
if sidecars['case_id'] is not None:
alert_cmd += ['--case-id', str(sidecars['case_id'])]
result = run_capture(alert_cmd)
case_alert = {
'returncode': result.returncode,
'stdout': result.stdout.strip(),
'stderr': result.stderr.strip(),
}
archive_sidecars(report_dir, sidecars)
archive_drop_package(report_dir, zip_path)
if sidecars['case_id'] is not None:
if sidecars['case_id'] is not None and not CASE_ALERT.is_file():
run([str(LINKER), '--case-id', str(sidecars['case_id']), '--mode', mode, '--link-source', sidecars['link_source']])
return latest
return {'latest_intake': latest, 'case_alert': case_alert}
def main():
@@ -110,8 +127,8 @@ def main():
print('no zip packages in drop dir')
return 0
for zip_path in zips:
latest = process_one(zip_path)
print(json.dumps({'processed': str(zip_path), 'latest_intake': latest}, ensure_ascii=False, indent=2))
result = process_one(zip_path)
print(json.dumps({'processed': str(zip_path), **result}, ensure_ascii=False, indent=2))
return 0
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
import os
import pathlib
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter
from datetime import datetime, timezone
from typing import Any
SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4}
LEVEL_NORMALIZATION = {
"informational": "info",
"info": "info",
"low": "low",
"med": "med",
"medium": "med",
"high": "high",
"crit": "crit",
"critical": "crit",
}
LEVEL_WEIGHTS = {
"info": 1,
"low": 4,
"med": 12,
"high": 40,
"crit": 100,
}
def env_bool(name: str, default: bool) -> bool:
value = os.environ.get(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def normalize_level(level: str | None) -> str:
if not level:
return "info"
return LEVEL_NORMALIZATION.get(level.strip().lower(), level.strip().lower())
def severity_meets(actual: str, threshold: str) -> bool:
return SEVERITY_ORDER.get(actual, 0) >= SEVERITY_ORDER.get(threshold, 0)
def build_hayabusa_payload(intake: dict[str, Any], mode: str, link_source: str) -> dict[str, Any]:
report_dir = pathlib.Path(intake["report_dir"])
return {
"tool": "hayabusa",
"host": intake["host"],
"mode": mode,
"status": intake["status"],
"intake_id": intake["intake_id"],
"package_path": intake["package_path"],
"sha256": intake["sha256"],
"report_dir": intake["report_dir"],
"summary_html": str(report_dir / "summary.html"),
"timeline_path": str(report_dir / "timeline.jsonl"),
"manifest_path": str(report_dir / "manifest.json"),
"link_source": link_source,
}
def post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=data, method="POST", headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
def patch_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=data, method="PATCH", headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
def get_json(url: str) -> dict[str, Any]:
with urllib.request.urlopen(url) as resp:
return json.loads(resp.read().decode("utf-8"))
def parse_timestamp(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def read_csv_rows(path: pathlib.Path) -> int:
if not path.is_file():
return 0
with path.open("r", encoding="utf-8-sig", newline="") as fh:
reader = csv.reader(fh)
rows = list(reader)
if not rows:
return 0
return max(0, len(rows) - 1)
def analyze_report(report_dir: pathlib.Path) -> dict[str, Any]:
timeline_path = report_dir / "timeline.jsonl"
level_counts: Counter[str] = Counter()
title_counts: Counter[str] = Counter()
first_ts: datetime | None = None
last_ts: datetime | None = None
total_events = 0
if timeline_path.is_file():
with timeline_path.open("r", encoding="utf-8") as fh:
for raw_line in fh:
line = raw_line.strip()
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
total_events += 1
level = normalize_level(str(event.get("Level", "")))
level_counts[level] += 1
title = str(event.get("RuleTitle") or "").strip() or "Unknown rule"
title_counts[title] += 1
ts = parse_timestamp(event.get("Timestamp"))
if ts is not None:
first_ts = ts if first_ts is None or ts < first_ts else first_ts
last_ts = ts if last_ts is None or ts > last_ts else last_ts
failed_logons = read_csv_rows(report_dir / "logon-summary-failed.csv")
successful_logons = read_csv_rows(report_dir / "logon-summary-successful.csv")
suspicious_pwsh = sum(
count
for title, count in title_counts.items()
if "pwsh" in title.lower() or "powershell" in title.lower() or "obfuscation" in title.lower()
)
credential_events = sum(count for title, count in title_counts.items() if "credential" in title.lower())
timestomp_events = sum(count for title, count in title_counts.items() if "timestomp" in title.lower())
logon_failure_events = sum(count for title, count in title_counts.items() if "logon failure" in title.lower())
score = (
sum(LEVEL_WEIGHTS.get(level, 0) * count for level, count in level_counts.items())
+ min(failed_logons, 200) * 2
+ suspicious_pwsh * 6
+ credential_events * 8
+ timestomp_events * 12
+ logon_failure_events * 2
)
crit_count = level_counts.get("crit", 0)
high_count = level_counts.get("high", 0)
med_count = level_counts.get("med", 0)
if crit_count >= 1 or score >= 240 or (high_count >= 4 and suspicious_pwsh >= 4):
severity = "critical"
elif high_count >= 1 or score >= 120 or suspicious_pwsh >= 8 or credential_events >= 5:
severity = "high"
elif med_count >= 1 or score >= 40 or failed_logons >= 10:
severity = "medium"
else:
severity = "low"
return {
"severity": severity,
"score": score,
"events_total": total_events,
"level_counts": dict(level_counts),
"top_rules": [{"title": title, "count": count} for title, count in title_counts.most_common(5)],
"first_timestamp": first_ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") if first_ts else None,
"last_timestamp": last_ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") if last_ts else None,
"failed_logon_rows": failed_logons,
"successful_logon_rows": successful_logons,
"suspicious_pwsh": suspicious_pwsh,
"credential_events": credential_events,
"timestomp_events": timestomp_events,
"logon_failure_events": logon_failure_events,
}
def build_case_title(host: str, summary: dict[str, Any]) -> str:
top_rule = summary.get("top_rules") or []
suffix = top_rule[0]["title"] if top_rule else "No dominant rule"
return f"Hayabusa {summary['severity'].upper()} · {host} · {suffix}"
def build_case_payload(intake: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]:
return {
"incident_id": f"hayabusa:{intake['host']}:{intake['intake_id']}",
"host": intake["host"],
"title": build_case_title(intake["host"], summary),
"severity": summary["severity"],
"evidence": {
"hayabusa": {
"intake_id": intake["intake_id"],
"package_path": intake["package_path"],
"sha256": intake["sha256"],
"report_dir": intake["report_dir"],
"summary": summary,
}
},
}
def build_comment(summary: dict[str, Any], intake: dict[str, Any]) -> str:
top = ", ".join(f"{item['title']} ({item['count']})" for item in summary.get("top_rules", [])[:3]) or "n/a"
return (
f"Hayabusa auto-summary\n"
f"Severity: {summary['severity']} (score={summary['score']})\n"
f"Host: {intake['host']}\n"
f"Intake: {intake['intake_id']}\n"
f"Events: {summary['events_total']}, failed_logons={summary['failed_logon_rows']}, "
f"suspicious_pwsh={summary['suspicious_pwsh']}, credential_events={summary['credential_events']}\n"
f"Top rules: {top}\n"
f"Report: {intake['report_dir']}"
)
def send_telegram(bot_token: str, chat_ids: list[str], text: str) -> list[dict[str, Any]]:
results = []
for chat_id in chat_ids:
payload = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode("utf-8")
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
req = urllib.request.Request(url, data=payload, method="POST")
try:
with urllib.request.urlopen(req) as resp:
body = json.loads(resp.read().decode("utf-8"))
results.append({"chat_id": chat_id, "ok": True, "response": body})
except Exception as exc: # noqa: BLE001
results.append({"chat_id": chat_id, "ok": False, "error": str(exc)})
return results
def build_telegram_text(case_id: int | None, intake: dict[str, Any], summary: dict[str, Any]) -> str:
top = ", ".join(f"{item['title']} ({item['count']})" for item in summary.get("top_rules", [])[:3]) or "n/a"
case_part = f"\nCase: {case_id}" if case_id is not None else ""
return (
f"Hayabusa {summary['severity'].upper()} alert\n"
f"Host: {intake['host']}{case_part}\n"
f"Intake: {intake['intake_id']}\n"
f"Score: {summary['score']}\n"
f"Events: {summary['events_total']}, failed_logons={summary['failed_logon_rows']}, "
f"suspicious_pwsh={summary['suspicious_pwsh']}\n"
f"Top rules: {top}\n"
f"Report: {intake['report_dir']}"
)
def main() -> int:
p = argparse.ArgumentParser(description="Auto-create/update AW-rus case, compute Hayabusa severity, and send Telegram alerts")
p.add_argument("--case-id", type=int)
p.add_argument("--intake-json", default="/opt/hayabusa/state/latest-intake.json")
p.add_argument("--case-api-base", default=os.environ.get("AW_HAYABUSA_CASE_API_BASE", "http://127.0.0.1:5602"))
p.add_argument("--mode", default="incident")
p.add_argument("--link-source", default="aw-rus-drop-autoprocess")
p.add_argument("--auto-create", action="store_true", default=env_bool("AW_HAYABUSA_AUTO_CASE_ENABLED", True))
p.add_argument("--auto-create-min-severity", default=os.environ.get("AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY", "medium"))
p.add_argument("--telegram-enabled", action="store_true", default=env_bool("AW_HAYABUSA_TELEGRAM_ENABLED", False))
p.add_argument("--telegram-min-severity", default=os.environ.get("AW_HAYABUSA_TELEGRAM_MIN_SEVERITY", "high"))
p.add_argument("--telegram-bot-token", default=os.environ.get("AW_HAYABUSA_TELEGRAM_BOT_TOKEN", ""))
p.add_argument("--telegram-chat-ids", default=os.environ.get("AW_HAYABUSA_TELEGRAM_CHAT_IDS", ""))
args = p.parse_args()
intake_path = pathlib.Path(args.intake_json)
intake = json.loads(intake_path.read_text(encoding="utf-8"))
summary = analyze_report(pathlib.Path(intake["report_dir"]))
case_api_base = args.case_api_base.rstrip("/")
if case_api_base.endswith("/api/0/dlp/cases"):
case_api_base = case_api_base[: -len("/api/0/dlp/cases")]
case_id = args.case_id
created_case = None
case_error = None
linked = False
comment_added = False
try:
if case_id is None and args.auto_create and severity_meets(summary["severity"], args.auto_create_min_severity):
created_case = post_json(f"{case_api_base}/api/0/dlp/cases", build_case_payload(intake, summary))
case_id = int(created_case["id"])
if case_id is not None:
patch_json(
f"{case_api_base}/api/0/dlp/cases/{case_id}",
{"severity": summary["severity"]},
)
post_json(
f"{case_api_base}/api/0/dlp/cases/{case_id}/forensics/hayabusa",
build_hayabusa_payload(intake, args.mode, args.link_source),
)
linked = True
post_json(
f"{case_api_base}/api/0/dlp/cases/{case_id}/comments",
{"comment": build_comment(summary, intake), "author": "aw-hayabusa-auto"},
)
comment_added = True
except Exception as exc: # noqa: BLE001
case_error = str(exc)
telegram_results: list[dict[str, Any]] = []
if args.telegram_enabled and args.telegram_bot_token and severity_meets(summary["severity"], args.telegram_min_severity):
chat_ids = [item.strip() for item in args.telegram_chat_ids.split(",") if item.strip()]
if chat_ids:
telegram_results = send_telegram(
bot_token=args.telegram_bot_token,
chat_ids=chat_ids,
text=build_telegram_text(case_id, intake, summary),
)
result = {
"summary": summary,
"case_id": case_id,
"case_created": created_case,
"case_linked": linked,
"case_comment_added": comment_added,
"case_error": case_error,
"telegram_results": telegram_results,
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if case_error is None else 1
if __name__ == "__main__":
raise SystemExit(main())