use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; use std::time::Duration; use anyhow::{Context, Result, anyhow}; use chrono::{DateTime, Datelike, SecondsFormat, TimeZone, Utc}; use clap::Parser; use reqwest::blocking::Client; use reqwest::header::ACCEPT; use serde::Serialize; use serde_json::{Value, json}; use urlencoding::encode; #[derive(Debug, Parser)] #[command(about = "Generate DLP compliance reports from AW DLP incidents")] struct Cli { #[arg(long)] month: Option, #[arg(long)] profile: Option, #[arg(long, default_value = "152-fz,pci-dss")] profiles: String, #[arg(long)] stdout_json: bool, } #[derive(Debug, Clone)] struct Config { aw_api_base: String, output_dir: PathBuf, base_dir: PathBuf, template_152fz: PathBuf, } #[derive(Debug, Clone, Default, PartialEq, Eq)] struct ReportStats { total_incidents: usize, high: usize, medium: usize, low: usize, by_host: BTreeMap, channels: BTreeMap, } #[derive(Debug, Serialize)] struct Metadata { profile: String, period: String, generated_at: String, aw_api_base: String, report_path: String, stats: MetadataStats, } #[derive(Debug, Serialize)] struct MetadataStats { total_incidents: usize, high: usize, medium: usize, low: usize, } fn main() { if let Err(err) = run() { eprintln!("{err:#}"); std::process::exit(1); } } fn run() -> Result<()> { let cli = Cli::parse(); let config = Config::from_env()?; let client = Client::builder() .timeout(Duration::from_secs(30)) .no_proxy() .build() .context("build HTTP client")?; let profiles = if let Some(profile) = cli.profile.as_deref() { vec![profile.to_string()] } else { cli.profiles .split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(ToOwned::to_owned) .collect::>() }; let mut items = Vec::new(); for profile in profiles { items.push(generate_report( &client, &config, cli.month.as_deref(), &profile, )?); } if cli.stdout_json { if cli.profile.is_some() && items.len() == 1 { println!("{}", serde_json::to_string(&items[0])?); } else { println!("{}", serde_json::to_string(&json!({"items": items}))?); } } Ok(()) } impl Config { fn from_env() -> Result { let aw_raw = env_string("AW_DLP_AW_API_BASE") .or_else(|| env_string("AW_SERVER_URL")) .unwrap_or_else(|| "http://127.0.0.1:5600".to_string()); let aw_api_base = build_aw_api_base(&aw_raw); let output_dir = env_path("AW_DLP_COMPLIANCE_REPORT_DIR") .unwrap_or_else(|| PathBuf::from("/opt/activitywatch/dlp-compliance/reports")); let base_dir = env_path("AW_DLP_COMPLIANCE_BASE_DIR") .unwrap_or_else(|| PathBuf::from("/opt/activitywatch/dlp-compliance")); let template_152fz = env_path("AW_DLP_COMPLIANCE_TEMPLATE") .unwrap_or_else(|| base_dir.join("templates/152-fz-report.html")); Ok(Self { aw_api_base, output_dir, base_dir, template_152fz, }) } } fn generate_report( client: &Client, config: &Config, month: Option<&str>, profile: &str, ) -> Result { let (start, end, period_label) = period_bounds(month)?; let incidents = load_incidents(client, config, start, end)?; let stats = build_stats(&incidents); fs::create_dir_all(&config.output_dir) .with_context(|| format!("create output dir {}", config.output_dir.display()))?; let generated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true); let html_path = config .output_dir .join(format!("{profile}-{period_label}.html")); let html = render_html(config, profile, &period_label, &stats, &generated_at)?; fs::write(&html_path, html).with_context(|| format!("write {}", html_path.display()))?; let metadata = Metadata { profile: profile.to_string(), period: period_label.clone(), generated_at, aw_api_base: config.aw_api_base.clone(), report_path: html_path.to_string_lossy().to_string(), stats: MetadataStats { total_incidents: stats.total_incidents, high: stats.high, medium: stats.medium, low: stats.low, }, }; let json_path = config .output_dir .join(format!("{profile}-{period_label}.json")); fs::write(&json_path, serde_json::to_string_pretty(&metadata)?) .with_context(|| format!("write {}", json_path.display()))?; Ok(metadata) } fn build_aw_api_base(raw_url: &str) -> String { let url = raw_url.trim().trim_end_matches('/').to_string(); if url.ends_with("/api/0") { url } else { format!("{url}/api/0") } } fn load_incidents( client: &Client, config: &Config, start: DateTime, end: DateTime, ) -> Result> { let buckets = http_json(client, &format!("{}/buckets", config.aw_api_base))?; let Some(map) = buckets.as_object() else { return Ok(Vec::new()); }; let mut bucket_ids = map .keys() .filter(|bucket_id| bucket_id.starts_with("aw-dlp-incidents_")) .cloned() .collect::>(); bucket_ids.sort(); let mut incidents = Vec::new(); for bucket_id in bucket_ids { let encoded = encode(&bucket_id); let url = format!("{}/buckets/{encoded}/events?limit=2000", config.aw_api_base); let events = http_json(client, &url)?; let Some(events) = events.as_array() else { continue; }; for event in events { let Some(ts) = event .get("timestamp") .and_then(Value::as_str) .and_then(parse_ts) else { continue; }; if ts < start || ts > end { continue; } incidents.push(event.clone()); } } Ok(incidents) } fn http_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}")) } fn build_stats(incidents: &[Value]) -> ReportStats { let mut by_host = BTreeMap::new(); let mut channels = BTreeMap::new(); let mut high = 0; let mut medium = 0; let mut low = 0; for event in incidents { let data = event.get("data").and_then(Value::as_object); let host = data .and_then(|item| item.get("hostname")) .and_then(Value::as_str) .unwrap_or("unknown") .to_string(); *by_host.entry(host).or_insert(0) += 1; match data .and_then(|item| item.get("severity")) .and_then(Value::as_str) .unwrap_or("low") .to_lowercase() .as_str() { "high" => high += 1, "medium" => medium += 1, _ => low += 1, } let channel = data .and_then(|item| item.get("signalType").or_else(|| item.get("source"))) .and_then(Value::as_str) .unwrap_or("unknown") .to_string(); *channels.entry(channel).or_insert(0) += 1; } ReportStats { total_incidents: incidents.len(), high, medium, low, by_host: sort_counts_desc(by_host), channels: sort_counts_desc(channels), } } fn sort_counts_desc(values: BTreeMap) -> BTreeMap { let mut rows = values.into_iter().collect::>(); rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); rows.into_iter().collect() } fn render_html( config: &Config, profile: &str, period_label: &str, stats: &ReportStats, generated_at: &str, ) -> Result { let template_path = resolve_template_path(config, profile); let template = fs::read_to_string(&template_path) .with_context(|| format!("read template {}", template_path.display()))?; Ok(template .replace("{{PERIOD}}", period_label) .replace("{{PROFILE}}", profile) .replace("{{GENERATED_AT}}", generated_at) .replace("{{TOTAL}}", &stats.total_incidents.to_string()) .replace("{{HIGH}}", &stats.high.to_string()) .replace("{{MEDIUM}}", &stats.medium.to_string()) .replace("{{LOW}}", &stats.low.to_string()) .replace( "{{HOST_TABLE}}", &render_table("Инциденты по хостам", &stats.by_host), ) .replace( "{{CHANNEL_TABLE}}", &render_table("Инциденты по каналам", &stats.channels), )) } fn resolve_template_path(config: &Config, profile: &str) -> PathBuf { if profile == "152-fz" { config.template_152fz.clone() } else { let specific = config .base_dir .join(format!("templates/{profile}-report.html")); if specific.exists() { specific } else { config.template_152fz.clone() } } } fn render_table(title: &str, rows: &BTreeMap) -> String { if rows.is_empty() { return format!("

