diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 8b434df..6bbdbae 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -9,7 +9,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow}; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use chrono::{SecondsFormat, Utc}; +use chrono::{NaiveDate, SecondsFormat, Utc}; use clap::Parser; use reqwest::blocking::Client; use reqwest::header::{CONNECTION, HeaderValue}; @@ -22,6 +22,7 @@ use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; const INDEX_HTML: &str = include_str!("static/index.html"); const APP_CSS: &str = include_str!("static/app.css"); const APP_JS: &str = include_str!("static/app.js"); +const UEBA_BASELINE_MIN_SAMPLES: usize = 3; #[derive(Debug, Parser)] #[command(about = "Read-only DetMir operator/manager/owner web portal")] @@ -437,6 +438,69 @@ struct UebaConfidencePolicy { policy_bonus: Option, } +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +struct UebaBaselineState { + #[serde(default = "default_ueba_baseline_state_version")] + version: String, + #[serde(default = "default_ueba_baseline_window_days")] + baseline_window_days: i64, + #[serde(default)] + updated_at_utc: Option, + #[serde(default)] + users: BTreeMap, + #[serde(default)] + departments: BTreeMap, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +struct UserBaseline { + #[serde(default)] + user_id: String, + #[serde(default)] + user: String, + #[serde(default)] + samples: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +struct DepartmentBaseline { + #[serde(default)] + name: String, + #[serde(default)] + samples: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +struct BaselineSample { + date: String, + index: f64, + #[serde(default)] + active_seconds: Option, + #[serde(default)] + users_count: Option, +} + +#[derive(Debug, Clone)] +struct CurrentBaselinePoint { + key: String, + label: String, + index: f64, + active_seconds: Option, + users_count: Option, +} + +#[derive(Debug, Clone)] +struct BaselineDeviation { + scope: &'static str, + key: String, + label: String, + current_index: f64, + baseline_index: f64, + deviation_pct: f64, + samples: usize, + status: &'static str, +} + #[derive(Debug)] struct WeightedActivity { role: String, @@ -486,10 +550,11 @@ fn run() -> Result { if args.json_smoke { let snapshot = build_snapshot(&args); let incident_state = load_incident_state_best_effort(&args); + let ueba_baseline_path = ueba_baseline_state_path(&args); let smoke = json!({ "health": build_health(&snapshot), "summary": build_summary(&snapshot), - "reports": build_reports(&snapshot, &incident_state, &build_dlp_evidence_response(&args), &args.workforce_policy_path, &args.ueba_policy_path, false), + "reports": build_reports(&snapshot, &incident_state, &build_dlp_evidence_response(&args), &args.workforce_policy_path, &args.ueba_policy_path, &ueba_baseline_path, false), "incidents": build_incidents(&snapshot, &incident_state), "dlp_evidence": build_dlp_evidence_response(&args), }); @@ -570,6 +635,7 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> { let snapshot = build_snapshot(args); let incident_state = load_incident_state_best_effort(args); let evidence = build_dlp_evidence_response(args); + let ueba_baseline_path = ueba_baseline_state_path(args); respond_json( request, &build_reports( @@ -578,6 +644,7 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> { &evidence, &args.workforce_policy_path, &args.ueba_policy_path, + &ueba_baseline_path, anonymize, ), ) @@ -980,6 +1047,7 @@ fn build_reports( evidence: &DlpEvidenceResponse, workforce_policy_path: &Path, ueba_policy_path: &Path, + ueba_baseline_path: &Path, anonymize: bool, ) -> Value { let summary = build_summary(snapshot); @@ -1014,11 +1082,13 @@ fn build_reports( let insight_items = workforce_insight_items(snapshot); let workforce_policy_explain = build_workforce_policy_explain(snapshot, workforce_policy_path, anonymize); + let ueba_baseline = build_ueba_baseline_analysis(snapshot, ueba_baseline_path, anonymize); let ueba_risk = build_ueba_risk( snapshot, &metrics, &workforce_policy_explain, &insight_items, + &ueba_baseline, ueba_policy_path, ); let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0 @@ -1138,6 +1208,7 @@ fn build_reports( } ], "ueba_risk": ueba_risk, + "ueba_baseline": ueba_baseline, "workforce_policy": workforce_policy_explain, "workforce": { "department_comparison": department_items, @@ -1258,12 +1329,457 @@ fn trend_status(trend: &Value) -> String { } } +fn default_ueba_baseline_state_version() -> String { + "ueba-baseline-v1".to_string() +} + +fn default_ueba_baseline_window_days() -> i64 { + 30 +} + +fn ueba_baseline_state_path(args: &Cli) -> PathBuf { + args.state_dir.join("ueba-baseline-state.json") +} + +fn load_ueba_baseline_state(path: &Path) -> (UebaBaselineState, Option) { + if !path.exists() { + return (UebaBaselineState::default(), None); + } + match fs::read_to_string(path) + .with_context(|| format!("read {}", path.display())) + .and_then(|data| { + serde_json::from_str::(&data) + .with_context(|| format!("parse {}", path.display())) + }) { + Ok(mut state) => { + if state.version.is_empty() { + state.version = default_ueba_baseline_state_version(); + } + if state.baseline_window_days <= 0 { + state.baseline_window_days = default_ueba_baseline_window_days(); + } + (state, None) + } + Err(err) => (UebaBaselineState::default(), Some(err.to_string())), + } +} + +fn save_ueba_baseline_state(path: &Path, state: &UebaBaselineState) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, serde_json::to_vec_pretty(state)?) + .with_context(|| format!("write {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("rename {}", path.display()))?; + Ok(()) +} + +fn build_ueba_baseline_analysis(snapshot: &Snapshot, path: &Path, anonymize: bool) -> Value { + let (mut state, load_error) = load_ueba_baseline_state(path); + if state.version.is_empty() { + state.version = default_ueba_baseline_state_version(); + } + if state.baseline_window_days <= 0 { + state.baseline_window_days = default_ueba_baseline_window_days(); + } + let report_date = baseline_report_date(snapshot); + prune_ueba_baseline_state(&mut state, &report_date); + let user_points = current_user_baseline_points(snapshot); + let department_points = current_department_baseline_points(snapshot); + let user_deviations = baseline_deviations_for_users(&state, &user_points); + let department_deviations = baseline_deviations_for_departments(&state, &department_points); + let user_samples = state + .users + .values() + .map(|item| item.samples.len()) + .sum::(); + let department_samples = state + .departments + .values() + .map(|item| item.samples.len()) + .sum::(); + let user_baseline_available = user_deviations + .iter() + .any(|item| item.samples >= UEBA_BASELINE_MIN_SAMPLES); + let department_baseline_available = department_deviations + .iter() + .any(|item| item.samples >= UEBA_BASELINE_MIN_SAMPLES); + let deviation_score = + baseline_deviation_score(&user_deviations, &department_deviations).min(25); + let strongest_deviations = + strongest_baseline_deviations(&user_deviations, &department_deviations, anonymize); + + update_ueba_baseline_state( + &mut state, + &report_date, + &snapshot.generated_at_utc, + &user_points, + &department_points, + ); + let save_error = save_ueba_baseline_state(path, &state) + .err() + .map(|err| err.to_string()); + + json!({ + "version": state.version, + "baseline_status": default_ueba_baseline_status(), + "path": path.display().to_string(), + "report_date": report_date, + "baseline_window_days": state.baseline_window_days, + "minimum_samples": UEBA_BASELINE_MIN_SAMPLES, + "user_baseline_available": user_baseline_available, + "department_baseline_available": department_baseline_available, + "deviation_score": deviation_score, + "baseline_samples": { + "users": user_samples, + "departments": department_samples, + "total": user_samples + department_samples + }, + "current_entities": { + "users": user_points.len(), + "departments": department_points.len() + }, + "strongest_deviations": strongest_deviations, + "state_error": load_error.or(save_error), + "updated": true, + "anonymized": anonymize + }) +} + +fn baseline_report_date(snapshot: &Snapshot) -> String { + snapshot + .worktime + .payload + .as_ref() + .and_then(|payload| payload.get("report_date")) + .and_then(Value::as_str) + .or_else(|| { + snapshot + .worktime_management + .payload + .as_ref() + .and_then(|payload| payload.get("report_date")) + .and_then(Value::as_str) + }) + .map(|value| value.chars().take(10).collect::()) + .unwrap_or_else(|| snapshot.generated_at_utc.chars().take(10).collect()) +} + +fn prune_ueba_baseline_state(state: &mut UebaBaselineState, current_date: &str) { + let Some(current) = parse_baseline_date(current_date) else { + return; + }; + let window = state.baseline_window_days.max(1); + for baseline in state.users.values_mut() { + baseline.samples.retain(|sample| { + parse_baseline_date(&sample.date) + .map(|date| current.signed_duration_since(date).num_days() < window) + .unwrap_or(true) + }); + } + state + .users + .retain(|_, baseline| !baseline.samples.is_empty()); + for baseline in state.departments.values_mut() { + baseline.samples.retain(|sample| { + parse_baseline_date(&sample.date) + .map(|date| current.signed_duration_since(date).num_days() < window) + .unwrap_or(true) + }); + } + state + .departments + .retain(|_, baseline| !baseline.samples.is_empty()); +} + +fn parse_baseline_date(value: &str) -> Option { + NaiveDate::parse_from_str(value.get(0..10).unwrap_or(value), "%Y-%m-%d").ok() +} + +fn current_user_baseline_points(snapshot: &Snapshot) -> Vec { + let Some(rows) = snapshot + .worktime + .payload + .as_ref() + .and_then(|payload| payload.get("rows")) + .and_then(Value::as_array) + else { + return Vec::new(); + }; + rows.iter() + .enumerate() + .map(|(idx, row)| { + let active_seconds = row + .get("active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + .max(0); + let raw_user = row.get("user").and_then(Value::as_str).unwrap_or(""); + let raw_user_id = row + .get("user_id") + .and_then(Value::as_str) + .unwrap_or(raw_user); + let key = if raw_user_id.trim().is_empty() { + format!("row-{}", idx + 1) + } else { + raw_user_id.to_string() + }; + let label = if raw_user.trim().is_empty() { + key.clone() + } else { + raw_user.to_string() + }; + let index = ((active_seconds as f64 / (8.0 * 3600.0)) * 100.0).clamp(0.0, 200.0); + CurrentBaselinePoint { + key, + label, + index, + active_seconds: Some(active_seconds), + users_count: None, + } + }) + .collect() +} + +fn current_department_baseline_points(snapshot: &Snapshot) -> Vec { + snapshot + .worktime_management + .payload + .as_ref() + .and_then(|payload| payload.get("department_rollups")) + .and_then(Value::as_array) + .map(|rows| { + rows.iter() + .filter_map(|row| { + let name = row.get("name").and_then(Value::as_str).unwrap_or(""); + if name.trim().is_empty() { + return None; + } + let index = row + .get("portfolio_coverage_pct") + .and_then(Value::as_f64) + .unwrap_or(0.0) + .clamp(0.0, 200.0); + let active_seconds = hhmm_to_seconds( + row.get("workday_total_active_hhmm") + .and_then(Value::as_str) + .unwrap_or(""), + ); + Some(CurrentBaselinePoint { + key: name.to_string(), + label: name.to_string(), + index, + active_seconds, + users_count: row.get("users_count").and_then(Value::as_i64), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn hhmm_to_seconds(value: &str) -> Option { + let mut parts = value.split(':'); + let hours = parts.next()?.parse::().ok()?; + let minutes = parts.next()?.parse::().ok()?; + Some((hours * 3600 + minutes * 60).max(0)) +} + +fn baseline_deviations_for_users( + state: &UebaBaselineState, + points: &[CurrentBaselinePoint], +) -> Vec { + points + .iter() + .filter_map(|point| { + let baseline = state.users.get(&point.key)?; + baseline_deviation("user", point, &baseline.samples) + }) + .collect() +} + +fn baseline_deviations_for_departments( + state: &UebaBaselineState, + points: &[CurrentBaselinePoint], +) -> Vec { + points + .iter() + .filter_map(|point| { + let baseline = state.departments.get(&point.key)?; + baseline_deviation("department", point, &baseline.samples) + }) + .collect() +} + +fn baseline_deviation( + scope: &'static str, + point: &CurrentBaselinePoint, + samples: &[BaselineSample], +) -> Option { + if samples.len() < UEBA_BASELINE_MIN_SAMPLES { + return None; + } + let mean = samples.iter().map(|sample| sample.index).sum::() / samples.len() as f64; + let deviation_pct = point.index - mean; + let status = if deviation_pct.abs() >= 25.0 { + "WARN" + } else { + "INFO" + }; + Some(BaselineDeviation { + scope, + key: point.key.clone(), + label: point.label.clone(), + current_index: round1(point.index), + baseline_index: round1(mean), + deviation_pct: round1(deviation_pct), + samples: samples.len(), + status, + }) +} + +fn baseline_deviation_score( + user_deviations: &[BaselineDeviation], + department_deviations: &[BaselineDeviation], +) -> u64 { + user_deviations + .iter() + .chain(department_deviations.iter()) + .map(|item| deviation_points(item.deviation_pct)) + .sum::() +} + +fn deviation_points(deviation_pct: f64) -> u64 { + let value = deviation_pct.abs(); + if value >= 40.0 { + 15 + } else if value >= 25.0 { + 10 + } else if value >= 15.0 { + 5 + } else { + 0 + } +} + +fn strongest_baseline_deviations( + user_deviations: &[BaselineDeviation], + department_deviations: &[BaselineDeviation], + anonymize: bool, +) -> Vec { + let mut items = user_deviations + .iter() + .chain(department_deviations.iter()) + .cloned() + .collect::>(); + items.sort_by(|left, right| { + right + .deviation_pct + .abs() + .partial_cmp(&left.deviation_pct.abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + items + .into_iter() + .take(12) + .enumerate() + .map(|(idx, item)| { + let (key, label) = if anonymize && item.scope == "user" { + ( + format!("EMPLOYEE-{}", idx + 1), + format!("Сотрудник {}", idx + 1), + ) + } else if anonymize && item.scope == "department" { + ( + format!("DEPARTMENT-{}", idx + 1), + format!("Подразделение {}", idx + 1), + ) + } else { + (item.key.clone(), item.label.clone()) + }; + json!({ + "scope": item.scope, + "key": key, + "label": label, + "current_index": item.current_index, + "baseline_index": item.baseline_index, + "deviation_pct": item.deviation_pct, + "samples": item.samples, + "status": item.status + }) + }) + .collect() +} + +fn update_ueba_baseline_state( + state: &mut UebaBaselineState, + report_date: &str, + generated_at_utc: &str, + user_points: &[CurrentBaselinePoint], + department_points: &[CurrentBaselinePoint], +) { + state.updated_at_utc = Some(generated_at_utc.to_string()); + for point in user_points { + let entry = state + .users + .entry(point.key.clone()) + .or_insert_with(|| UserBaseline { + user_id: point.key.clone(), + user: point.label.clone(), + samples: Vec::new(), + }); + entry.user = point.label.clone(); + upsert_baseline_sample( + &mut entry.samples, + BaselineSample { + date: report_date.to_string(), + index: round1(point.index), + active_seconds: point.active_seconds, + users_count: None, + }, + ); + } + for point in department_points { + let entry = state + .departments + .entry(point.key.clone()) + .or_insert_with(|| DepartmentBaseline { + name: point.label.clone(), + samples: Vec::new(), + }); + entry.name = point.label.clone(); + upsert_baseline_sample( + &mut entry.samples, + BaselineSample { + date: report_date.to_string(), + index: round1(point.index), + active_seconds: point.active_seconds, + users_count: point.users_count, + }, + ); + } +} + +fn upsert_baseline_sample(samples: &mut Vec, sample: BaselineSample) { + if let Some(existing) = samples.iter_mut().find(|item| item.date == sample.date) { + *existing = sample; + } else { + samples.push(sample); + } + samples.sort_by(|left, right| left.date.cmp(&right.date)); +} + +fn round1(value: f64) -> f64 { + (value * 10.0).round() / 10.0 +} + fn default_ueba_policy_version() -> String { "ueba-rule-v1".to_string() } fn default_ueba_baseline_status() -> String { - "portfolio_only_no_per_user_baseline".to_string() + "per_user_department_baseline_skeleton".to_string() } fn default_ueba_score_cap() -> u64 { @@ -1286,6 +1802,7 @@ fn default_ueba_risk_policy() -> UebaRiskPolicy { ("application_classification_gap".to_string(), 10), ("application_classification_gap_large".to_string(), 15), ("worktime_unavailable".to_string(), 25), + ("baseline_deviation".to_string(), 15), ]), confidence: UebaConfidencePolicy { base: Some(0.55), @@ -1372,6 +1889,7 @@ fn ueba_calculated_from( metrics: &ReportMetrics, workforce_policy: &Value, insight_items: &[Value], + ueba_baseline: &Value, policy_configured: bool, policy_error: Option<&str>, ) -> Vec { @@ -1381,6 +1899,7 @@ fn ueba_calculated_from( json!({"source": "evidence", "available": true, "items": metrics.evidence_total, "screenshots": metrics.evidence_screenshots, "used_as": "confidence"}), json!({"source": "workforce_insights", "available": true, "items": insight_items.len()}), json!({"source": "workforce_policy_audit", "available": workforce_policy.get("configured").and_then(Value::as_bool).unwrap_or(false)}), + json!({"source": "ueba_baseline", "available": ueba_baseline.get("user_baseline_available").and_then(Value::as_bool).unwrap_or(false) || ueba_baseline.get("department_baseline_available").and_then(Value::as_bool).unwrap_or(false), "samples": ueba_baseline.get("baseline_samples").cloned().unwrap_or_else(|| json!({}))}), json!({"source": "ueba_policy", "available": policy_configured, "error": policy_error}), ] } @@ -1390,6 +1909,7 @@ fn build_ueba_risk( metrics: &ReportMetrics, workforce_policy: &Value, insight_items: &[Value], + ueba_baseline: &Value, policy_path: &Path, ) -> Value { let (policy, policy_configured, policy_error) = load_ueba_risk_policy(policy_path); @@ -1518,11 +2038,47 @@ fn build_ueba_risk( "Восстановить Worktime API/collectors перед выводами по сотрудникам.", ); } + let deviation_score = ueba_baseline + .get("deviation_score") + .and_then(Value::as_u64) + .unwrap_or(0); + if deviation_score > 0 { + let deviations = ueba_baseline + .get("strongest_deviations") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .take(3) + .filter_map(|item| { + let label = item.get("label").and_then(Value::as_str)?; + let delta = item.get("deviation_pct").and_then(Value::as_f64)?; + Some(format!("{label}: {delta:+.1}%")) + }) + .collect::>() + .join("; ") + }) + .unwrap_or_default(); + push_risk_reason( + &mut reasons, + &mut score, + ("baseline_deviation", "Отклонение от baseline", "baseline"), + "WARN", + risk_weight(&policy, "baseline_deviation", deviation_score).min(deviation_score), + if deviations.is_empty() { + format!("deviation_score={deviation_score}") + } else { + deviations + }, + "Проверить сотрудника/подразделение относительно обычного профиля активности.", + ); + } let calculated_from = ueba_calculated_from( metrics, workforce_policy, insight_items, + ueba_baseline, policy_configured, policy_error.as_deref(), ); @@ -1538,7 +2094,15 @@ fn build_ueba_risk( "formula": format!("sum(reason_points) capped at {}", policy.score_cap.max(1)), "confidence": confidence, "risk_sources": risk_sources, - "baseline_status": policy.baseline_status, + "baseline_status": ueba_baseline + .get("baseline_status") + .and_then(Value::as_str) + .unwrap_or(&policy.baseline_status), + "baseline_window_days": ueba_baseline.get("baseline_window_days").and_then(Value::as_i64).unwrap_or(default_ueba_baseline_window_days()), + "user_baseline_available": ueba_baseline.get("user_baseline_available").and_then(Value::as_bool).unwrap_or(false), + "department_baseline_available": ueba_baseline.get("department_baseline_available").and_then(Value::as_bool).unwrap_or(false), + "deviation_score": deviation_score, + "baseline_samples": ueba_baseline.get("baseline_samples").cloned().unwrap_or_else(|| json!({})), "policy_version": policy.version, "policy_path": policy_path.display().to_string(), "policy_configured": policy_configured, @@ -2111,6 +2675,27 @@ fn append_ueba_risk_markdown(text: &mut String, risk: &Value) { .and_then(Value::as_str) .unwrap_or("unknown") )); + text.push_str(&format!( + "- Baseline window: {} days\n", + risk.get("baseline_window_days") + .and_then(Value::as_i64) + .unwrap_or(default_ueba_baseline_window_days()) + )); + text.push_str(&format!( + "- Baseline available: user={}, department={}\n", + risk.get("user_baseline_available") + .and_then(Value::as_bool) + .unwrap_or(false), + risk.get("department_baseline_available") + .and_then(Value::as_bool) + .unwrap_or(false) + )); + text.push_str(&format!( + "- Deviation score: {}\n", + risk.get("deviation_score") + .and_then(Value::as_u64) + .unwrap_or(0) + )); text.push_str(&format!( "- Policy version: {}\n", risk.get("policy_version") @@ -4057,14 +4642,17 @@ mod tests { }], error: None, }; - let missing_policy = Path::new("/tmp/detmir-missing-workforce-policy.json"); - let missing_ueba_policy = Path::new("/tmp/detmir-missing-ueba-policy.yaml"); + let dir = tempfile::tempdir().unwrap(); + let missing_policy = dir.path().join("detmir-missing-workforce-policy.json"); + let missing_ueba_policy = dir.path().join("detmir-missing-ueba-policy.yaml"); + let baseline_path = dir.path().join("ueba-baseline-state.json"); let report = build_reports( &snapshot, &IncidentStateFile::default(), &evidence, - missing_policy, - missing_ueba_policy, + &missing_policy, + &missing_ueba_policy, + &baseline_path, false, ); assert_eq!(report["operator_ok"], true); @@ -4086,8 +4674,13 @@ mod tests { assert!(report["ueba_risk"]["confidence"].as_f64().unwrap() > 0.55); assert_eq!( report["ueba_risk"]["baseline_status"], - "portfolio_only_no_per_user_baseline" + "per_user_department_baseline_skeleton" ); + assert_eq!(report["ueba_risk"]["baseline_window_days"], 30); + assert_eq!(report["ueba_risk"]["user_baseline_available"], false); + assert_eq!(report["ueba_risk"]["department_baseline_available"], false); + assert_eq!(report["ueba_risk"]["deviation_score"], 0); + assert!(report["ueba_risk"]["baseline_samples"].is_object()); assert_eq!(report["ueba_risk"]["policy_version"], "ueba-rule-v1"); assert!(report["ueba_risk"]["calculated_from"].is_array()); assert!( @@ -4192,6 +4785,13 @@ confidence: &metrics, &json!({"configured": false}), &[], + &json!({ + "baseline_window_days": 30, + "user_baseline_available": false, + "department_baseline_available": false, + "deviation_score": 0, + "baseline_samples": {"users": 0, "departments": 0, "total": 0} + }), &policy_path, ); assert_eq!(risk["score"], 7); @@ -4204,6 +4804,106 @@ confidence: assert_ne!(risk["reasons"][0]["code"], "evidence_present"); } + #[test] + fn ueba_baseline_accumulates_user_and_department_deviation() { + fn snapshot_for(date: &str, active_seconds: i64, department_coverage: f64) -> Snapshot { + Snapshot { + generated_at_utc: format!("{date}T10:00:00Z"), + detmir_status: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "".to_string(), + error: None, + payload: None, + }, + detmir_check: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "".to_string(), + error: None, + payload: None, + }, + failed_units: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "".to_string(), + error: None, + payload: None, + }, + worktime: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "".to_string(), + error: None, + payload: Some(json!({ + "report_date": date, + "rows": [ + {"user": "USER-1", "user_id": "EMP-1", "active_seconds": active_seconds} + ], + "true_active_apps": [] + })), + }, + worktime_management: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "".to_string(), + error: None, + payload: Some(json!({ + "report_date": date, + "department_rollups": [ + { + "name": "DEPT-1", + "users_count": 1, + "active_users": 1, + "portfolio_coverage_pct": department_coverage, + "workday_total_active_hhmm": "08:00" + } + ] + })), + }, + one_c: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "".to_string(), + error: None, + payload: None, + }, + } + } + + let dir = tempfile::tempdir().unwrap(); + let baseline_path = dir.path().join("ueba-baseline-state.json"); + for day in ["2026-06-01", "2026-06-02", "2026-06-03"] { + let snapshot = snapshot_for(day, 8 * 3600, 90.0); + let analysis = build_ueba_baseline_analysis(&snapshot, &baseline_path, false); + assert_eq!(analysis["state_error"], Value::Null); + } + + let snapshot = snapshot_for("2026-06-04", 2 * 3600, 40.0); + let analysis = build_ueba_baseline_analysis(&snapshot, &baseline_path, false); + assert_eq!(analysis["baseline_window_days"], 30); + assert_eq!(analysis["user_baseline_available"], true); + assert_eq!(analysis["department_baseline_available"], true); + assert!(analysis["deviation_score"].as_u64().unwrap() > 0); + assert_eq!(analysis["baseline_samples"]["users"], 3); + assert_eq!(analysis["baseline_samples"]["departments"], 3); + assert!( + analysis["strongest_deviations"] + .as_array() + .unwrap() + .iter() + .any(|item| item["scope"] == "user") + ); + + let snapshot = snapshot_for("2026-06-05", 3600, 30.0); + let anonymized = build_ueba_baseline_analysis(&snapshot, &baseline_path, true); + let first = &anonymized["strongest_deviations"][0]; + if first["scope"] == "user" { + assert_eq!(first["label"], "Сотрудник 1"); + assert_eq!(first["key"], "EMPLOYEE-1"); + } + } + #[test] fn weighted_activity_uses_role_application_policy() { let snapshot = Snapshot { diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index d12a3c7..06110d0 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -403,6 +403,7 @@ function renderUebaRisk(risk) { const reasons = Array.isArray(risk.reasons) ? risk.reasons.slice(0, 12) : []; const sources = Array.isArray(risk.risk_sources) ? risk.risk_sources.join(", ") : "-"; const confidence = Number.isFinite(Number(risk.confidence)) ? `${Math.round(Number(risk.confidence) * 100)}%` : "0%"; + const baselineReady = `user: ${risk.user_baseline_available ? "yes" : "no"} · dept: ${risk.department_baseline_available ? "yes" : "no"}`; return `
@@ -411,6 +412,7 @@ function renderUebaRisk(risk) {

${escapeHtml(risk.note || "Read-only risk score без автоматического воздействия.")}

Формула: ${escapeHtml(risk.formula || "sum(reason_points) capped at 100")}.

Confidence: ${escapeHtml(confidence)} · sources: ${escapeHtml(sources)} · baseline: ${escapeHtml(risk.baseline_status || "-")} · policy: ${escapeHtml(risk.policy_version || "-")}

+

Baseline window: ${escapeHtml(risk.baseline_window_days || "-")} days · available: ${escapeHtml(baselineReady)} · deviation: ${escapeHtml(risk.deviation_score ?? 0)}

${escapeHtml(risk.level || "unknown")} · ${escapeHtml(risk.score ?? 0)}/100 diff --git a/configs/detmir-ueba-risk-policy.example.yaml b/configs/detmir-ueba-risk-policy.example.yaml index fed05b6..d311ef7 100644 --- a/configs/detmir-ueba-risk-policy.example.yaml +++ b/configs/detmir-ueba-risk-policy.example.yaml @@ -1,5 +1,5 @@ version: "ueba-rule-v1" -baseline_status: "portfolio_only_no_per_user_baseline" +baseline_status: "per_user_department_baseline_skeleton" score_cap: 100 weights: @@ -13,6 +13,7 @@ weights: application_classification_gap: 10 application_classification_gap_large: 15 worktime_unavailable: 25 + baseline_deviation: 15 confidence: base: 0.55 diff --git a/docs/DETMIR_COMMERCIAL_MODULES_RU.md b/docs/DETMIR_COMMERCIAL_MODULES_RU.md index bc7742e..5d261c3 100644 --- a/docs/DETMIR_COMMERCIAL_MODULES_RU.md +++ b/docs/DETMIR_COMMERCIAL_MODULES_RU.md @@ -117,13 +117,25 @@ DetMir Workforce/Security формирует read-only UEBA-compatible rule-base но не добавляют risk score сами по себе; - `risk_sources`: типы источников, которые дали risk reasons; - `baseline_status`: статус baseline-модели, сейчас - `portfolio_only_no_per_user_baseline`; + `per_user_department_baseline_skeleton`; +- `baseline_window_days`: rolling window локальной baseline-истории; +- `user_baseline_available`: есть ли минимум samples для per-user сравнения; +- `department_baseline_available`: есть ли минимум samples для сравнения + подразделений; +- `deviation_score`: score отклонения текущего дня от baseline; +- `baseline_samples`: количество накопленных user/department samples; - `policy_version`: версия risk policy; - `calculated_from`: список источников, участвовавших в расчете; - `reasons`: DLP WARN/FAIL, open review queue, off-hours/weekend insights, просадки/аномалии Workforce, приложения без явного `application_weights` правила. +Baseline skeleton хранится локально в state каталоге портала как +`ueba-baseline-state.json`. Текущий день записывается атомарно по `report_date` +и не дублируется при повторном открытии отчета. Отклонение считается только +после накопления минимального количества исторических samples, поэтому первый +период эксплуатации честно показывает `*_baseline_available=false`. + Веса настраиваются через YAML policy: - пример: `configs/detmir-ueba-risk-policy.example.yaml`; diff --git a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md index 28c1303..2d74002 100644 --- a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md +++ b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md @@ -32,6 +32,8 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`. active applications, DLP WARN/FAIL, evidence screenshots/items, open issues; - отчет содержит read-only UEBA-compatible rule-based risk scoring v1: `score`, `level`, `confidence`, `risk_sources`, `baseline_status`, `policy_version`, + `baseline_window_days`, `user_baseline_available`, + `department_baseline_available`, `deviation_score`, `baseline_samples`, `calculated_from`, `reasons`; - отчет и вкладка `Руководитель` показывают `Индекс активности` как proxy `активное время / плановое рабочее время`; @@ -66,6 +68,9 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`. настраиваются в `/etc/detmir-portal-ueba-policy.yaml`; evidence используется как confidence, а не как отдельный risk reason; слой не выполняет pfSense/NAC/SOAR actions; +- baseline skeleton хранится в state каталоге портала как + `ueba-baseline-state.json`: per-user и per-department samples копятся по + `report_date`, а deviation включается только после минимальной истории; - вкладка `Руководитель` получает этот блок через легкий endpoint `/api/workforce/policy/explain`, без загрузки полного `/api/reports`; - contract легкого endpoint защищен unit-тестом