feat(portal): make UEBA risk policy configurable

This commit is contained in:
igor04091968
2026-06-03 18:00:08 +03:00
parent 2cb2260bb2
commit c2e2f64c08
8 changed files with 395 additions and 43 deletions
+1
View File
@@ -584,6 +584,7 @@ dependencies = [
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"sha2", "sha2",
"tempfile", "tempfile",
"tiny_http", "tiny_http",
+1
View File
@@ -15,6 +15,7 @@ reqwest.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
serde_yaml.workspace = true
sha2.workspace = true sha2.workspace = true
tiny_http.workspace = true tiny_http.workspace = true
+332 -35
View File
@@ -71,6 +71,13 @@ struct Cli {
)] )]
workforce_policy_path: PathBuf, workforce_policy_path: PathBuf,
#[arg(
long,
default_value = "/etc/detmir-portal-ueba-policy.yaml",
env = "DETMIR_PORTAL_UEBA_POLICY_PATH"
)]
ueba_policy_path: PathBuf,
#[arg(long, default_value_t = 10, env = "DETMIR_PORTAL_TIMEOUT_SECONDS")] #[arg(long, default_value_t = 10, env = "DETMIR_PORTAL_TIMEOUT_SECONDS")]
timeout_seconds: u64, timeout_seconds: u64,
@@ -402,6 +409,34 @@ struct WorkforceRolePolicy {
application_weights: BTreeMap<String, f64>, application_weights: BTreeMap<String, f64>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)]
struct UebaRiskPolicy {
#[serde(default = "default_ueba_policy_version")]
version: String,
#[serde(default = "default_ueba_baseline_status")]
baseline_status: String,
#[serde(default = "default_ueba_score_cap")]
score_cap: u64,
#[serde(default)]
weights: BTreeMap<String, u64>,
#[serde(default)]
confidence: UebaConfidencePolicy,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
struct UebaConfidencePolicy {
#[serde(default)]
base: Option<f64>,
#[serde(default)]
evidence_bonus: Option<f64>,
#[serde(default)]
screenshot_bonus: Option<f64>,
#[serde(default)]
worktime_bonus: Option<f64>,
#[serde(default)]
policy_bonus: Option<f64>,
}
#[derive(Debug)] #[derive(Debug)]
struct WeightedActivity { struct WeightedActivity {
role: String, role: String,
@@ -454,7 +489,7 @@ fn run() -> Result<i32> {
let smoke = json!({ let smoke = json!({
"health": build_health(&snapshot), "health": build_health(&snapshot),
"summary": build_summary(&snapshot), "summary": build_summary(&snapshot),
"reports": build_reports(&snapshot, &incident_state, &build_dlp_evidence_response(&args), &args.workforce_policy_path, false), "reports": build_reports(&snapshot, &incident_state, &build_dlp_evidence_response(&args), &args.workforce_policy_path, &args.ueba_policy_path, false),
"incidents": build_incidents(&snapshot, &incident_state), "incidents": build_incidents(&snapshot, &incident_state),
"dlp_evidence": build_dlp_evidence_response(&args), "dlp_evidence": build_dlp_evidence_response(&args),
}); });
@@ -542,6 +577,7 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> {
&incident_state, &incident_state,
&evidence, &evidence,
&args.workforce_policy_path, &args.workforce_policy_path,
&args.ueba_policy_path,
anonymize, anonymize,
), ),
) )
@@ -943,6 +979,7 @@ fn build_reports(
incident_state: &IncidentStateFile, incident_state: &IncidentStateFile,
evidence: &DlpEvidenceResponse, evidence: &DlpEvidenceResponse,
workforce_policy_path: &Path, workforce_policy_path: &Path,
ueba_policy_path: &Path,
anonymize: bool, anonymize: bool,
) -> Value { ) -> Value {
let summary = build_summary(snapshot); let summary = build_summary(snapshot);
@@ -982,6 +1019,7 @@ fn build_reports(
&metrics, &metrics,
&workforce_policy_explain, &workforce_policy_explain,
&insight_items, &insight_items,
ueba_policy_path,
); );
let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0 let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0
{ {
@@ -1220,12 +1258,141 @@ fn trend_status(trend: &Value) -> String {
} }
} }
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()
}
fn default_ueba_score_cap() -> u64 {
100
}
fn default_ueba_risk_policy() -> UebaRiskPolicy {
UebaRiskPolicy {
version: default_ueba_policy_version(),
baseline_status: default_ueba_baseline_status(),
score_cap: default_ueba_score_cap(),
weights: BTreeMap::from([
("dlp_fail".to_string(), 35),
("dlp_warn".to_string(), 20),
("open_incidents".to_string(), 15),
("night_activity".to_string(), 20),
("weekend_activity".to_string(), 20),
("workforce_drop".to_string(), 15),
("workforce_anomaly".to_string(), 10),
("application_classification_gap".to_string(), 10),
("application_classification_gap_large".to_string(), 15),
("worktime_unavailable".to_string(), 25),
]),
confidence: UebaConfidencePolicy {
base: Some(0.55),
evidence_bonus: Some(0.10),
screenshot_bonus: Some(0.10),
worktime_bonus: Some(0.15),
policy_bonus: Some(0.10),
},
}
}
fn load_ueba_risk_policy(path: &Path) -> (UebaRiskPolicy, bool, Option<String>) {
if !path.exists() {
return (default_ueba_risk_policy(), false, None);
}
match fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))
.and_then(|data| {
serde_yaml::from_str::<UebaRiskPolicy>(&data)
.with_context(|| format!("parse {}", path.display()))
}) {
Ok(mut policy) => {
let defaults = default_ueba_risk_policy();
for (key, value) in defaults.weights {
policy.weights.entry(key).or_insert(value);
}
if policy.score_cap == 0 {
policy.score_cap = default_ueba_score_cap();
}
(policy, true, None)
}
Err(err) => (default_ueba_risk_policy(), false, Some(err.to_string())),
}
}
fn risk_weight(policy: &UebaRiskPolicy, key: &str, fallback: u64) -> u64 {
policy.weights.get(key).copied().unwrap_or(fallback)
}
fn confidence_part(value: Option<f64>, fallback: f64) -> f64 {
value.unwrap_or(fallback).clamp(0.0, 1.0)
}
fn ueba_confidence(
metrics: &ReportMetrics,
workforce_policy: &Value,
snapshot: &Snapshot,
policy: &UebaRiskPolicy,
) -> f64 {
let mut confidence = confidence_part(policy.confidence.base, 0.55);
if metrics.evidence_total > 0 {
confidence += confidence_part(policy.confidence.evidence_bonus, 0.10);
}
if metrics.evidence_screenshots > 0 {
confidence += confidence_part(policy.confidence.screenshot_bonus, 0.10);
}
if snapshot.worktime.ok && snapshot.worktime_management.ok {
confidence += confidence_part(policy.confidence.worktime_bonus, 0.15);
}
if workforce_policy
.get("configured")
.and_then(Value::as_bool)
.unwrap_or(false)
{
confidence += confidence_part(policy.confidence.policy_bonus, 0.10);
}
(confidence.clamp(0.0, 1.0) * 100.0).round() / 100.0
}
fn risk_sources(reasons: &[Value]) -> Vec<String> {
let mut out = Vec::new();
for reason in reasons {
let Some(source) = reason.get("source").and_then(Value::as_str) else {
continue;
};
if !out.iter().any(|item| item == source) {
out.push(source.to_string());
}
}
out
}
fn ueba_calculated_from(
metrics: &ReportMetrics,
workforce_policy: &Value,
insight_items: &[Value],
policy_configured: bool,
policy_error: Option<&str>,
) -> Vec<Value> {
vec![
json!({"source": "dlp_counts", "available": true, "warn": metrics.dlp_warn, "fail": metrics.dlp_fail}),
json!({"source": "incident_queue", "available": true, "open": metrics.open_incidents}),
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_policy", "available": policy_configured, "error": policy_error}),
]
}
fn build_ueba_risk( fn build_ueba_risk(
snapshot: &Snapshot, snapshot: &Snapshot,
metrics: &ReportMetrics, metrics: &ReportMetrics,
workforce_policy: &Value, workforce_policy: &Value,
insight_items: &[Value], insight_items: &[Value],
policy_path: &Path,
) -> Value { ) -> Value {
let (policy, policy_configured, policy_error) = load_ueba_risk_policy(policy_path);
let mut reasons = Vec::new(); let mut reasons = Vec::new();
let mut score = 0_u64; let mut score = 0_u64;
@@ -1233,9 +1400,9 @@ fn build_ueba_risk(
push_risk_reason( push_risk_reason(
&mut reasons, &mut reasons,
&mut score, &mut score,
("dlp_fail", "DLP FAIL"), ("dlp_fail", "DLP FAIL", "dlp"),
"FAIL", "FAIL",
35, risk_weight(&policy, "dlp_fail", 35),
format!("fail={}", metrics.dlp_fail), format!("fail={}", metrics.dlp_fail),
"Проверить DLP/case queue и evidence.", "Проверить DLP/case queue и evidence.",
); );
@@ -1244,9 +1411,9 @@ fn build_ueba_risk(
push_risk_reason( push_risk_reason(
&mut reasons, &mut reasons,
&mut score, &mut score,
("dlp_warn", "DLP WARN"), ("dlp_warn", "DLP WARN", "dlp"),
"WARN", "WARN",
20, risk_weight(&policy, "dlp_warn", 20),
format!("warn={}", metrics.dlp_warn), format!("warn={}", metrics.dlp_warn),
"Разобрать предупреждения DLP и подтвердить/отклонить события.", "Разобрать предупреждения DLP и подтвердить/отклонить события.",
); );
@@ -1255,27 +1422,13 @@ fn build_ueba_risk(
push_risk_reason( push_risk_reason(
&mut reasons, &mut reasons,
&mut score, &mut score,
("open_incidents", "Открытые вопросы"), ("open_incidents", "Открытые вопросы", "incidents"),
"WARN", "WARN",
15, risk_weight(&policy, "open_incidents", 15),
format!("open={}", metrics.open_incidents), format!("open={}", metrics.open_incidents),
"Назначить ответственного и закрыть очередь review.", "Назначить ответственного и закрыть очередь 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 { for item in insight_items {
let status = item.get("status").and_then(Value::as_str).unwrap_or("INFO"); let status = item.get("status").and_then(Value::as_str).unwrap_or("INFO");
@@ -1312,9 +1465,9 @@ fn build_ueba_risk(
push_risk_reason( push_risk_reason(
&mut reasons, &mut reasons,
&mut score, &mut score,
(code, title), (code, title, "workforce"),
status, status,
points, risk_weight(&policy, code, points),
value, value,
"Проверить первичные события ActivityWatch и контекст подразделения.", "Проверить первичные события ActivityWatch и контекст подразделения.",
); );
@@ -1337,12 +1490,13 @@ fn build_ueba_risk(
( (
"application_classification_gap", "application_classification_gap",
"Приложения без явного правила", "Приложения без явного правила",
"policy",
), ),
"WARN", "WARN",
if default_weight_seconds >= 3600 { if default_weight_seconds >= 3600 {
15 risk_weight(&policy, "application_classification_gap_large", 15)
} else { } else {
10 risk_weight(&policy, "application_classification_gap", 10)
}, },
format!( format!(
"default_weight_apps={}, default_weight_time={}", "default_weight_apps={}, default_weight_time={}",
@@ -1357,31 +1511,48 @@ fn build_ueba_risk(
push_risk_reason( push_risk_reason(
&mut reasons, &mut reasons,
&mut score, &mut score,
("worktime_unavailable", "Нет надежного Worktime"), ("worktime_unavailable", "Нет надежного Worktime", "worktime"),
"FAIL", "FAIL",
25, risk_weight(&policy, "worktime_unavailable", 25),
snapshot.worktime.summary.clone(), snapshot.worktime.summary.clone(),
"Восстановить Worktime API/collectors перед выводами по сотрудникам.", "Восстановить Worktime API/collectors перед выводами по сотрудникам.",
); );
} }
let score = score.min(100); let calculated_from = ueba_calculated_from(
metrics,
workforce_policy,
insight_items,
policy_configured,
policy_error.as_deref(),
);
let confidence = ueba_confidence(metrics, workforce_policy, snapshot, &policy);
let risk_sources = risk_sources(&reasons);
let score = score.min(policy.score_cap.max(1));
let (level, status) = ueba_risk_level(score); let (level, status) = ueba_risk_level(score);
json!({ json!({
"score": score, "score": score,
"level": level, "level": level,
"status": status, "status": status,
"summary": format!("{} risk, {} reason(s)", level, reasons.len()), "summary": format!("{} risk, {} reason(s)", level, reasons.len()),
"formula": "sum(reason_points) capped at 100", "formula": format!("sum(reason_points) capped at {}", policy.score_cap.max(1)),
"confidence": confidence,
"risk_sources": risk_sources,
"baseline_status": policy.baseline_status,
"policy_version": policy.version,
"policy_path": policy_path.display().to_string(),
"policy_configured": policy_configured,
"policy_error": policy_error,
"calculated_from": calculated_from,
"reasons": reasons, "reasons": reasons,
"note": "Read-only UEBA score: мониторинг и приоритизация проверки, без автоматического воздействия на сеть." "note": "UEBA-compatible rule-based risk scoring v1: read-only мониторинг и приоритизация проверки, без автоматического воздействия на сеть."
}) })
} }
fn push_risk_reason( fn push_risk_reason(
reasons: &mut Vec<Value>, reasons: &mut Vec<Value>,
score: &mut u64, score: &mut u64,
code_title: (&str, &str), code_title_source: (&str, &str, &str),
severity: &str, severity: &str,
points: u64, points: u64,
evidence: impl Into<String>, evidence: impl Into<String>,
@@ -1389,10 +1560,11 @@ fn push_risk_reason(
) { ) {
*score = score.saturating_add(points); *score = score.saturating_add(points);
reasons.push(json!({ reasons.push(json!({
"label": code_title.1, "label": code_title_source.1,
"status": severity, "status": severity,
"value": evidence.into(), "value": evidence.into(),
"code": code_title.0, "code": code_title_source.0,
"source": code_title_source.2,
"severity": severity, "severity": severity,
"points": points, "points": points,
"recommendation": recommendation, "recommendation": recommendation,
@@ -1926,6 +2098,25 @@ fn append_ueba_risk_markdown(text: &mut String, risk: &Value) {
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("sum(reason_points) capped at 100") .unwrap_or("sum(reason_points) capped at 100")
)); ));
text.push_str(&format!(
"- Confidence: {:.0}%\n",
risk.get("confidence")
.and_then(Value::as_f64)
.unwrap_or(0.0)
* 100.0
));
text.push_str(&format!(
"- Baseline: {}\n",
risk.get("baseline_status")
.and_then(Value::as_str)
.unwrap_or("unknown")
));
text.push_str(&format!(
"- Policy version: {}\n",
risk.get("policy_version")
.and_then(Value::as_str)
.unwrap_or("unknown")
));
if let Some(note) = risk.get("note").and_then(Value::as_str) { if let Some(note) = risk.get("note").and_then(Value::as_str) {
text.push_str(&format!("- Note: {note}\n")); text.push_str(&format!("- Note: {note}\n"));
} }
@@ -3653,6 +3844,7 @@ mod tests {
worktime_url: "http://127.0.0.1".to_string(), worktime_url: "http://127.0.0.1".to_string(),
one_c_url: "http://127.0.0.1".to_string(), one_c_url: "http://127.0.0.1".to_string(),
workforce_policy_path: dir.path().join("workforce-policy.json"), workforce_policy_path: dir.path().join("workforce-policy.json"),
ueba_policy_path: dir.path().join("ueba-policy.yaml"),
timeout_seconds: 1, timeout_seconds: 1,
state_dir: dir.path().join("state"), state_dir: dir.path().join("state"),
dlp_db_path: dir.path().join("dlp.sqlite"), dlp_db_path: dir.path().join("dlp.sqlite"),
@@ -3866,11 +4058,13 @@ mod tests {
error: None, error: None,
}; };
let missing_policy = Path::new("/tmp/detmir-missing-workforce-policy.json"); let missing_policy = Path::new("/tmp/detmir-missing-workforce-policy.json");
let missing_ueba_policy = Path::new("/tmp/detmir-missing-ueba-policy.yaml");
let report = build_reports( let report = build_reports(
&snapshot, &snapshot,
&IncidentStateFile::default(), &IncidentStateFile::default(),
&evidence, &evidence,
missing_policy, missing_policy,
missing_ueba_policy,
false, false,
); );
assert_eq!(report["operator_ok"], true); assert_eq!(report["operator_ok"], true);
@@ -3882,13 +4076,20 @@ mod tests {
.contains("derived detections/cases") .contains("derived detections/cases")
); );
assert!(report["kpis"].as_array().unwrap().len() >= 6); assert!(report["kpis"].as_array().unwrap().len() >= 6);
assert!(report["ueba_risk"]["score"].as_u64().unwrap() > 0); assert_eq!(report["ueba_risk"]["score"], 0);
assert!( assert!(
!report["ueba_risk"]["reasons"] report["ueba_risk"]["reasons"]
.as_array() .as_array()
.unwrap() .unwrap()
.is_empty() .is_empty()
); );
assert!(report["ueba_risk"]["confidence"].as_f64().unwrap() > 0.55);
assert_eq!(
report["ueba_risk"]["baseline_status"],
"portfolio_only_no_per_user_baseline"
);
assert_eq!(report["ueba_risk"]["policy_version"], "ueba-rule-v1");
assert!(report["ueba_risk"]["calculated_from"].is_array());
assert!( assert!(
report["markdown"] report["markdown"]
.as_str() .as_str()
@@ -3907,6 +4108,102 @@ mod tests {
); );
} }
#[test]
fn ueba_risk_uses_yaml_policy_and_evidence_as_confidence() {
let dir = tempfile::tempdir().unwrap();
let policy_path = dir.path().join("ueba-policy.yaml");
fs::write(
&policy_path,
r#"
version: "ueba-rule-v1-test"
baseline_status: "test_baseline"
score_cap: 50
weights:
dlp_warn: 7
confidence:
base: 0.2
evidence_bonus: 0.3
screenshot_bonus: 0.2
worktime_bonus: 0.1
policy_bonus: 0.1
"#,
)
.unwrap();
let snapshot = Snapshot {
generated_at_utc: "2026-06-03T10:00:00Z".to_string(),
detmir_status: SourceStatus {
ok: true,
status: "WARN".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: None,
},
worktime_management: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: None,
},
one_c: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: None,
},
};
let metrics = ReportMetrics {
users_count: 1,
active_seconds: 3600,
apps_count: 1,
dlp_ok: 21,
dlp_warn: 1,
dlp_fail: 0,
evidence_total: 3,
evidence_screenshots: 1,
open_incidents: 0,
acknowledged_incidents: 0,
workforce_index: Some(13),
};
let risk = build_ueba_risk(
&snapshot,
&metrics,
&json!({"configured": false}),
&[],
&policy_path,
);
assert_eq!(risk["score"], 7);
assert_eq!(risk["policy_version"], "ueba-rule-v1-test");
assert_eq!(risk["baseline_status"], "test_baseline");
assert_eq!(risk["policy_configured"], true);
assert_eq!(risk["confidence"], 0.8);
assert_eq!(risk["risk_sources"][0], "dlp");
assert_eq!(risk["reasons"].as_array().unwrap().len(), 1);
assert_ne!(risk["reasons"][0]["code"], "evidence_present");
}
#[test] #[test]
fn weighted_activity_uses_role_application_policy() { fn weighted_activity_uses_role_application_policy() {
let snapshot = Snapshot { let snapshot = Snapshot {
@@ -401,6 +401,8 @@ function renderReportSections(sections) {
function renderUebaRisk(risk) { function renderUebaRisk(risk) {
if (!risk) return ""; if (!risk) return "";
const reasons = Array.isArray(risk.reasons) ? risk.reasons.slice(0, 12) : []; 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%";
return ` return `
<section class="card ueba-risk-card"> <section class="card ueba-risk-card">
<div class="section-head"> <div class="section-head">
@@ -408,6 +410,7 @@ function renderUebaRisk(risk) {
<h3>UEBA риск</h3> <h3>UEBA риск</h3>
<p class="muted">${escapeHtml(risk.note || "Read-only risk score без автоматического воздействия.")}</p> <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">Формула: ${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>
</div> </div>
<span class="badge ${statusClass(risk.status)}">${escapeHtml(risk.level || "unknown")} · ${escapeHtml(risk.score ?? 0)}/100</span> <span class="badge ${statusClass(risk.status)}">${escapeHtml(risk.level || "unknown")} · ${escapeHtml(risk.score ?? 0)}/100</span>
</div> </div>
+11
View File
@@ -13,6 +13,7 @@
detmir_portal_one_c_host: "{{ hostvars[(groups['proxmox'] | default([]) | first) | default('192.0.2.2', true)].ansible_host | default((groups['proxmox'] | default([]) | first) | default('192.0.2.2', true), true) }}" detmir_portal_one_c_host: "{{ hostvars[(groups['proxmox'] | default([]) | first) | default('192.0.2.2', true)].ansible_host | default((groups['proxmox'] | default([]) | first) | default('192.0.2.2', true), true) }}"
detmir_portal_one_c_url: "{{ detmir_portal_one_c_url_override | default('http://' + detmir_portal_one_c_host + ':8710', true) }}" detmir_portal_one_c_url: "{{ detmir_portal_one_c_url_override | default('http://' + detmir_portal_one_c_host + ':8710', true) }}"
detmir_portal_workforce_policy_path: "/etc/detmir-portal-workforce-policy.json" detmir_portal_workforce_policy_path: "/etc/detmir-portal-workforce-policy.json"
detmir_portal_ueba_policy_path: "/etc/detmir-portal-ueba-policy.yaml"
tasks: tasks:
- name: Check local detmir-portal binary - name: Check local detmir-portal binary
@@ -49,6 +50,7 @@
DETMIR_PORTAL_WORKTIME_URL={{ detmir_portal_worktime_url }} DETMIR_PORTAL_WORKTIME_URL={{ detmir_portal_worktime_url }}
DETMIR_PORTAL_ONE_C_URL={{ detmir_portal_one_c_url }} DETMIR_PORTAL_ONE_C_URL={{ detmir_portal_one_c_url }}
DETMIR_PORTAL_WORKFORCE_POLICY_PATH={{ detmir_portal_workforce_policy_path }} DETMIR_PORTAL_WORKFORCE_POLICY_PATH={{ detmir_portal_workforce_policy_path }}
DETMIR_PORTAL_UEBA_POLICY_PATH={{ detmir_portal_ueba_policy_path }}
DETMIR_PORTAL_TIMEOUT_SECONDS=10 DETMIR_PORTAL_TIMEOUT_SECONDS=10
DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal
DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite
@@ -65,6 +67,15 @@
force: false force: false
content: "{{ lookup('file', aw_repo_root + '/configs/detmir-workforce-policy.example.json') }}" content: "{{ lookup('file', aw_repo_root + '/configs/detmir-workforce-policy.example.json') }}"
- name: Install initial UEBA risk policy when absent
ansible.builtin.copy:
dest: "{{ detmir_portal_ueba_policy_path }}"
owner: root
group: root
mode: "0644"
force: false
content: "{{ lookup('file', aw_repo_root + '/configs/detmir-ueba-risk-policy.example.yaml') }}"
- name: Ensure detmir-portal state and evidence directories - name: Ensure detmir-portal state and evidence directories
ansible.builtin.file: ansible.builtin.file:
path: "{{ item }}" path: "{{ item }}"
@@ -0,0 +1,22 @@
version: "ueba-rule-v1"
baseline_status: "portfolio_only_no_per_user_baseline"
score_cap: 100
weights:
dlp_fail: 35
dlp_warn: 20
open_incidents: 15
night_activity: 20
weekend_activity: 20
workforce_drop: 15
workforce_anomaly: 10
application_classification_gap: 10
application_classification_gap_large: 15
worktime_unavailable: 25
confidence:
base: 0.55
evidence_bonus: 0.10
screenshot_bonus: 0.10
worktime_bonus: 0.15
policy_bonus: 0.10
+18 -4
View File
@@ -106,16 +106,30 @@ PDF-экспорт выполняется штатной печатью брау
заменяются на `Сотрудник N` и `EMPLOYEE-N`. Live-режим без query-флага заменяются на `Сотрудник N` и `EMPLOYEE-N`. Live-режим без query-флага
сохраняет реальные имена для внутреннего коммерческого контура DetMir. сохраняет реальные имена для внутреннего коммерческого контура DetMir.
### UEBA risk scoring ### UEBA-compatible rule-based risk scoring v1
DetMir Workforce/Security формирует read-only UEBA score для руководителя и ИБ: DetMir Workforce/Security формирует read-only UEBA-compatible rule-based score
для руководителя и ИБ:
- `risk_score`: сумма reason points, capped at 100; - `risk_score`: сумма reason points, capped at 100;
- `risk_level`: `normal`, `low`, `medium`, `high`; - `risk_level`: `normal`, `low`, `medium`, `high`;
- `reasons`: DLP WARN/FAIL, open review queue, evidence, off-hours/weekend - `confidence`: доверие к расчету; evidence и screenshot повышают confidence,
insights, просадки/аномалии Workforce, приложения без явного но не добавляют risk score сами по себе;
- `risk_sources`: типы источников, которые дали risk reasons;
- `baseline_status`: статус baseline-модели, сейчас
`portfolio_only_no_per_user_baseline`;
- `policy_version`: версия risk policy;
- `calculated_from`: список источников, участвовавших в расчете;
- `reasons`: DLP WARN/FAIL, open review queue, off-hours/weekend insights,
просадки/аномалии Workforce, приложения без явного
`application_weights` правила. `application_weights` правила.
Веса настраиваются через YAML policy:
- пример: `configs/detmir-ueba-risk-policy.example.yaml`;
- runtime-файл: `/etc/detmir-portal-ueba-policy.yaml`;
- env-путь: `DETMIR_PORTAL_UEBA_POLICY_PATH`.
Важно: текущий UEBA слой только ранжирует риск и объясняет причины. Он не Важно: текущий UEBA слой только ранжирует риск и объясняет причины. Он не
выполняет pfSense/NAC/SOAR actions и не меняет сетевые политики. выполняет pfSense/NAC/SOAR actions и не меняет сетевые политики.
+7 -4
View File
@@ -30,8 +30,9 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`.
- UI tab: `Отчеты`; - UI tab: `Отчеты`;
- отчет содержит KPI для владельца/руководителя: worktime users, active time, - отчет содержит KPI для владельца/руководителя: worktime users, active time,
active applications, DLP WARN/FAIL, evidence screenshots/items, open issues; active applications, DLP WARN/FAIL, evidence screenshots/items, open issues;
- отчет содержит read-only UEBA risk score: `score`, `level`, `reasons`, - отчет содержит read-only UEBA-compatible rule-based risk scoring v1: `score`,
формула `sum(reason_points) capped at 100`; `level`, `confidence`, `risk_sources`, `baseline_status`, `policy_version`,
`calculated_from`, `reasons`;
- отчет и вкладка `Руководитель` показывают `Индекс активности` как - отчет и вкладка `Руководитель` показывают `Индекс активности` как
proxy `активное время / плановое рабочее время`; proxy `активное время / плановое рабочее время`;
- отчет поддерживает `Взвешенную активность` при наличии - отчет поддерживает `Взвешенную активность` при наличии
@@ -61,8 +62,10 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`.
- для демо/экспертных PDF и Markdown добавлен режим обезличивания: - для демо/экспертных PDF и Markdown добавлен режим обезличивания:
`?anonymize=1` на `/api/reports` и `/api/workforce/policy/explain`, плюс `?anonymize=1` на `/api/reports` и `/api/workforce/policy/explain`, плюс
кнопка `Демо без имен` во вкладке `Отчеты`; кнопка `Демо без имен` во вкладке `Отчеты`;
- UEBA score виден в KPI, отдельной секции отчета, Markdown и PDF; слой не - UEBA score виден в KPI, отдельной секции отчета, Markdown и PDF; веса
выполняет pfSense/NAC/SOAR actions; настраиваются в `/etc/detmir-portal-ueba-policy.yaml`; evidence используется
как confidence, а не как отдельный risk reason; слой не выполняет
pfSense/NAC/SOAR actions;
- вкладка `Руководитель` получает этот блок через легкий endpoint - вкладка `Руководитель` получает этот блок через легкий endpoint
`/api/workforce/policy/explain`, без загрузки полного `/api/reports`; `/api/workforce/policy/explain`, без загрузки полного `/api/reports`;
- contract легкого endpoint защищен unit-тестом - contract легкого endpoint защищен unit-тестом