use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use chrono::{DateTime, Utc}; use reqwest::blocking::Client; use reqwest::header::{ACCEPT, CONTENT_TYPE}; use serde::Serialize; use serde_json::{Value, json}; pub const SEVERITY_ORDER: &[(&str, i64)] = &[("low", 1), ("medium", 2), ("high", 3), ("critical", 4)]; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct Summary { pub severity: String, pub score: i64, pub events_total: usize, pub level_counts: BTreeMap, pub top_rules: Vec, pub first_timestamp: Option, pub last_timestamp: Option, pub failed_logon_rows: usize, pub successful_logon_rows: usize, pub suspicious_pwsh: usize, pub credential_events: usize, pub timestomp_events: usize, pub logon_failure_events: usize, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TopRule { pub title: String, pub count: usize, } pub fn http_client() -> Result { Client::builder() .timeout(Duration::from_secs(30)) .no_proxy() .build() .context("build HTTP client") } pub fn get_json(client: &Client, url: &str) -> Result { client .get(url) .header(ACCEPT, "application/json") .send() .with_context(|| format!("GET {url}"))? .error_for_status() .with_context(|| format!("GET {url} status"))? .json::() .with_context(|| format!("decode JSON from {url}")) } pub fn post_json(client: &Client, url: &str, payload: &Value) -> Result { let response = client .post(url) .header(ACCEPT, "application/json") .header(CONTENT_TYPE, "application/json") .json(payload) .send() .with_context(|| format!("POST {url}"))? .error_for_status() .with_context(|| format!("POST {url} status"))?; decode_optional_json(response.text().context("read POST response")?) } pub fn patch_json(client: &Client, url: &str, payload: &Value) -> Result { let response = client .patch(url) .header(ACCEPT, "application/json") .header(CONTENT_TYPE, "application/json") .json(payload) .send() .with_context(|| format!("PATCH {url}"))? .error_for_status() .with_context(|| format!("PATCH {url} status"))?; decode_optional_json(response.text().context("read PATCH response")?) } fn decode_optional_json(body: String) -> Result { if body.trim().is_empty() { Ok(json!({})) } else { serde_json::from_str(&body).context("decode JSON response") } } pub fn read_json_file(path: &Path) -> Result { let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; serde_json::from_str(&text).with_context(|| format!("decode {}", path.display())) } pub fn write_json_pretty(value: &Value) -> Result { serde_json::to_string_pretty(value).context("encode JSON") } pub fn normalize_host(value: Option<&str>) -> String { value.unwrap_or("").trim().to_lowercase() } pub fn normalize_case_api_base(raw: &str) -> String { let mut base = raw.trim().trim_end_matches('/').to_string(); if base.ends_with("/api/0/dlp/cases") { let cut = base.len() - "/api/0/dlp/cases".len(); base.truncate(cut); } base } pub fn build_hayabusa_payload(intake: &Value, mode: &str, link_source: &str) -> Result { let report_dir = required_str(intake, "report_dir")?; let report_dir_path = PathBuf::from(report_dir); Ok(json!({ "tool": "hayabusa", "host": required_str(intake, "host")?, "mode": mode, "status": required_str(intake, "status")?, "intake_id": required_str(intake, "intake_id")?, "package_path": required_str(intake, "package_path")?, "sha256": required_str(intake, "sha256")?, "report_dir": report_dir, "summary_html": report_dir_path.join("summary.html").to_string_lossy(), "timeline_path": report_dir_path.join("timeline.jsonl").to_string_lossy(), "manifest_path": report_dir_path.join("manifest.json").to_string_lossy(), "link_source": link_source, })) } pub fn link_hayabusa_to_case( client: &Client, case_api_base: &str, case_id: i64, intake: &Value, mode: &str, link_source: &str, ) -> Result { let case_api_base = normalize_case_api_base(case_api_base); let case = get_json( client, &format!("{case_api_base}/api/0/dlp/cases/{case_id}"), )?; let case_host = normalize_host(case.get("host").and_then(Value::as_str)); let intake_host = normalize_host(intake.get("host").and_then(Value::as_str)); if !case_host.is_empty() && !intake_host.is_empty() && case_host != intake_host { bail!( "hayabusa host mismatch: case host={} intake host={}", case.get("host").and_then(Value::as_str).unwrap_or(""), intake.get("host").and_then(Value::as_str).unwrap_or("") ); } let payload = build_hayabusa_payload(intake, mode, link_source)?; post_json( client, &format!("{case_api_base}/api/0/dlp/cases/{case_id}/forensics/hayabusa"), &payload, )?; get_json( client, &format!("{case_api_base}/api/0/dlp/cases/{case_id}"), ) } pub fn analyze_report(report_dir: &Path) -> Result { let timeline_path = report_dir.join("timeline.jsonl"); let mut level_counts: HashMap = HashMap::new(); let mut title_counts: HashMap = HashMap::new(); let mut first_ts: Option> = None; let mut last_ts: Option> = None; let mut total_events = 0usize; if timeline_path.is_file() { let text = fs::read_to_string(&timeline_path) .with_context(|| format!("read {}", timeline_path.display()))?; for raw_line in text.lines() { let line = raw_line.trim(); if line.is_empty() { continue; } let Ok(event) = serde_json::from_str::(line) else { continue; }; total_events += 1; let level = normalize_level(event.get("Level").and_then(Value::as_str)); *level_counts.entry(level).or_insert(0) += 1; let title = event .get("RuleTitle") .and_then(Value::as_str) .map(str::trim) .filter(|title| !title.is_empty()) .unwrap_or("Unknown rule") .to_string(); let next_index = title_counts.len(); let entry = title_counts.entry(title).or_insert((0, next_index)); entry.0 += 1; if let Some(ts) = event .get("Timestamp") .and_then(Value::as_str) .and_then(parse_ts) { first_ts = Some(first_ts.map_or(ts, |old| old.min(ts))); last_ts = Some(last_ts.map_or(ts, |old| old.max(ts))); } } } let failed_logons = read_csv_rows(&report_dir.join("logon-summary-failed.csv"))?; let successful_logons = read_csv_rows(&report_dir.join("logon-summary-successful.csv"))?; let suspicious_pwsh = title_counts .iter() .filter(|(title, _)| { let text = title.to_lowercase(); text.contains("pwsh") || text.contains("powershell") || text.contains("obfuscation") }) .map(|(_, (count, _))| *count) .sum(); let credential_events = title_counts .iter() .filter(|(title, _)| title.to_lowercase().contains("credential")) .map(|(_, (count, _))| *count) .sum(); let timestomp_events = title_counts .iter() .filter(|(title, _)| title.to_lowercase().contains("timestomp")) .map(|(_, (count, _))| *count) .sum(); let logon_failure_events = title_counts .iter() .filter(|(title, _)| title.to_lowercase().contains("logon failure")) .map(|(_, (count, _))| *count) .sum(); let score = level_counts .iter() .map(|(level, count)| level_weight(level) * (*count as i64)) .sum::() + (failed_logons.min(200) as i64) * 2 + (suspicious_pwsh as i64) * 6 + (credential_events as i64) * 8 + (timestomp_events as i64) * 12 + (logon_failure_events as i64) * 2; let crit_count = *level_counts.get("crit").unwrap_or(&0); let high_count = *level_counts.get("high").unwrap_or(&0); let med_count = *level_counts.get("med").unwrap_or(&0); let severity = if crit_count >= 1 || score >= 240 || (high_count >= 4 && suspicious_pwsh >= 4) { "critical" } else if high_count >= 1 || score >= 120 || suspicious_pwsh >= 8 || credential_events >= 5 { "high" } else if med_count >= 1 || score >= 40 || failed_logons >= 10 { "medium" } else { "low" } .to_string(); Ok(Summary { severity, score, events_total: total_events, level_counts: sort_map(level_counts), top_rules: top_rules(title_counts), first_timestamp: first_ts.map(format_ts), last_timestamp: last_ts.map(format_ts), failed_logon_rows: failed_logons, successful_logon_rows: successful_logons, suspicious_pwsh, credential_events, timestomp_events, logon_failure_events, }) } pub fn build_case_payload(intake: &Value, summary: &Summary) -> Result { Ok(json!({ "incident_id": format!("hayabusa:{}:{}", required_str(intake, "host")?, required_str(intake, "intake_id")?), "host": required_str(intake, "host")?, "title": build_case_title(required_str(intake, "host")?, summary), "severity": summary.severity, "evidence": { "hayabusa": { "intake_id": required_str(intake, "intake_id")?, "package_path": required_str(intake, "package_path")?, "sha256": required_str(intake, "sha256")?, "report_dir": required_str(intake, "report_dir")?, "summary": serde_json::to_value(summary)?, } } })) } pub fn build_case_title(host: &str, summary: &Summary) -> String { let suffix = summary .top_rules .first() .map(|item| item.title.as_str()) .unwrap_or("No dominant rule"); format!( "Hayabusa {} · {host} · {suffix}", summary.severity.to_uppercase() ) } pub fn build_comment(summary: &Summary, intake: &Value) -> Result { let top = summary .top_rules .iter() .take(3) .map(|item| format!("{} ({})", item.title, item.count)) .collect::>() .join(", "); Ok(format!( "Hayabusa auto-summary\nSeverity: {} (score={})\nHost: {}\nIntake: {}\nEvents: {}, failed_logons={}, suspicious_pwsh={}, credential_events={}\nTop rules: {}\nReport: {}", summary.severity, summary.score, required_str(intake, "host")?, required_str(intake, "intake_id")?, summary.events_total, summary.failed_logon_rows, summary.suspicious_pwsh, summary.credential_events, if top.is_empty() { "n/a".to_string() } else { top }, required_str(intake, "report_dir")?, )) } pub fn build_telegram_text( case_id: Option, intake: &Value, summary: &Summary, ) -> Result { let severity_label = match summary.severity.as_str() { "critical" => "критичное событие", "high" => "опасное событие", "medium" => "подозрительное событие", "low" => "слабый сигнал", other => other, }; let top_rule = summary .top_rules .first() .map(|item| item.title.as_str()) .unwrap_or("нет явного доминирующего правила"); let top_count = summary .top_rules .first() .map(|item| item.count) .unwrap_or(0); let mut lines = vec![ format!("Hayabusa: {severity_label}"), String::new(), format!("Хост: {}", required_str(intake, "host")?), ]; if let Some(case_id) = case_id { lines.push(format!("Кейс: {case_id}")); } lines.extend([ format!("Уровень: {}", summary.severity), String::new(), "Что найдено:".to_string(), format!("- {top_rule}: {top_count}"), format!("- подозрительный PowerShell: {}", summary.suspicious_pwsh), format!("- ошибок входа: {}", summary.logon_failure_events), format!("- событий по учётным данным: {}", summary.credential_events), ]); if summary.timestomp_events > 0 { lines.push(format!( "- timestomp-подобных событий: {}", summary.timestomp_events )); } lines.extend([ String::new(), "Главный риск:".to_string(), "возможная активность вокруг учётных данных и PowerShell".to_string(), String::new(), "Отчёт:".to_string(), required_str(intake, "report_dir")?.to_string(), ]); Ok(lines.join("\n")) } pub fn severity_meets(actual: &str, threshold: &str) -> bool { severity_order(actual) >= severity_order(threshold) } pub fn env_bool(name: &str, default: bool) -> bool { match std::env::var(name) { Ok(value) => matches!( value.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "on" ), Err(_) => default, } } pub fn env_string(name: &str, default: &str) -> String { std::env::var(name) .ok() .filter(|value| !value.is_empty()) .unwrap_or_else(|| default.to_string()) } pub fn required_str<'a>(value: &'a Value, key: &str) -> Result<&'a str> { value .get(key) .and_then(Value::as_str) .ok_or_else(|| anyhow!("{key} missing")) } pub fn windows_filename(path: &str) -> String { path.rsplit(['\\', '/']) .next() .filter(|name| !name.is_empty()) .unwrap_or(path) .to_string() } pub fn guess_host_from_filename(filename: &str) -> String { let stem = filename.strip_suffix(".zip").unwrap_or(filename); stem.split_once('-') .map(|(host, _)| host) .unwrap_or(stem) .to_string() } fn read_csv_rows(path: &Path) -> Result { if !path.is_file() { return Ok(0); } let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; let rows = text.lines().count(); Ok(rows.saturating_sub(1)) } fn normalize_level(level: Option<&str>) -> String { match level.unwrap_or("").trim().to_lowercase().as_str() { "" => "info", "informational" | "info" => "info", "low" => "low", "med" | "medium" => "med", "high" => "high", "crit" | "critical" => "crit", other => other, } .to_string() } fn level_weight(level: &str) -> i64 { match level { "info" => 1, "low" => 4, "med" => 12, "high" => 40, "crit" => 100, _ => 0, } } fn severity_order(value: &str) -> i64 { SEVERITY_ORDER .iter() .find_map(|(name, order)| (*name == value).then_some(*order)) .unwrap_or(0) } fn parse_ts(value: &str) -> Option> { DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00")) .map(|ts| ts.with_timezone(&Utc)) .ok() } fn format_ts(value: DateTime) -> String { value.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true) } fn sort_map(values: HashMap) -> BTreeMap { values.into_iter().collect() } fn top_rules(values: HashMap) -> Vec { let mut rows = values.into_iter().collect::>(); rows.sort_by(|a, b| b.1.0.cmp(&a.1.0).then_with(|| a.1.1.cmp(&b.1.1))); rows.into_iter() .take(5) .map(|(title, (count, _))| TopRule { title, count }) .collect() } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn severity_rules_match_expected_thresholds() { assert!(severity_meets("high", "medium")); assert!(!severity_meets("low", "medium")); } #[test] fn analyzes_timeline_and_csv_counts() { let dir = tempdir().unwrap(); fs::write( dir.path().join("timeline.jsonl"), r#"{"Level":"high","RuleTitle":"PowerShell Credential Dump","Timestamp":"2026-06-01T00:00:00Z"}"#, ) .unwrap(); fs::write(dir.path().join("logon-summary-failed.csv"), "h\n1\n2\n").unwrap(); let summary = analyze_report(dir.path()).unwrap(); assert_eq!(summary.severity, "high"); assert_eq!(summary.events_total, 1); assert_eq!(summary.failed_logon_rows, 2); assert_eq!(summary.credential_events, 1); assert_eq!(summary.suspicious_pwsh, 1); } }