feat(detmir): add rust-first operations tooling

This commit is contained in:
igor04091968
2026-06-02 17:57:58 +03:00
parent 60670d30a8
commit 19e3682bc8
263 changed files with 51678 additions and 718 deletions
@@ -0,0 +1,300 @@
use std::fs::{self, File};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use clap::Parser;
use fs2::FileExt;
use hayabusa_tools::{guess_host_from_filename, read_json_file};
use serde_json::{Value, json};
const LOCK_PATH: &str = "/opt/hayabusa/state/aw-hayabusa-autoprocess.lock";
const WRAPPER: &str = "/usr/local/bin/aw-hayabusa";
const LINKER: &str = "/usr/local/bin/aw-hayabusa-link-case";
const CASE_ALERT: &str = "/usr/local/bin/aw-hayabusa-case-alert";
const LATEST_INTAKE: &str = "/opt/hayabusa/state/latest-intake.json";
#[derive(Debug, Parser)]
#[command(about = "Auto-process Hayabusa zip packages dropped onto aw-rus server")]
struct Cli {
#[arg(long, default_value = "/opt/activitywatch/aw-rus-ops/drop")]
drop_dir: PathBuf,
#[arg(long, default_value_t = true)]
once: bool,
}
#[derive(Debug)]
struct Sidecars {
case_id: Option<i64>,
host: Option<String>,
mode: String,
link_source: String,
caseid_path: PathBuf,
meta_path: PathBuf,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
let _once = cli.once;
fs::create_dir_all(&cli.drop_dir)
.with_context(|| format!("create {}", cli.drop_dir.display()))?;
let lock_path = Path::new(LOCK_PATH);
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let lock = File::create(lock_path).with_context(|| format!("open {}", lock_path.display()))?;
if lock.try_lock_exclusive().is_err() {
eprintln!("autoprocess already running");
return Ok(0);
}
let zips = list_zips(&cli.drop_dir)?;
if zips.is_empty() {
println!("no zip packages in drop dir");
return Ok(0);
}
for zip_path in zips {
let result = process_one(&zip_path)?;
println!(
"{}",
serde_json::to_string_pretty(&json!({
"processed": zip_path.display().to_string(),
"latest_intake": result.latest_intake,
"case_alert": result.case_alert,
}))?
);
}
Ok(0)
}
struct ProcessResult {
latest_intake: Value,
case_alert: Option<Value>,
}
fn list_zips(drop_dir: &Path) -> Result<Vec<PathBuf>> {
let mut zips = Vec::new();
for entry in fs::read_dir(drop_dir).with_context(|| format!("read {}", drop_dir.display()))? {
let path = entry?.path();
if path.extension().and_then(|ext| ext.to_str()) == Some("zip") {
zips.push(path);
}
}
zips.sort();
Ok(zips)
}
fn process_one(zip_path: &Path) -> Result<ProcessResult> {
let sidecars = load_sidecars(zip_path)?;
let host = guess_host(zip_path, &sidecars);
let mode = if sidecars.mode.is_empty() {
"incident".to_string()
} else {
sidecars.mode.clone()
};
let mut accept_cmd = vec![
"accept".to_string(),
"--package".to_string(),
zip_path.display().to_string(),
];
if let Some(host) = host {
accept_cmd.extend(["--host".to_string(), host]);
}
run_checked(Path::new(WRAPPER), &accept_cmd)?;
run_checked(
Path::new(WRAPPER),
&[
"process-inbox".to_string(),
"--mode".to_string(),
mode.clone(),
"--limit".to_string(),
"1".to_string(),
],
)?;
let latest = read_json_file(Path::new(LATEST_INTAKE))?;
let report_dir = PathBuf::from(
latest
.get("report_dir")
.and_then(Value::as_str)
.context("latest intake report_dir missing")?,
);
let mut case_alert = None;
if Path::new(CASE_ALERT).is_file() {
let mut alert_cmd = vec![
"--mode".to_string(),
mode.clone(),
"--link-source".to_string(),
sidecars.link_source.clone(),
];
if let Some(case_id) = sidecars.case_id {
alert_cmd.extend(["--case-id".to_string(), case_id.to_string()]);
}
let output = run_capture(Path::new(CASE_ALERT), &alert_cmd)?;
case_alert = Some(json!({
"returncode": output.returncode,
"stdout": output.stdout.trim(),
"stderr": output.stderr.trim(),
}));
}
archive_sidecars(&report_dir, &sidecars)?;
archive_drop_package(&report_dir, zip_path)?;
if sidecars.case_id.is_some() && !Path::new(CASE_ALERT).is_file() {
run_checked(
Path::new(LINKER),
&[
"--case-id".to_string(),
sidecars.case_id.unwrap().to_string(),
"--mode".to_string(),
mode,
"--link-source".to_string(),
sidecars.link_source,
],
)?;
}
Ok(ProcessResult {
latest_intake: latest,
case_alert,
})
}
fn load_sidecars(zip_path: &Path) -> Result<Sidecars> {
let base = zip_path.with_extension("");
let caseid_path = base.with_extension("caseid");
let meta_path = base.with_extension("meta.json");
let meta = if meta_path.is_file() {
read_json_file(&meta_path)?
} else {
json!({})
};
let mut case_id = meta.get("case_id").and_then(Value::as_i64);
if case_id.is_none() && caseid_path.is_file() {
let raw = fs::read_to_string(&caseid_path)
.with_context(|| format!("read {}", caseid_path.display()))?;
let trimmed = raw.trim();
if !trimmed.is_empty() {
case_id = Some(
trimmed
.parse::<i64>()
.with_context(|| format!("parse {}", caseid_path.display()))?,
);
}
}
Ok(Sidecars {
case_id,
host: meta.get("host").and_then(Value::as_str).map(str::to_string),
mode: meta
.get("mode")
.and_then(Value::as_str)
.unwrap_or("incident")
.to_string(),
link_source: meta
.get("link_source")
.and_then(Value::as_str)
.unwrap_or("aw-rus-drop-autoprocess")
.to_string(),
caseid_path,
meta_path,
})
}
fn archive_sidecars(report_dir: &Path, sidecars: &Sidecars) -> Result<()> {
let target_dir = report_dir.join("input-sidecars");
fs::create_dir_all(&target_dir).with_context(|| format!("create {}", target_dir.display()))?;
for path in [&sidecars.caseid_path, &sidecars.meta_path] {
if path.is_file() {
fs::rename(
path,
target_dir.join(path.file_name().context("sidecar file name")?),
)
.with_context(|| format!("move {}", path.display()))?;
}
}
Ok(())
}
fn archive_drop_package(report_dir: &Path, zip_path: &Path) -> Result<()> {
let target_dir = report_dir.join("input-drop");
fs::create_dir_all(&target_dir).with_context(|| format!("create {}", target_dir.display()))?;
let target_path = target_dir.join(zip_path.file_name().context("zip file name")?);
if target_path.exists() {
fs::remove_file(&target_path)
.with_context(|| format!("remove {}", target_path.display()))?;
}
fs::rename(zip_path, &target_path).with_context(|| format!("move {}", zip_path.display()))?;
Ok(())
}
fn guess_host(zip_path: &Path, sidecars: &Sidecars) -> Option<String> {
if let Some(host) = &sidecars.host {
if !host.is_empty() {
return Some(host.clone());
}
}
let name = zip_path
.file_stem()
.and_then(|name| name.to_str())
.map(guess_host_from_filename)?;
(!name.is_empty()).then_some(name)
}
fn run_checked(program: &Path, args: &[String]) -> Result<()> {
println!(
"RUN {} {}",
program.display(),
args.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(" ")
);
let status = Command::new(program)
.args(args)
.status()
.with_context(|| format!("run {}", program.display()))?;
if !status.success() {
bail!(
"{} failed with status {}",
program.display(),
status.code().unwrap_or(1)
);
}
Ok(())
}
struct Captured {
returncode: i32,
stdout: String,
stderr: String,
}
fn run_capture(program: &Path, args: &[String]) -> Result<Captured> {
println!(
"RUN {} {}",
program.display(),
args.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(" ")
);
let output = Command::new(program)
.args(args)
.output()
.with_context(|| format!("run {}", program.display()))?;
Ok(Captured {
returncode: output.status.code().unwrap_or(1),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
@@ -0,0 +1,184 @@
use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
use hayabusa_tools::{
analyze_report, build_case_payload, build_comment, build_hayabusa_payload, build_telegram_text,
env_bool, env_string, http_client, normalize_case_api_base, patch_json, post_json,
read_json_file, required_str, severity_meets,
};
use reqwest::blocking::Client;
use serde_json::{Value, json};
#[derive(Debug, Parser)]
#[command(
about = "Auto-create/update AW-rus case, compute Hayabusa severity, and send Telegram alerts"
)]
struct Cli {
#[arg(long)]
case_id: Option<i64>,
#[arg(long, default_value = "/opt/hayabusa/state/latest-intake.json")]
intake_json: PathBuf,
#[arg(long)]
case_api_base: Option<String>,
#[arg(long, default_value = "incident")]
mode: String,
#[arg(long, default_value = "aw-rus-drop-autoprocess")]
link_source: String,
#[arg(long, default_value_t = false)]
auto_create: bool,
#[arg(long)]
auto_create_min_severity: Option<String>,
#[arg(long, default_value_t = false)]
telegram_enabled: bool,
#[arg(long)]
telegram_min_severity: Option<String>,
#[arg(long)]
telegram_bot_token: Option<String>,
#[arg(long)]
telegram_chat_ids: Option<String>,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let cli = Cli::parse();
let intake = read_json_file(&cli.intake_json)?;
let summary = analyze_report(PathBuf::from(required_str(&intake, "report_dir")?).as_path())?;
let case_api_base = normalize_case_api_base(cli.case_api_base.as_deref().unwrap_or(
&env_string("AW_HAYABUSA_CASE_API_BASE", "http://127.0.0.1:5602"),
));
let auto_create = cli.auto_create || env_bool("AW_HAYABUSA_AUTO_CASE_ENABLED", true);
let auto_create_min_severity = cli
.auto_create_min_severity
.unwrap_or_else(|| env_string("AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY", "medium"));
let telegram_enabled = cli.telegram_enabled || env_bool("AW_HAYABUSA_TELEGRAM_ENABLED", false);
let telegram_min_severity = cli
.telegram_min_severity
.unwrap_or_else(|| env_string("AW_HAYABUSA_TELEGRAM_MIN_SEVERITY", "high"));
let telegram_bot_token = cli
.telegram_bot_token
.unwrap_or_else(|| env_string("AW_HAYABUSA_TELEGRAM_BOT_TOKEN", ""));
let telegram_chat_ids = cli
.telegram_chat_ids
.unwrap_or_else(|| env_string("AW_HAYABUSA_TELEGRAM_CHAT_IDS", ""));
let client = http_client()?;
let mut case_id = cli.case_id;
let mut created_case = Value::Null;
let mut case_error: Option<String> = None;
let mut linked = false;
let mut comment_added = false;
if let Err(err) = (|| -> Result<()> {
if case_id.is_none()
&& auto_create
&& severity_meets(&summary.severity, &auto_create_min_severity)
{
created_case = post_json(
&client,
&format!("{case_api_base}/api/0/dlp/cases"),
&build_case_payload(&intake, &summary)?,
)?;
case_id = created_case.get("id").and_then(Value::as_i64);
}
if let Some(id) = case_id {
patch_json(
&client,
&format!("{case_api_base}/api/0/dlp/cases/{id}"),
&json!({"severity": summary.severity}),
)?;
post_json(
&client,
&format!("{case_api_base}/api/0/dlp/cases/{id}/forensics/hayabusa"),
&build_hayabusa_payload(&intake, &cli.mode, &cli.link_source)?,
)?;
linked = true;
post_json(
&client,
&format!("{case_api_base}/api/0/dlp/cases/{id}/comments"),
&json!({"comment": build_comment(&summary, &intake)?, "author": "aw-hayabusa-auto"}),
)?;
comment_added = true;
}
Ok(())
})() {
case_error = Some(err.to_string());
}
let telegram_results = if telegram_enabled
&& !telegram_bot_token.is_empty()
&& severity_meets(&summary.severity, &telegram_min_severity)
{
let chat_ids = telegram_chat_ids
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.collect::<Vec<_>>();
send_telegram(
&client,
&telegram_bot_token,
&chat_ids,
&build_telegram_text(case_id, &intake, &summary)?,
)
} else {
Vec::new()
};
let result = json!({
"summary": summary,
"case_id": case_id,
"case_created": if created_case.is_null() { Value::Null } else { created_case },
"case_linked": linked,
"case_comment_added": comment_added,
"case_error": case_error,
"telegram_results": telegram_results,
});
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(if result.get("case_error").is_some_and(|v| !v.is_null()) {
1
} else {
0
})
}
fn send_telegram(client: &Client, bot_token: &str, chat_ids: &[&str], text: &str) -> Vec<Value> {
let mut results = Vec::new();
for chat_id in chat_ids {
let url = format!("https://api.telegram.org/bot{bot_token}/sendMessage");
let response = client
.post(&url)
.form(&[("chat_id", *chat_id), ("text", text)])
.send();
match response {
Ok(resp) => match resp.json::<Value>() {
Ok(body) => results.push(json!({"chat_id": chat_id, "ok": true, "response": body})),
Err(err) => {
results.push(json!({"chat_id": chat_id, "ok": false, "error": err.to_string()}))
}
},
Err(_) => results
.push(json!({"chat_id": chat_id, "ok": false, "error": "telegram request failed"})),
}
}
results
}
@@ -0,0 +1,243 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use clap::Parser;
use hayabusa_tools::{guess_host_from_filename, windows_filename};
use regex::Regex;
use serde_json::Value;
const WINDOWS_EXPORT_CMD: &str = r"powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-evtx-for-hayabusa.ps1 -DaysBack {days_back} | ConvertTo-Json -Depth 8 -Compress";
const WINDOWS_LATEST_ZIP_CMD: &str = 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";
#[derive(Debug, Parser)]
#[command(
about = "Run Windows EVTX export and Hayabusa intake directly from aw-server, without the laptop"
)]
struct Cli {
#[arg(
long,
default_value = "/opt/activitywatch/aw-rus-ops/ansible/inventory.ini"
)]
inventory: PathBuf,
#[arg(long, default_value = "/opt/activitywatch/aw-rus-ops/venv/bin/ansible")]
ansible_bin: PathBuf,
#[arg(long, default_value = "/opt/activitywatch/aw-rus-ops/drop")]
drop_dir: PathBuf,
#[arg(long, default_value_t = 1)]
days_back: i64,
#[arg(long, default_value = "incident", value_parser = ["quick", "incident", "full"])]
mode: String,
#[arg(long)]
case_id: Option<i64>,
#[arg(long, default_value = "aw-rus-ops-from-windows")]
link_source: String,
#[arg(long, default_value = "aw_windows")]
windows_group: String,
#[arg(long, default_value = "/usr/local/bin/aw-hayabusa")]
wrapper: PathBuf,
#[arg(long, default_value = "/usr/local/bin/aw-hayabusa-link-case")]
linker: PathBuf,
}
fn main() {
if let Err(err) = run() {
eprintln!("{err:#}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
ensure_file(&cli.inventory, "inventory")?;
ensure_file(&cli.ansible_bin, "ansible binary")?;
ensure_file(&cli.wrapper, "wrapper")?;
fs::create_dir_all(&cli.drop_dir)
.with_context(|| format!("create {}", cli.drop_dir.display()))?;
let export_arg = WINDOWS_EXPORT_CMD.replace("{days_back}", &cli.days_back.to_string());
let export_cmd = vec![
cli.windows_group.as_str(),
"-i",
path_str(&cli.inventory)?,
"-m",
"win_shell",
"-a",
export_arg.as_str(),
];
print_run("RUN_EXPORT", &cli.ansible_bin, &export_cmd);
let export_out = run_capture(&cli.ansible_bin, &export_cmd)?;
let export_json = extract_json_blob(&export_out)?;
let list_cmd = vec![
cli.windows_group.as_str(),
"-i",
path_str(&cli.inventory)?,
"-m",
"win_shell",
"-a",
WINDOWS_LATEST_ZIP_CMD,
];
print_run("RUN_LIST", &cli.ansible_bin, &list_cmd);
let latest_out = run_capture(&cli.ansible_bin, &list_cmd)?;
let latest = normalize_latest_zip(extract_json_blob(&latest_out)?)?;
let remote_zip = latest
.get("FullName")
.and_then(Value::as_str)
.context("latest zip FullName missing")?;
let filename = windows_filename(remote_zip);
let local_zip = cli.drop_dir.join(&filename);
let remote_zip_posix = remote_zip.replace('\\', "/");
let fetch_arg = format!(
"src={} dest={}/ flat=yes",
remote_zip_posix,
cli.drop_dir.display()
);
let fetch_cmd = vec![
cli.windows_group.as_str(),
"-i",
path_str(&cli.inventory)?,
"-m",
"fetch",
"-a",
fetch_arg.as_str(),
];
print_run("RUN_FETCH", &cli.ansible_bin, &fetch_cmd);
run_checked(&cli.ansible_bin, &fetch_cmd)?;
if !local_zip.is_file() {
bail!("fetched zip not found: {}", local_zip.display());
}
let host = export_json
.get("hostname")
.and_then(Value::as_str)
.map(str::to_string)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| guess_host_from_filename(&filename));
let mut accept_args = vec![
"accept".to_string(),
"--package".to_string(),
local_zip.display().to_string(),
];
if !host.is_empty() {
accept_args.extend(["--host".to_string(), host]);
}
print_run_owned("RUN_ACCEPT", &cli.wrapper, &accept_args);
run_checked_owned(&cli.wrapper, &accept_args)?;
let process_args = vec![
"process-inbox".to_string(),
"--mode".to_string(),
cli.mode.clone(),
"--limit".to_string(),
"1".to_string(),
];
print_run_owned("RUN_PROCESS", &cli.wrapper, &process_args);
run_checked_owned(&cli.wrapper, &process_args)?;
if let Some(case_id) = cli.case_id {
ensure_file(&cli.linker, "linker")?;
let link_args = vec![
"--case-id".to_string(),
case_id.to_string(),
"--mode".to_string(),
cli.mode,
"--link-source".to_string(),
cli.link_source,
];
print_run_owned("RUN_LINK", &cli.linker, &link_args);
run_checked_owned(&cli.linker, &link_args)?;
}
let latest_intake = Path::new("/opt/hayabusa/state/latest-intake.json");
println!("LATEST_INTAKE");
println!(
"{}",
fs::read_to_string(latest_intake).context("read latest intake")?
);
Ok(())
}
fn ensure_file(path: &Path, label: &str) -> Result<()> {
if !path.is_file() {
bail!("{label} not found: {}", path.display());
}
Ok(())
}
fn path_str(path: &Path) -> Result<&str> {
path.to_str()
.with_context(|| format!("path is not UTF-8: {}", path.display()))
}
fn run_capture(program: &Path, args: &[&str]) -> Result<String> {
let output = Command::new(program)
.args(args)
.output()
.with_context(|| format!("run {}", program.display()))?;
if !output.status.success() {
print!("{}", String::from_utf8_lossy(&output.stdout));
eprint!("{}", String::from_utf8_lossy(&output.stderr));
std::process::exit(output.status.code().unwrap_or(1));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn run_checked(program: &Path, args: &[&str]) -> Result<()> {
let status = Command::new(program)
.args(args)
.status()
.with_context(|| format!("run {}", program.display()))?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
fn run_checked_owned(program: &Path, args: &[String]) -> Result<()> {
let status = Command::new(program)
.args(args)
.status()
.with_context(|| format!("run {}", program.display()))?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
fn print_run(label: &str, program: &Path, args: &[&str]) {
println!("{label} {} {}", program.display(), args.join(" "));
}
fn print_run_owned(label: &str, program: &Path, args: &[String]) {
println!("{label} {} {}", program.display(), args.join(" "));
}
fn extract_json_blob(text: &str) -> Result<Value> {
let re = Regex::new(r"(?s)(\{.*\}|\[.*\])").context("compile JSON extractor")?;
let matches = re.find_iter(text).collect::<Vec<_>>();
for candidate in matches.iter().rev().map(|item| item.as_str()) {
if let Ok(value) = serde_json::from_str::<Value>(candidate) {
return Ok(value);
}
}
bail!("cannot parse JSON from ansible output:\n{text}")
}
fn normalize_latest_zip(value: Value) -> Result<Value> {
if let Some(items) = value.as_array() {
items.first().cloned().context("latest zip list is empty")
} else {
Ok(value)
}
}
@@ -0,0 +1,55 @@
use anyhow::Result;
use clap::Parser;
use hayabusa_tools::{build_hayabusa_payload, http_client, link_hayabusa_to_case, read_json_file};
use serde_json::json;
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(about = "Link Hayabusa intake metadata to AW-rus case management")]
struct Cli {
#[arg(long)]
case_id: i64,
#[arg(long, default_value = "/opt/hayabusa/state/latest-intake.json")]
intake_json: PathBuf,
#[arg(long, default_value = "http://127.0.0.1:5602")]
case_api_base: String,
#[arg(long, default_value = "incident")]
mode: String,
#[arg(long, default_value = "aw-rus-ops")]
link_source: String,
}
fn main() {
if let Err(err) = run() {
eprintln!("{err:#}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
let intake = read_json_file(&cli.intake_json)?;
let client = http_client()?;
let case = link_hayabusa_to_case(
&client,
&cli.case_api_base,
cli.case_id,
&intake,
&cli.mode,
&cli.link_source,
)?;
let _payload = build_hayabusa_payload(&intake, &cli.mode, &cli.link_source)?;
println!(
"{}",
serde_json::to_string_pretty(&json!({
"case_id": cli.case_id,
"intake": intake,
"forensics": case.get("forensics").cloned().unwrap_or(serde_json::Value::Null),
}))?
);
Ok(())
}