feat(ueba): add per-user and department baseline skeleton

This commit is contained in:
igor04091968
2026-06-03 19:07:46 +03:00
parent c2e2f64c08
commit efd09012d1
5 changed files with 731 additions and 11 deletions
+709 -9
View File
@@ -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 {
@@ -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 `
<section class="card ueba-risk-card">
<div class="section-head">
@@ -411,6 +412,7 @@ function renderUebaRisk(risk) {
<p class="muted">${escapeHtml(risk.note || "Read-only risk score без автоматического воздействия.")}</p>
<p class="muted small">Формула: ${escapeHtml(risk.formula || "sum(reason_points) capped at 100")}.</p>
<p class="muted small">Confidence: ${escapeHtml(confidence)} · sources: ${escapeHtml(sources)} · baseline: ${escapeHtml(risk.baseline_status || "-")} · policy: ${escapeHtml(risk.policy_version || "-")}</p>
<p class="muted small">Baseline window: ${escapeHtml(risk.baseline_window_days || "-")} days · available: ${escapeHtml(baselineReady)} · deviation: ${escapeHtml(risk.deviation_score ?? 0)}</p>
</div>
<span class="badge ${statusClass(risk.status)}">${escapeHtml(risk.level || "unknown")} · ${escapeHtml(risk.score ?? 0)}/100</span>
</div>
+2 -1
View File
@@ -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
+13 -1
View File
@@ -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`;
+5
View File
@@ -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-тестом