|
|
|
@@ -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<f64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
users: BTreeMap<String, UserBaseline>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
departments: BTreeMap<String, DepartmentBaseline>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
|
|
|
|
struct UserBaseline {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
user_id: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
user: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
samples: Vec<BaselineSample>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
|
|
|
|
struct DepartmentBaseline {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
name: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
samples: Vec<BaselineSample>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
|
|
|
|
struct BaselineSample {
|
|
|
|
|
date: String,
|
|
|
|
|
index: f64,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
active_seconds: Option<i64>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
users_count: Option<i64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
struct CurrentBaselinePoint {
|
|
|
|
|
key: String,
|
|
|
|
|
label: String,
|
|
|
|
|
index: f64,
|
|
|
|
|
active_seconds: Option<i64>,
|
|
|
|
|
users_count: Option<i64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<i32> {
|
|
|
|
|
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<String>) {
|
|
|
|
|
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::<UebaBaselineState>(&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::<usize>();
|
|
|
|
|
let department_samples = state
|
|
|
|
|
.departments
|
|
|
|
|
.values()
|
|
|
|
|
.map(|item| item.samples.len())
|
|
|
|
|
.sum::<usize>();
|
|
|
|
|
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::<String>())
|
|
|
|
|
.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> {
|
|
|
|
|
NaiveDate::parse_from_str(value.get(0..10).unwrap_or(value), "%Y-%m-%d").ok()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn current_user_baseline_points(snapshot: &Snapshot) -> Vec<CurrentBaselinePoint> {
|
|
|
|
|
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<CurrentBaselinePoint> {
|
|
|
|
|
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<i64> {
|
|
|
|
|
let mut parts = value.split(':');
|
|
|
|
|
let hours = parts.next()?.parse::<i64>().ok()?;
|
|
|
|
|
let minutes = parts.next()?.parse::<i64>().ok()?;
|
|
|
|
|
Some((hours * 3600 + minutes * 60).max(0))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn baseline_deviations_for_users(
|
|
|
|
|
state: &UebaBaselineState,
|
|
|
|
|
points: &[CurrentBaselinePoint],
|
|
|
|
|
) -> Vec<BaselineDeviation> {
|
|
|
|
|
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<BaselineDeviation> {
|
|
|
|
|
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<BaselineDeviation> {
|
|
|
|
|
if samples.len() < UEBA_BASELINE_MIN_SAMPLES {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let mean = samples.iter().map(|sample| sample.index).sum::<f64>() / 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::<u64>()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<Value> {
|
|
|
|
|
let mut items = user_deviations
|
|
|
|
|
.iter()
|
|
|
|
|
.chain(department_deviations.iter())
|
|
|
|
|
.cloned()
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
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<BaselineSample>, 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<Value> {
|
|
|
|
@@ -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::<Vec<_>>()
|
|
|
|
|
.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 {
|
|
|
|
|