{title}

Нет данных

"); } let body = rows .iter() .map(|(name, count)| format!("{name}{count}")) .collect::(); format!( "

{title}

{body}
ПараметрЗначение
" ) } fn period_bounds(month: Option<&str>) -> Result<(DateTime, DateTime, String)> { let start = if let Some(month) = month { let (year, month) = month .split_once('-') .ok_or_else(|| anyhow!("month must be YYYY-MM"))?; Utc.with_ymd_and_hms(year.parse()?, month.parse()?, 1, 0, 0, 0) .single() .ok_or_else(|| anyhow!("invalid month"))? } else { let now = Utc::now(); Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0) .single() .ok_or_else(|| anyhow!("invalid current month"))? }; let end = if start.month() == 12 { Utc.with_ymd_and_hms(start.year() + 1, 1, 1, 0, 0, 0) .single() .ok_or_else(|| anyhow!("invalid end month"))? } else { Utc.with_ymd_and_hms(start.year(), start.month() + 1, 1, 0, 0, 0) .single() .ok_or_else(|| anyhow!("invalid end month"))? }; Ok(( start, end, format!("{:04}-{:02}", start.year(), start.month()), )) } fn parse_ts(value: &str) -> Option> { DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00")) .map(|ts| ts.with_timezone(&Utc)) .ok() } fn env_string(name: &str) -> Option { std::env::var(name).ok().filter(|value| !value.is_empty()) } fn env_path(name: &str) -> Option { env_string(name).map(PathBuf::from) } #[cfg(test)] mod tests { use super::*; #[test] fn aw_api_base_matches_python_contract() { assert_eq!(build_aw_api_base("http://x"), "http://x/api/0"); assert_eq!(build_aw_api_base("http://x/api/0"), "http://x/api/0"); } #[test] fn stats_match_expected_counts() { let incidents = vec![ json!({"data":{"hostname":"a","severity":"high","signalType":"usb"}}), json!({"data":{"hostname":"a","severity":"medium","source":"clipboard"}}), json!({"data":{"hostname":"b","severity":"low"}}), ]; let stats = build_stats(&incidents); assert_eq!(stats.total_incidents, 3); assert_eq!(stats.high, 1); assert_eq!(stats.medium, 1); assert_eq!(stats.low, 1); assert_eq!(stats.by_host.get("a"), Some(&2)); assert_eq!(stats.channels.get("unknown"), Some(&1)); } #[test] fn month_bounds_are_utc_month() { let (start, end, label) = period_bounds(Some("2026-06")).unwrap(); assert_eq!(label, "2026-06"); assert_eq!(start.to_rfc3339(), "2026-06-01T00:00:00+00:00"); assert_eq!(end.to_rfc3339(), "2026-07-01T00:00:00+00:00"); } }