feat(hayabusa): automate detached server-side processing
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Watch AW-RUS Hayabusa drop directory for new zip packages
|
||||
|
||||
[Path]
|
||||
PathModified=/opt/activitywatch/aw-rus-ops/drop
|
||||
PathExistsGlob=/opt/activitywatch/aw-rus-ops/drop/*.zip
|
||||
Unit=aw-hayabusa-drop.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=AW-RUS Hayabusa auto-process dropped packages
|
||||
After=network-online.target activitywatch-server.service
|
||||
Wants=network-online.target activitywatch-server.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 /usr/local/bin/aw-hayabusa-autoprocess
|
||||
User=root
|
||||
Group=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=aw-hayabusa-drop
|
||||
@@ -0,0 +1,106 @@
|
||||
# aw-rus Hayabusa Server Ops Bundle
|
||||
|
||||
This directory is the server-side operational bundle for Hayabusa on `10.10.10.13`.
|
||||
|
||||
## Goal
|
||||
|
||||
Allow operators to run the full bounded DFIR path without depending on the laptop repository.
|
||||
|
||||
## Server paths
|
||||
|
||||
- wrapper: `/usr/local/bin/aw-hayabusa`
|
||||
- case linker: `/usr/local/bin/aw-hayabusa-link-case`
|
||||
- Windows-driven E2E helper: `/usr/local/bin/aw-hayabusa-from-windows`
|
||||
- ops bundle root: `/opt/activitywatch/aw-rus-ops`
|
||||
- local inventory for server-side controller mode: `/opt/activitywatch/aw-rus-ops/ansible/inventory.ini`
|
||||
- local controller venv: `/opt/activitywatch/aw-rus-ops/venv`
|
||||
- local drop zone for fetched EVTX zips: `/opt/activitywatch/aw-rus-ops/drop`
|
||||
|
||||
## Minimal operator workflow on the server
|
||||
|
||||
1. Check runner health:
|
||||
|
||||
```bash
|
||||
aw-hayabusa doctor
|
||||
aw-hayabusa inventory
|
||||
```
|
||||
|
||||
2. If a zip is already on the server:
|
||||
|
||||
```bash
|
||||
aw-hayabusa accept --package /path/to/HOST-YYYYMMDD-HHMMSS.zip --host HOST
|
||||
aw-hayabusa process-inbox --mode incident
|
||||
```
|
||||
|
||||
3. Link the latest successful run to a case:
|
||||
|
||||
```bash
|
||||
aw-hayabusa-link-case --case-id 30 --mode incident
|
||||
```
|
||||
|
||||
## Full no-laptop workflow from the server
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- `/opt/activitywatch/aw-rus-ops/venv` contains `ansible` and `pywinrm`
|
||||
- `/opt/activitywatch/aw-rus-ops/ansible/inventory.ini` contains the live Windows connection details
|
||||
- WinRM from `10.10.10.13` to the Windows host is reachable
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
aw-hayabusa-from-windows --days-back 1 --mode incident --case-id 30
|
||||
```
|
||||
|
||||
This performs:
|
||||
|
||||
- Windows EVTX export via WinRM
|
||||
- fetch of the newest zip directly onto `10.10.10.13`
|
||||
- `aw-hayabusa accept`
|
||||
- `aw-hayabusa process-inbox`
|
||||
- bounded case linkage via case API
|
||||
|
||||
If WinRM from the server to Windows is blocked by network policy, use the drop-zone workflow below instead.
|
||||
|
||||
## Drop-zone automation on 10.10.10.13
|
||||
|
||||
The server can auto-process packages dropped into:
|
||||
|
||||
- `/opt/activitywatch/aw-rus-ops/drop`
|
||||
|
||||
Installed units:
|
||||
|
||||
- `/etc/systemd/system/aw-hayabusa-drop.service`
|
||||
- `/etc/systemd/system/aw-hayabusa-drop.path`
|
||||
|
||||
Behavior:
|
||||
|
||||
- any `*.zip` placed in `drop/` is automatically accepted and processed
|
||||
- optional `*.caseid` sidecar with the same basename triggers automatic bounded case linkage
|
||||
- processed `*.zip` is moved out of `drop/` into `report_dir/input-drop/` to avoid repeated re-trigger loops
|
||||
- sidecars are archived into `report_dir/input-sidecars/`
|
||||
|
||||
## Windows direct upload into the drop zone
|
||||
|
||||
Preferred production path when server-side WinRM is unavailable:
|
||||
|
||||
1. On Windows, use:
|
||||
|
||||
```powershell
|
||||
powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-upload-hayabusa-to-aw-server.ps1 -DaysBack 1 -CaseId 30
|
||||
```
|
||||
|
||||
2. The script will:
|
||||
|
||||
- run `C:\ProgramData\AWatch-rus\export-evtx-for-hayabusa.ps1`
|
||||
- upload the newest zip to `/opt/activitywatch/aw-rus-ops/drop`
|
||||
- upload matching `.caseid` when `-CaseId` is specified
|
||||
- let `aw-hayabusa-drop.path` process the package automatically on `10.10.10.13`
|
||||
|
||||
One-time SSH prerequisite on the server for user `awops`:
|
||||
|
||||
```bash
|
||||
printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILoFWQmgoUJj1P7mp1/fB5aBkI3fVgjPme9jmK8Gh9jr igor@snb-live' | sudo tee /var/lib/awops/.ssh/authorized_keys >/dev/null
|
||||
sudo chown awops:awops /var/lib/awops/.ssh/authorized_keys
|
||||
sudo chmod 600 /var/lib/awops/.ssh/authorized_keys
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
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')
|
||||
|
||||
|
||||
def run(cmd):
|
||||
print('RUN', ' '.join(str(x) for x in cmd), flush=True)
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
|
||||
def read_latest_intake():
|
||||
return json.loads(pathlib.Path('/opt/hayabusa/state/latest-intake.json').read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def load_sidecars(zip_path: pathlib.Path):
|
||||
base = zip_path.with_suffix('')
|
||||
caseid_path = base.with_suffix('.caseid')
|
||||
meta_path = base.with_suffix('.meta.json')
|
||||
meta = {}
|
||||
if meta_path.is_file():
|
||||
meta = json.loads(meta_path.read_text(encoding='utf-8'))
|
||||
case_id = meta.get('case_id')
|
||||
if case_id is None and caseid_path.is_file():
|
||||
raw = caseid_path.read_text(encoding='utf-8').strip()
|
||||
if raw:
|
||||
case_id = int(raw)
|
||||
return {
|
||||
'base': base,
|
||||
'case_id': case_id,
|
||||
'host': meta.get('host'),
|
||||
'mode': meta.get('mode', 'incident'),
|
||||
'link_source': meta.get('link_source', 'aw-rus-drop-autoprocess'),
|
||||
'caseid_path': caseid_path,
|
||||
'meta_path': meta_path,
|
||||
}
|
||||
|
||||
|
||||
def archive_sidecars(report_dir: pathlib.Path, sidecars):
|
||||
target_dir = report_dir / 'input-sidecars'
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
for path in (sidecars['caseid_path'], sidecars['meta_path']):
|
||||
if path.is_file():
|
||||
shutil.move(str(path), str(target_dir / path.name))
|
||||
|
||||
|
||||
def archive_drop_package(report_dir: pathlib.Path, zip_path: pathlib.Path):
|
||||
target_dir = report_dir / 'input-drop'
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_path = target_dir / zip_path.name
|
||||
if target_path.exists():
|
||||
target_path.unlink()
|
||||
shutil.move(str(zip_path), str(target_path))
|
||||
|
||||
|
||||
def guess_host(zip_path: pathlib.Path, sidecars) -> Optional[str]:
|
||||
if sidecars['host']:
|
||||
return str(sidecars['host'])
|
||||
name = zip_path.stem
|
||||
if '-' in name:
|
||||
return name.split('-', 1)[0]
|
||||
return name or None
|
||||
|
||||
|
||||
def process_one(zip_path: pathlib.Path):
|
||||
sidecars = load_sidecars(zip_path)
|
||||
host = guess_host(zip_path, sidecars)
|
||||
mode = sidecars['mode'] or 'incident'
|
||||
accept_cmd = [str(WRAPPER), 'accept', '--package', str(zip_path)]
|
||||
if host:
|
||||
accept_cmd += ['--host', host]
|
||||
run(accept_cmd)
|
||||
run([str(WRAPPER), 'process-inbox', '--mode', mode, '--limit', '1'])
|
||||
latest = read_latest_intake()
|
||||
report_dir = pathlib.Path(latest['report_dir'])
|
||||
archive_sidecars(report_dir, sidecars)
|
||||
archive_drop_package(report_dir, zip_path)
|
||||
if sidecars['case_id'] is not None:
|
||||
run([str(LINKER), '--case-id', str(sidecars['case_id']), '--mode', mode, '--link-source', sidecars['link_source']])
|
||||
return latest
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description='Auto-process Hayabusa zip packages dropped onto aw-rus server')
|
||||
p.add_argument('--drop-dir', default=str(DROP_DIR))
|
||||
p.add_argument('--once', action='store_true', default=True)
|
||||
args = p.parse_args()
|
||||
|
||||
drop_dir = pathlib.Path(args.drop_dir)
|
||||
drop_dir.mkdir(parents=True, exist_ok=True)
|
||||
LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with LOCK_PATH.open('w') as lock_fh:
|
||||
try:
|
||||
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
print('autoprocess already running', file=sys.stderr)
|
||||
return 0
|
||||
zips = sorted(drop_dir.glob('*.zip'))
|
||||
if not zips:
|
||||
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))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
OPS_ROOT = pathlib.Path('/opt/activitywatch/aw-rus-ops')
|
||||
DEFAULT_INVENTORY = OPS_ROOT / 'ansible' / 'inventory.ini'
|
||||
DEFAULT_DROP = OPS_ROOT / 'drop'
|
||||
DEFAULT_ANSIBLE = OPS_ROOT / 'venv' / 'bin' / 'ansible'
|
||||
DEFAULT_WRAPPER = pathlib.Path('/usr/local/bin/aw-hayabusa')
|
||||
DEFAULT_LINKER = pathlib.Path('/usr/local/bin/aw-hayabusa-link-case')
|
||||
WINDOWS_EXPORT_CMD = r"powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-evtx-for-hayabusa.ps1 -DaysBack {days_back} | ConvertTo-Json -Depth 8 -Compress"
|
||||
WINDOWS_LATEST_ZIP_CMD = r"Get-ChildItem 'C:\ProgramData\AWatch-rus\forensics\evtx-exports' -File -Filter '*.zip' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 FullName,Length,LastWriteTime | ConvertTo-Json -Compress"
|
||||
|
||||
|
||||
def run(cmd):
|
||||
proc = subprocess.run(cmd, text=True, capture_output=True)
|
||||
if proc.returncode != 0:
|
||||
sys.stderr.write(proc.stdout)
|
||||
sys.stderr.write(proc.stderr)
|
||||
raise SystemExit(proc.returncode)
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def extract_json_blob(text):
|
||||
matches = re.findall(r'(\{.*\}|\[.*\])', text, re.S)
|
||||
for candidate in reversed(matches):
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except Exception:
|
||||
continue
|
||||
raise SystemExit('cannot parse JSON from ansible output:\n' + text)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description='Run Windows EVTX export and Hayabusa intake directly from aw-server, without the laptop')
|
||||
p.add_argument('--inventory', default=str(DEFAULT_INVENTORY))
|
||||
p.add_argument('--ansible-bin', default=str(DEFAULT_ANSIBLE))
|
||||
p.add_argument('--drop-dir', default=str(DEFAULT_DROP))
|
||||
p.add_argument('--days-back', type=int, default=1)
|
||||
p.add_argument('--mode', default='incident', choices=['quick', 'incident', 'full'])
|
||||
p.add_argument('--case-id', type=int)
|
||||
p.add_argument('--link-source', default='aw-rus-ops-from-windows')
|
||||
p.add_argument('--windows-group', default='aw_windows')
|
||||
p.add_argument('--wrapper', default=str(DEFAULT_WRAPPER))
|
||||
p.add_argument('--linker', default=str(DEFAULT_LINKER))
|
||||
args = p.parse_args()
|
||||
|
||||
inventory = pathlib.Path(args.inventory)
|
||||
ansible_bin = pathlib.Path(args.ansible_bin)
|
||||
drop_dir = pathlib.Path(args.drop_dir)
|
||||
wrapper = pathlib.Path(args.wrapper)
|
||||
linker = pathlib.Path(args.linker)
|
||||
if not inventory.is_file():
|
||||
raise SystemExit(f'inventory not found: {inventory}')
|
||||
if not ansible_bin.is_file():
|
||||
raise SystemExit(f'ansible binary not found: {ansible_bin}')
|
||||
if not wrapper.is_file():
|
||||
raise SystemExit(f'wrapper not found: {wrapper}')
|
||||
drop_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
export_cmd = [str(ansible_bin), args.windows_group, '-i', str(inventory), '-m', 'win_shell', '-a', WINDOWS_EXPORT_CMD.format(days_back=args.days_back)]
|
||||
print('RUN_EXPORT', ' '.join(export_cmd))
|
||||
export_out = run(export_cmd)
|
||||
export_json = extract_json_blob(export_out)
|
||||
|
||||
list_cmd = [str(ansible_bin), args.windows_group, '-i', str(inventory), '-m', 'win_shell', '-a', WINDOWS_LATEST_ZIP_CMD]
|
||||
print('RUN_LIST', ' '.join(list_cmd))
|
||||
latest_out = run(list_cmd)
|
||||
latest = extract_json_blob(latest_out)
|
||||
if isinstance(latest, list):
|
||||
latest = latest[0]
|
||||
remote_zip = latest['FullName']
|
||||
filename = pathlib.PureWindowsPath(remote_zip).name
|
||||
local_zip = drop_dir / filename
|
||||
|
||||
remote_zip_posix = remote_zip.replace('\\', '/')
|
||||
fetch_cmd = [str(ansible_bin), args.windows_group, '-i', str(inventory), '-m', 'fetch', '-a', f'src={remote_zip_posix} dest={drop_dir}/ flat=yes']
|
||||
print('RUN_FETCH', ' '.join(fetch_cmd))
|
||||
run(fetch_cmd)
|
||||
if not local_zip.is_file():
|
||||
raise SystemExit(f'fetched zip not found: {local_zip}')
|
||||
|
||||
accept_cmd = [str(wrapper), 'accept', '--package', str(local_zip)]
|
||||
host = export_json.get('hostname') or pathlib.Path(filename).stem.split('-')[0]
|
||||
if host:
|
||||
accept_cmd += ['--host', str(host)]
|
||||
print('RUN_ACCEPT', ' '.join(accept_cmd))
|
||||
subprocess.run(accept_cmd, check=True)
|
||||
|
||||
process_cmd = [str(wrapper), 'process-inbox', '--mode', args.mode, '--limit', '1']
|
||||
print('RUN_PROCESS', ' '.join(process_cmd))
|
||||
subprocess.run(process_cmd, check=True)
|
||||
|
||||
if args.case_id is not None:
|
||||
if not linker.is_file():
|
||||
raise SystemExit(f'linker not found: {linker}')
|
||||
link_cmd = [str(linker), '--case-id', str(args.case_id), '--mode', args.mode, '--link-source', args.link_source]
|
||||
print('RUN_LINK', ' '.join(link_cmd))
|
||||
subprocess.run(link_cmd, check=True)
|
||||
|
||||
latest_intake = pathlib.Path('/opt/hayabusa/state/latest-intake.json')
|
||||
print('LATEST_INTAKE')
|
||||
print(latest_intake.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def build_payload(intake, mode, link_source):
|
||||
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, payload):
|
||||
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:
|
||||
return json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
|
||||
def get_json(url):
|
||||
with urllib.request.urlopen(url) as resp:
|
||||
return json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description='Link latest or specified Hayabusa intake metadata to AW-rus case management')
|
||||
p.add_argument('--case-id', type=int, required=True)
|
||||
p.add_argument('--intake-json', default='/opt/hayabusa/state/latest-intake.json')
|
||||
p.add_argument('--case-api-base', default='http://127.0.0.1:5602')
|
||||
p.add_argument('--mode', default='incident')
|
||||
p.add_argument('--link-source', default='aw-rus-ops')
|
||||
args = p.parse_args()
|
||||
|
||||
intake_path = pathlib.Path(args.intake_json)
|
||||
if not intake_path.is_file():
|
||||
raise SystemExit(f'intake json not found: {intake_path}')
|
||||
intake = json.loads(intake_path.read_text(encoding='utf-8'))
|
||||
payload = build_payload(intake, args.mode, args.link_source)
|
||||
case_url = f"{args.case_api_base.rstrip('/')}/api/0/dlp/cases/{args.case_id}/forensics/hayabusa"
|
||||
try:
|
||||
post_json(case_url, payload)
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode('utf-8', errors='replace')
|
||||
raise SystemExit(f'case API POST failed: HTTP {exc.code}: {body}')
|
||||
case = get_json(f"{args.case_api_base.rstrip('/')}/api/0/dlp/cases/{args.case_id}")
|
||||
print(json.dumps({'case_id': args.case_id, 'intake': intake, 'forensics': case.get('forensics')}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user