feat(portal): add UEBA risk scoring

This commit is contained in:
igor04091968
2026-06-03 17:34:21 +03:00
parent 2bf8ecfea8
commit 2cb2260bb2
5 changed files with 318 additions and 2 deletions
+270 -2
View File
@@ -977,6 +977,12 @@ 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_risk = build_ueba_risk(
snapshot,
&metrics,
&workforce_policy_explain,
&insight_items,
);
let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0
{
"Контур DetMir работает штатно, критичных действий не требуется"
@@ -1019,7 +1025,7 @@ fn build_reports(
&metrics,
&recommendations,
&workforce_summary,
&workforce_policy_explain,
(&workforce_policy_explain, &ueba_risk),
);
json!({
"generated_at_utc": snapshot.generated_at_utc,
@@ -1030,6 +1036,7 @@ fn build_reports(
"headline": headline,
"executive_points": executive_points,
"kpis": [
report_kpi("UEBA риск", format!("{}/100", ueba_risk.get("score").and_then(Value::as_u64).unwrap_or(0)), ueba_risk.get("status").and_then(Value::as_str).unwrap_or("UNKNOWN").to_string(), ueba_risk.get("summary").and_then(Value::as_str).unwrap_or("risk score")),
report_kpi("Индекс активности", workforce_index_text(metrics.workforce_index), workforce_index_status(metrics.workforce_index), "proxy: активное время / плановое рабочее время"),
weighted_activity_kpi_from_policy(&workforce_policy_explain),
report_kpi("Сотрудники", metrics.users_count.to_string(), worktime.status.clone(), "строки worktime за сегодня"),
@@ -1066,6 +1073,10 @@ fn build_reports(
"title": "Выводы Workforce",
"items": insight_items.clone()
},
{
"title": "UEBA риск",
"items": ueba_risk.get("reasons").and_then(Value::as_array).cloned().unwrap_or_default()
},
{
"title": "Подразделения сегодня",
"items": department_items.clone()
@@ -1088,6 +1099,7 @@ fn build_reports(
"items": recommendations.iter().map(|item| report_item("Рекомендация", "INFO", item)).collect::<Vec<_>>()
}
],
"ueba_risk": ueba_risk,
"workforce_policy": workforce_policy_explain,
"workforce": {
"department_comparison": department_items,
@@ -1208,6 +1220,197 @@ fn trend_status(trend: &Value) -> String {
}
}
fn build_ueba_risk(
snapshot: &Snapshot,
metrics: &ReportMetrics,
workforce_policy: &Value,
insight_items: &[Value],
) -> Value {
let mut reasons = Vec::new();
let mut score = 0_u64;
if metrics.dlp_fail > 0 {
push_risk_reason(
&mut reasons,
&mut score,
("dlp_fail", "DLP FAIL"),
"FAIL",
35,
format!("fail={}", metrics.dlp_fail),
"Проверить DLP/case queue и evidence.",
);
}
if metrics.dlp_warn > 0 {
push_risk_reason(
&mut reasons,
&mut score,
("dlp_warn", "DLP WARN"),
"WARN",
20,
format!("warn={}", metrics.dlp_warn),
"Разобрать предупреждения DLP и подтвердить/отклонить события.",
);
}
if metrics.open_incidents > 0 {
push_risk_reason(
&mut reasons,
&mut score,
("open_incidents", "Открытые вопросы"),
"WARN",
15,
format!("open={}", metrics.open_incidents),
"Назначить ответственного и закрыть очередь review.",
);
}
if metrics.evidence_total > 0 {
push_risk_reason(
&mut reasons,
&mut score,
("evidence_present", "Есть evidence"),
"INFO",
10,
format!(
"items={}, screenshots={}",
metrics.evidence_total, metrics.evidence_screenshots
),
"Проверить evidence metadata и audit просмотра.",
);
}
for item in insight_items {
let status = item.get("status").and_then(Value::as_str).unwrap_or("INFO");
if status == "OK" || status == "INFO" {
continue;
}
let label = item
.get("label")
.and_then(Value::as_str)
.unwrap_or("Workforce");
let value = item.get("value").and_then(Value::as_str).unwrap_or("");
let text = format!("{label} {value}").to_lowercase();
let (code, title, points) = if text.contains("ноч")
|| text.contains("night")
|| text.contains("вне рабочего")
|| text.contains("off-hours")
{
("night_activity", "Активность вне рабочего окна", 20)
} else if text.contains("выход")
|| text.contains("weekend")
|| text.contains("суббот")
|| text.contains("воскрес")
{
("weekend_activity", "Работа в выходной", 20)
} else if text.contains("просад")
|| text.contains("паден")
|| text.contains("drop")
|| text.contains("недогруз")
{
("workforce_drop", "Отклонение активности", 15)
} else {
("workforce_anomaly", "Workforce anomaly", 10)
};
push_risk_reason(
&mut reasons,
&mut score,
(code, title),
status,
points,
value,
"Проверить первичные события ActivityWatch и контекст подразделения.",
);
}
let default_weight_apps = workforce_policy
.get("policy_audit")
.and_then(|audit| audit.get("default_weight_applications"))
.and_then(Value::as_u64)
.unwrap_or(0);
let default_weight_seconds = workforce_policy
.get("policy_audit")
.and_then(|audit| audit.get("default_weight_seconds"))
.and_then(Value::as_i64)
.unwrap_or(0);
if default_weight_apps > 0 {
push_risk_reason(
&mut reasons,
&mut score,
(
"application_classification_gap",
"Приложения без явного правила",
),
"WARN",
if default_weight_seconds >= 3600 {
15
} else {
10
},
format!(
"default_weight_apps={}, default_weight_time={}",
default_weight_apps,
human_duration(default_weight_seconds)
),
"Уточнить role/application policy, чтобы снизить ошибки классификации.",
);
}
if !snapshot.worktime.ok {
push_risk_reason(
&mut reasons,
&mut score,
("worktime_unavailable", "Нет надежного Worktime"),
"FAIL",
25,
snapshot.worktime.summary.clone(),
"Восстановить Worktime API/collectors перед выводами по сотрудникам.",
);
}
let score = score.min(100);
let (level, status) = ueba_risk_level(score);
json!({
"score": score,
"level": level,
"status": status,
"summary": format!("{} risk, {} reason(s)", level, reasons.len()),
"formula": "sum(reason_points) capped at 100",
"reasons": reasons,
"note": "Read-only UEBA score: мониторинг и приоритизация проверки, без автоматического воздействия на сеть."
})
}
fn push_risk_reason(
reasons: &mut Vec<Value>,
score: &mut u64,
code_title: (&str, &str),
severity: &str,
points: u64,
evidence: impl Into<String>,
recommendation: &str,
) {
*score = score.saturating_add(points);
reasons.push(json!({
"label": code_title.1,
"status": severity,
"value": evidence.into(),
"code": code_title.0,
"severity": severity,
"points": points,
"recommendation": recommendation,
}));
}
fn ueba_risk_level(score: u64) -> (&'static str, &'static str) {
if score >= 70 {
("high", "FAIL")
} else if score >= 40 {
("medium", "WARN")
} else if score >= 15 {
("low", "WARN")
} else {
("normal", "OK")
}
}
fn coverage_status(coverage: f64) -> &'static str {
if coverage >= 75.0 {
"OK"
@@ -1644,8 +1847,9 @@ fn render_report_markdown(
metrics: &ReportMetrics,
recommendations: &[String],
workforce: &ReportWorkforceSummary,
workforce_policy: &Value,
explainability: (&Value, &Value),
) -> String {
let (workforce_policy, ueba_risk) = explainability;
let mut text = String::new();
text.push_str("# DetMir оперативный отчет\n\n");
text.push_str(&format!("Дата снимка: {}\n\n", snapshot.generated_at_utc));
@@ -1698,11 +1902,59 @@ fn render_report_markdown(
for item in recommendations {
text.push_str(&format!("- {item}\n"));
}
append_ueba_risk_markdown(&mut text, ueba_risk);
append_workforce_policy_markdown(&mut text, workforce_policy);
text.push_str("\nПримечание: DLP/case показатели являются derived detections/cases и требуют регламентной валидации перед подачей как подтвержденные инциденты.\n");
text
}
fn append_ueba_risk_markdown(text: &mut String, risk: &Value) {
text.push_str("\n## UEBA риск\n\n");
text.push_str(&format!(
"- Score: {}/100\n",
risk.get("score").and_then(Value::as_u64).unwrap_or(0)
));
text.push_str(&format!(
"- Level: {}\n",
risk.get("level")
.and_then(Value::as_str)
.unwrap_or("unknown")
));
text.push_str(&format!(
"- Formula: {}\n",
risk.get("formula")
.and_then(Value::as_str)
.unwrap_or("sum(reason_points) capped at 100")
));
if let Some(note) = risk.get("note").and_then(Value::as_str) {
text.push_str(&format!("- Note: {note}\n"));
}
text.push_str("\n### Причины риска\n\n");
let reasons = risk
.get("reasons")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if reasons.is_empty() {
text.push_str("- Существенных UEBA-сигналов в текущем срезе нет.\n");
return;
}
for item in reasons.iter().take(12) {
text.push_str(&format!(
"- {}: +{} points, {}, evidence: {}\n",
item.get("label").and_then(Value::as_str).unwrap_or("-"),
item.get("points").and_then(Value::as_u64).unwrap_or(0),
item.get("severity")
.and_then(Value::as_str)
.unwrap_or("INFO"),
item.get("value").and_then(Value::as_str).unwrap_or("-")
));
if let Some(recommendation) = item.get("recommendation").and_then(Value::as_str) {
text.push_str(&format!(" - recommendation: {recommendation}\n"));
}
}
}
fn append_workforce_policy_markdown(text: &mut String, policy: &Value) {
if policy.get("configured").and_then(Value::as_bool) != Some(true) {
text.push_str("\n## Почему такой индекс\n\n");
@@ -3297,6 +3549,9 @@ mod tests {
assert_eq!(normalize_path("/portal/api/health?x=1"), "/api/health");
assert_eq!(normalize_path("/portal/"), "/");
assert_eq!(normalize_path("/api/health"), "/api/health");
assert!(query_flag("/api/reports?anonymize=1", "anonymize"));
assert!(query_flag("/api/reports?anonymize=true", "anonymize"));
assert!(!query_flag("/api/reports?anonymize=0", "anonymize"));
}
#[test]
@@ -3627,6 +3882,19 @@ mod tests {
.contains("derived detections/cases")
);
assert!(report["kpis"].as_array().unwrap().len() >= 6);
assert!(report["ueba_risk"]["score"].as_u64().unwrap() > 0);
assert!(
!report["ueba_risk"]["reasons"]
.as_array()
.unwrap()
.is_empty()
);
assert!(
report["markdown"]
.as_str()
.unwrap()
.contains("## UEBA риск")
);
assert_eq!(report["workforce_policy"]["configured"], false);
assert_eq!(report["workforce"]["trend_status"], "daily_only");
assert_eq!(report["workforce"]["insights"].as_array().unwrap().len(), 1);
@@ -187,6 +187,10 @@ h1 {
margin: 12px 0;
}
.ueba-risk-card {
margin: 12px 0;
}
.section-head {
display: flex;
align-items: flex-start;
@@ -398,6 +398,30 @@ function renderReportSections(sections) {
`).join("")}</div>`;
}
function renderUebaRisk(risk) {
if (!risk) return "";
const reasons = Array.isArray(risk.reasons) ? risk.reasons.slice(0, 12) : [];
return `
<section class="card ueba-risk-card">
<div class="section-head">
<div>
<h3>UEBA риск</h3>
<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>
</div>
<span class="badge ${statusClass(risk.status)}">${escapeHtml(risk.level || "unknown")} · ${escapeHtml(risk.score ?? 0)}/100</span>
</div>
<div class="list compact-list">${reasons.length ? reasons.map(item => `
<div class="row compact-row">
<strong>${escapeHtml(item.label || item.code || "-")}</strong>
<span class="muted">${escapeHtml(item.value || "")} · ${escapeHtml(item.recommendation || "")}</span>
<span class="badge ${statusClass(item.status || item.severity)}">+${escapeHtml(item.points || 0)}</span>
</div>
`).join("") : `<div class="row compact-row"><strong>Сигналы</strong><span class="muted">Существенных UEBA-сигналов в текущем срезе нет.</span><span class="badge status-ok">OK</span></div>`}</div>
</section>
`;
}
function renderReports(data) {
return `
<h2 class="section-title">Отчеты</h2>
@@ -421,6 +445,7 @@ function renderReports(data) {
</div>
<h3 class="section-title">Ключевые показатели</h3>
${renderKpiCards(data.kpis)}
${renderUebaRisk(data.ueba_risk)}
${renderWorkforceIndexExplanation(data.workforce_policy)}
<h3 class="section-title">Срезы отчета</h3>
${renderReportSections(data.sections)}