feat(hayabusa): add auto-case scoring and 6h automation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user