feat: add UEBA confidence guardrails

This commit is contained in:
igor04091968
2026-06-07 22:34:45 +03:00
parent da84a9a800
commit bae67494e2
10 changed files with 956 additions and 1 deletions
@@ -1265,6 +1265,24 @@
"minimum": 0, "minimum": 0,
"maximum": 100 "maximum": 100
}, },
"confidence": {
"type": "string",
"enum": [
"high",
"medium",
"low",
"unknown"
]
},
"classification": {
"type": "string",
"enum": [
"confirmed_risk",
"likely_risk",
"needs_investigation",
"insufficient_data"
]
},
"title": { "title": {
"type": "string" "type": "string"
}, },
@@ -1310,6 +1328,8 @@
"ok", "ok",
"score", "score",
"severity", "severity",
"confidence",
"classification",
"score_components", "score_components",
"reason_codes", "reason_codes",
"model" "model"
@@ -1339,6 +1359,54 @@
"critical" "critical"
] ]
}, },
"confidence": {
"type": "string",
"enum": [
"high",
"medium",
"low",
"unknown"
]
},
"confidence_score": {
"type": [
"number",
"null"
],
"minimum": 0,
"maximum": 1
},
"classification": {
"type": "string",
"enum": [
"confirmed_risk",
"likely_risk",
"needs_investigation",
"insufficient_data"
]
},
"classification_reason": {
"type": "string"
},
"confidence_reasons": {
"type": "array",
"items": {
"type": "string"
}
},
"confidence_contributors": {
"type": "array",
"items": {
"$ref": "#/components/schemas/JsonObject"
}
},
"evidence_status": {
"type": "string",
"enum": [
"available",
"not_available"
]
},
"score_components": { "score_components": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -70,6 +70,8 @@ export interface RiskNarrative {
}; };
risk_level: "low" | "guarded" | "medium" | "high" | "critical" | string; risk_level: "low" | "guarded" | "medium" | "high" | "critical" | string;
risk_score: number; risk_score: number;
confidence?: "high" | "medium" | "low" | "unknown" | string;
classification?: "confirmed_risk" | "likely_risk" | "needs_investigation" | "insufficient_data" | string;
title: string; title: string;
summary: string; summary: string;
why: string[]; why: string[];
@@ -257,6 +259,13 @@ export interface UebaResponse {
score: number | null; score: number | null;
severity: "normal" | "low" | "medium" | "high" | "critical" | string; severity: "normal" | "low" | "medium" | "high" | "critical" | string;
status?: string; status?: string;
confidence: "high" | "medium" | "low" | "unknown" | string;
confidence_score?: number | null;
classification: "confirmed_risk" | "likely_risk" | "needs_investigation" | "insufficient_data" | string;
classification_reason?: string;
confidence_reasons: string[];
confidence_contributors?: JsonObject[];
evidence_status?: "available" | "not_available" | string;
score_components: { score_components: {
activity_anomaly: number; activity_anomaly: number;
time_anomaly: number; time_anomaly: number;
@@ -88,6 +88,7 @@ fn generate_actions(report: &Value) -> Vec<ExecutiveAction> {
let mut actions = Vec::new(); let mut actions = Vec::new();
add_workforce_kpi_action(report, &mut actions); add_workforce_kpi_action(report, &mut actions);
add_coverage_action(report, &mut actions); add_coverage_action(report, &mut actions);
add_ueba_confidence_action(report, &mut actions);
add_ueba_action(report, &mut actions); add_ueba_action(report, &mut actions);
add_security_correlation_action(report, &mut actions); add_security_correlation_action(report, &mut actions);
add_incident_candidate_action(report, &mut actions); add_incident_candidate_action(report, &mut actions);
@@ -186,6 +187,68 @@ fn add_coverage_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
}); });
} }
fn add_ueba_confidence_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
let score = report
.pointer("/ueba_risk/score")
.and_then(Value::as_u64)
.unwrap_or(0);
let explicit_confidence = report
.pointer("/ueba_risk/confidence_level")
.and_then(Value::as_str);
let explicit_classification = report
.pointer("/ueba_risk/classification")
.and_then(Value::as_str);
if score < 70 && explicit_confidence.is_none() && explicit_classification.is_none() {
return;
}
let confidence = report
.pointer("/ueba_risk/confidence_level")
.and_then(Value::as_str)
.unwrap_or("unknown");
let classification = report
.pointer("/ueba_risk/classification")
.and_then(Value::as_str)
.unwrap_or("insufficient_data");
if score < 70 && !matches!(classification, "needs_investigation" | "insufficient_data") {
return;
}
if !matches!(confidence, "low" | "unknown")
&& !matches!(classification, "needs_investigation" | "insufficient_data")
{
return;
}
let reasons = report
.pointer("/ueba_risk/confidence_reasons")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.take(3)
.map(ToString::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut evidence = vec![
format!("UEBA confidence: {confidence}"),
format!("UEBA classification: {classification}"),
];
evidence.extend(reasons);
actions.push(ExecutiveAction {
priority: ActionPriority::Critical,
title: "Проверить полноту данных".to_string(),
summary: "Перед жестким выводом по UEBA нужно подтвердить покрытие, свежесть и полноту телеметрии"
.to_string(),
owner_role: ActionOwnerRole::Admin,
recommended_deadline: "4h".to_string(),
reason_codes: vec![
"LOW_UEBA_CONFIDENCE".to_string(),
"CHECK_DATA_COMPLETENESS".to_string(),
],
evidence,
});
}
fn add_ueba_action(report: &Value, actions: &mut Vec<ExecutiveAction>) { fn add_ueba_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
let score = report let score = report
.pointer("/ueba_risk/score") .pointer("/ueba_risk/score")
@@ -389,6 +452,13 @@ mod tests {
.iter() .iter()
.any(|code| code == "LOW_WORKFORCE_KPI") .any(|code| code == "LOW_WORKFORCE_KPI")
})); }));
assert!(actions.iter().any(|item| {
item["reason_codes"]
.as_array()
.unwrap()
.iter()
.any(|code| code == "LOW_UEBA_CONFIDENCE")
}));
assert!(actions.iter().any(|item| item["priority"] == "critical")); assert!(actions.iter().any(|item| item["priority"] == "critical"));
} }
+464
View File
@@ -3765,6 +3765,16 @@ fn build_ueba_api_payload(report: &Value, role: PortalRole) -> Value {
"score": report.pointer("/ueba_risk/score").cloned().unwrap_or(Value::Null), "score": report.pointer("/ueba_risk/score").cloned().unwrap_or(Value::Null),
"severity": report.pointer("/ueba_risk/level").cloned().unwrap_or_else(|| json!("normal")), "severity": report.pointer("/ueba_risk/level").cloned().unwrap_or_else(|| json!("normal")),
"status": report.pointer("/ueba_risk/status").cloned().unwrap_or_else(|| json!("OK")), "status": report.pointer("/ueba_risk/status").cloned().unwrap_or_else(|| json!("OK")),
"confidence": report.pointer("/ueba_risk/confidence_level").cloned().unwrap_or_else(|| json!("unknown")),
"confidence_score": report.pointer("/ueba_risk/confidence_score")
.or_else(|| report.pointer("/ueba_risk/confidence"))
.cloned()
.unwrap_or(Value::Null),
"classification": report.pointer("/ueba_risk/classification").cloned().unwrap_or_else(|| json!("insufficient_data")),
"classification_reason": report.pointer("/ueba_risk/classification_reason").cloned().unwrap_or_else(|| json!("confidence_unknown")),
"confidence_reasons": report.pointer("/ueba_risk/confidence_reasons").cloned().unwrap_or_else(|| json!([])),
"confidence_contributors": report.pointer("/ueba_risk/confidence_contributors").cloned().unwrap_or_else(|| json!([])),
"evidence_status": report.pointer("/ueba_risk/evidence_status").cloned().unwrap_or_else(|| json!("not_available")),
"score_components": report.pointer("/ueba_risk/score_components").cloned().unwrap_or_else(|| json!({ "score_components": report.pointer("/ueba_risk/score_components").cloned().unwrap_or_else(|| json!({
"activity_anomaly": 0, "activity_anomaly": 0,
"time_anomaly": 0, "time_anomaly": 0,
@@ -6370,6 +6380,288 @@ fn ueba_confidence(
(confidence.clamp(0.0, 1.0) * 100.0).round() / 100.0 (confidence.clamp(0.0, 1.0) * 100.0).round() / 100.0
} }
fn confidence_contributor(
name: &str,
level: &str,
reason: &str,
detail: impl Into<String>,
) -> Value {
json!({
"name": name,
"level": level,
"reason": reason,
"detail": detail.into(),
})
}
fn coverage_confidence_level(value: u8, expected_nodes: usize) -> &'static str {
if expected_nodes == 0 {
"unknown"
} else if value >= 80 {
"high"
} else if value >= 50 {
"medium"
} else {
"low"
}
}
fn ueba_evidence_status(metrics: &ReportMetrics) -> &'static str {
if metrics.evidence_screenshots > 0 || metrics.evidence_total > 0 {
"available"
} else {
"not_available"
}
}
fn ueba_confidence_guardrails(
snapshot: &Snapshot,
metrics: &ReportMetrics,
workforce_policy: &Value,
ueba_baseline: &Value,
reasons: &[Value],
score: u64,
) -> (
String,
String,
String,
Vec<String>,
Vec<Value>,
&'static str,
) {
let mut contributors = Vec::new();
let coverage = snapshot.agent_coverage_sla.coverage_pct;
let freshness = snapshot.agent_coverage_sla.freshness_pct;
let expected_nodes = snapshot.agent_coverage_sla.expected_nodes;
let coverage_level = coverage_confidence_level(coverage, expected_nodes);
contributors.push(confidence_contributor(
"agent_coverage",
coverage_level,
if expected_nodes == 0 {
"expected_nodes_not_configured"
} else if coverage < 80 {
"coverage_below_target"
} else {
"coverage_ok"
},
format!("coverage={coverage}%, expected_nodes={expected_nodes}"),
));
let freshness_level = coverage_confidence_level(freshness, expected_nodes);
contributors.push(confidence_contributor(
"data_freshness",
freshness_level,
if expected_nodes == 0 {
"freshness_scope_unknown"
} else if freshness < 80 {
"freshness_below_target"
} else {
"fresh_data"
},
format!("freshness={freshness}%"),
));
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 telemetry_gaps = [
!snapshot.worktime.ok,
!snapshot.worktime_management.ok,
metrics.users_count == 0,
metrics.apps_count == 0,
default_weight_apps > 0,
]
.into_iter()
.filter(|gap| *gap)
.count();
let telemetry_level = if telemetry_gaps == 0 {
"high"
} else if telemetry_gaps <= 2 {
"medium"
} else {
"low"
};
contributors.push(confidence_contributor(
"telemetry_completeness",
telemetry_level,
if telemetry_gaps == 0 {
"telemetry_complete"
} else {
"telemetry_missing_or_unclassified"
},
format!("gap_count={telemetry_gaps}, default_weight_apps={default_weight_apps}"),
));
let evidence_status = ueba_evidence_status(metrics);
let evidence_level = if metrics.evidence_screenshots > 0 {
"high"
} else if metrics.evidence_total > 0 {
"medium"
} else if score >= 70 {
"low"
} else {
"medium"
};
contributors.push(confidence_contributor(
"evidence_presence",
evidence_level,
if evidence_status == "available" {
"evidence_available"
} else {
"evidence_not_available"
},
format!(
"items={}, screenshots={}",
metrics.evidence_total, metrics.evidence_screenshots
),
));
let baseline_samples = ueba_baseline
.get("baseline_samples")
.and_then(Value::as_object)
.and_then(|items| items.get("total"))
.and_then(Value::as_u64)
.unwrap_or(0);
let user_baseline = ueba_baseline
.get("user_baseline_available")
.and_then(Value::as_bool)
.unwrap_or(false);
let department_baseline = ueba_baseline
.get("department_baseline_available")
.and_then(Value::as_bool)
.unwrap_or(false);
let history_level = if user_baseline && department_baseline && baseline_samples >= 6 {
"high"
} else if (user_baseline || department_baseline) && baseline_samples >= 3 {
"medium"
} else if baseline_samples > 0 {
"low"
} else {
"unknown"
};
contributors.push(confidence_contributor(
"history_depth",
history_level,
if baseline_samples == 0 {
"baseline_missing"
} else if history_level == "high" {
"baseline_ready"
} else {
"baseline_limited"
},
format!(
"samples={baseline_samples}, user_baseline={user_baseline}, department_baseline={department_baseline}"
),
));
let mut sources = Vec::new();
for reason in reasons {
if let Some(source) = reason.get("source").and_then(Value::as_str) {
if !sources.iter().any(|item| item == source) {
sources.push(source.to_string());
}
}
}
let has_dlp = reasons.iter().any(|reason| {
reason
.get("source")
.and_then(Value::as_str)
.is_some_and(|source| source == "dlp")
});
let has_workforce = sources.iter().any(|source| source == "workforce");
let has_history = sources
.iter()
.any(|source| source == "baseline" || source == "incidents");
let signal_level = if has_dlp && has_workforce && has_history {
"high"
} else if sources.len() >= 2 && evidence_status == "available" {
"medium"
} else if score >= 70 && has_workforce && has_history {
"low"
} else if sources.is_empty() {
"unknown"
} else {
"medium"
};
contributors.push(confidence_contributor(
"signal_consistency",
signal_level,
match signal_level {
"high" => "multiple_corroborating_signals",
"medium" => "partial_corroboration",
"low" => "weak_corroboration",
_ => "signals_missing",
},
format!(
"source_count={}, sources={}",
sources.len(),
sources.join(",")
),
));
let levels = contributors
.iter()
.filter_map(|item| item.get("level").and_then(Value::as_str))
.collect::<Vec<_>>();
let confidence_level = if levels.iter().all(|level| *level == "unknown") {
"unknown"
} else if levels.contains(&"low") {
"low"
} else if levels
.iter()
.any(|level| *level == "medium" || *level == "unknown")
{
"medium"
} else {
"high"
};
let classification =
if confidence_level == "unknown" || (score == 0 && confidence_level == "low") {
"insufficient_data"
} else if confidence_level == "low" && score >= 70 {
"needs_investigation"
} else if confidence_level == "high" && score >= 70 {
"confirmed_risk"
} else if score >= 15 {
"likely_risk"
} else {
"insufficient_data"
};
let mut confidence_reasons = contributors
.iter()
.filter(|item| {
item.get("level")
.and_then(Value::as_str)
.is_some_and(|level| level == "low" || level == "unknown")
})
.filter_map(|item| {
let name = item.get("name").and_then(Value::as_str)?;
let reason = item.get("reason").and_then(Value::as_str)?;
Some(format!("{name}:{reason}"))
})
.collect::<Vec<_>>();
if confidence_reasons.is_empty() {
confidence_reasons.push("confidence_inputs_acceptable".to_string());
}
let classification_reason = confidence_reasons
.first()
.cloned()
.unwrap_or_else(|| "confidence_inputs_acceptable".to_string());
(
confidence_level.to_string(),
classification.to_string(),
classification_reason,
confidence_reasons,
contributors,
evidence_status,
)
}
fn risk_sources(reasons: &[Value]) -> Vec<String> { fn risk_sources(reasons: &[Value]) -> Vec<String> {
let mut out = Vec::new(); let mut out = Vec::new();
for reason in reasons { for reason in reasons {
@@ -6578,6 +6870,21 @@ fn build_ueba_risk(
let (level, status) = ueba_risk_level(score); let (level, status) = ueba_risk_level(score);
let score_components = ueba_score_components(&reasons, score); let score_components = ueba_score_components(&reasons, score);
let reason_codes = ueba_reason_codes(&reasons); let reason_codes = ueba_reason_codes(&reasons);
let (
confidence_level,
classification,
classification_reason,
confidence_reasons,
confidence_contributors,
evidence_status,
) = ueba_confidence_guardrails(
snapshot,
metrics,
workforce_policy,
ueba_baseline,
&reasons,
score,
);
let calculated_from = ueba_calculated_from( let calculated_from = ueba_calculated_from(
metrics, metrics,
workforce_policy, workforce_policy,
@@ -6598,6 +6905,13 @@ fn build_ueba_risk(
"score_components": score_components, "score_components": score_components,
"reason_codes": reason_codes, "reason_codes": reason_codes,
"confidence": confidence, "confidence": confidence,
"confidence_score": confidence,
"confidence_level": confidence_level,
"classification": classification,
"classification_reason": classification_reason,
"confidence_reasons": confidence_reasons,
"confidence_contributors": confidence_contributors,
"evidence_status": evidence_status,
"risk_sources": risk_sources, "risk_sources": risk_sources,
"baseline_status": ueba_baseline "baseline_status": ueba_baseline
.get("baseline_status") .get("baseline_status")
@@ -7331,6 +7645,20 @@ fn append_risk_narrative_markdown(text: &mut String, narrative: &Value) {
.and_then(Value::as_u64) .and_then(Value::as_u64)
.unwrap_or(0) .unwrap_or(0)
)); ));
text.push_str(&format!(
"- Уверенность: {}\n",
narrative
.get("confidence")
.and_then(Value::as_str)
.unwrap_or("unknown")
));
text.push_str(&format!(
"- Классификация: {}\n",
narrative
.get("classification")
.and_then(Value::as_str)
.unwrap_or("insufficient_data")
));
text.push_str(&format!( text.push_str(&format!(
"- Вывод: {}\n", "- Вывод: {}\n",
narrative narrative
@@ -8094,6 +8422,18 @@ fn append_ueba_risk_markdown(text: &mut String, risk: &Value) {
.unwrap_or(0.0) .unwrap_or(0.0)
* 100.0 * 100.0
)); ));
text.push_str(&format!(
"- Уровень уверенности: {}\n",
risk.get("confidence_level")
.and_then(Value::as_str)
.unwrap_or("unknown")
));
text.push_str(&format!(
"- Классификация: {}\n",
risk.get("classification")
.and_then(Value::as_str)
.unwrap_or("insufficient_data")
));
text.push_str(&format!( text.push_str(&format!(
"- Обычный профиль: {}\n", "- Обычный профиль: {}\n",
risk.get("baseline_status") risk.get("baseline_status")
@@ -8130,6 +8470,37 @@ fn append_ueba_risk_markdown(text: &mut String, risk: &Value) {
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}\n")); text.push_str(&format!("- Примечание: {note}\n"));
} }
text.push_str("\n## UEBA Confidence\n\n");
text.push_str(&format!(
"- Severity: {}\n",
risk.get("level")
.and_then(Value::as_str)
.unwrap_or("unknown")
));
text.push_str(&format!(
"- Confidence: {}\n",
risk.get("confidence_level")
.and_then(Value::as_str)
.unwrap_or("unknown")
));
text.push_str(&format!(
"- Classification: {}\n",
risk.get("classification")
.and_then(Value::as_str)
.unwrap_or("insufficient_data")
));
text.push_str(&format!(
"- Evidence status: {}\n",
risk.get("evidence_status")
.and_then(Value::as_str)
.unwrap_or("not_available")
));
append_string_list_markdown(
text,
"### Confidence reasons",
risk.get("confidence_reasons").and_then(Value::as_array),
"confidence reasons are not available",
);
text.push_str("\n### Причины риска\n\n"); text.push_str("\n### Причины риска\n\n");
let reasons = risk let reasons = risk
.get("reasons") .get("reasons")
@@ -10953,6 +11324,9 @@ mod tests {
let ueba = build_ueba_api_payload(&report, PortalRole::Security); let ueba = build_ueba_api_payload(&report, PortalRole::Security);
assert_eq!(ueba["score"], 55); assert_eq!(ueba["score"], 55);
assert_eq!(ueba["severity"], "medium"); assert_eq!(ueba["severity"], "medium");
assert_eq!(ueba["confidence"], "unknown");
assert_eq!(ueba["classification"], "insufficient_data");
assert!(ueba["confidence_reasons"].as_array().unwrap().is_empty());
assert_eq!(ueba["score_components"]["activity_anomaly"], 15); assert_eq!(ueba["score_components"]["activity_anomaly"], 15);
assert_eq!(ueba["score_components"]["application_anomaly"], 20); assert_eq!(ueba["score_components"]["application_anomaly"], 20);
assert_eq!(ueba["reason_codes"][0], "activity_anomaly"); assert_eq!(ueba["reason_codes"][0], "activity_anomaly");
@@ -10970,6 +11344,96 @@ mod tests {
assert!(!text.contains("192.168.")); assert!(!text.contains("192.168."));
} }
#[test]
fn ueba_confidence_guardrails_separate_severity_from_confirmation() {
fn ok_source() -> SourceStatus {
SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "ok".to_string(),
error: None,
payload: None,
}
}
let dir = tempfile::tempdir().unwrap();
let policy_path = dir.path().join("ueba-policy.yaml");
let snapshot = Snapshot {
generated_at_utc: "2026-06-07T10:00:00Z".to_string(),
detmir_status: ok_source(),
detmir_check: ok_source(),
failed_units: ok_source(),
worktime: ok_source(),
worktime_management: ok_source(),
one_c: ok_source(),
one_c_overview: ok_source(),
agent_quality: AgentQuality::default(),
agent_quality_history: Vec::new(),
agent_quality_history_summary: AgentQualityHistorySummary::default(),
agent_quality_nodes: Vec::new(),
agent_quality_nodes_summary: AgentQualityNodesSummary::default(),
agent_coverage_sla: AgentCoverageSla {
expected_nodes: 1,
reporting_nodes_24h: 0,
stale_nodes: 1,
missing_nodes: 0,
coverage_pct: 0,
freshness_pct: 0,
sla_status: "CRITICAL".to_string(),
problem_nodes: Vec::new(),
},
security_events_summary: SecurityEventsSummary::disabled(),
};
let metrics = ReportMetrics {
users_count: 1,
active_seconds: 0,
apps_count: 0,
dlp_ok: 0,
dlp_warn: 0,
dlp_fail: 0,
evidence_total: 0,
evidence_screenshots: 0,
open_incidents: 1,
acknowledged_incidents: 0,
workforce_index: Some(0),
};
let insights = (0..8)
.map(|_| json!({"status": "WARN", "label": "Просадка активности", "value": "drop"}))
.collect::<Vec<_>>();
let risk = build_ueba_risk(
&snapshot,
&metrics,
&json!({"configured": true, "policy_audit": {"default_weight_applications": 0}}),
&insights,
&json!({
"baseline_window_days": 30,
"user_baseline_available": true,
"department_baseline_available": false,
"deviation_score": 15,
"baseline_samples": {"users": 19, "departments": 21, "total": 40}
}),
&policy_path,
);
assert_eq!(risk["score"], 100);
assert_eq!(risk["level"], "critical");
assert_eq!(risk["confidence_level"], "low");
assert_eq!(risk["classification"], "needs_investigation");
assert_eq!(risk["evidence_status"], "not_available");
assert!(
risk["confidence_reasons"]
.as_array()
.unwrap()
.iter()
.any(|item| {
item.as_str()
.unwrap()
.contains("agent_coverage:coverage_below_target")
})
);
assert_eq!(risk["score_components"]["network_anomaly"], 0);
assert_eq!(risk["score_components"]["application_anomaly"], 0);
}
#[test] #[test]
fn links_are_gateway_relative() { fn links_are_gateway_relative() {
let links = links(); let links = links();
@@ -39,6 +39,8 @@ pub(crate) struct RiskNarrativeInputs<'a> {
struct NarrativeSignal { struct NarrativeSignal {
score: u8, score: u8,
level: &'static str, level: &'static str,
confidence: String,
classification: String,
why: Vec<String>, why: Vec<String>,
evidence: Vec<Value>, evidence: Vec<Value>,
recommended_actions: Vec<String>, recommended_actions: Vec<String>,
@@ -62,6 +64,8 @@ pub(crate) fn build_risk_narrative(
let mut signal = NarrativeSignal { let mut signal = NarrativeSignal {
score: 0, score: 0,
level: "low", level: "low",
confidence: "unknown".to_string(),
classification: "insufficient_data".to_string(),
why: Vec::new(), why: Vec::new(),
evidence: Vec::new(), evidence: Vec::new(),
recommended_actions: Vec::new(), recommended_actions: Vec::new(),
@@ -74,6 +78,7 @@ pub(crate) fn build_risk_narrative(
add_workforce_kpi_signal(&mut signal, inputs.workforce_kpi_explain); add_workforce_kpi_signal(&mut signal, inputs.workforce_kpi_explain);
add_ueba_signal(&mut signal, inputs.ueba_risk); add_ueba_signal(&mut signal, inputs.ueba_risk);
add_ueba_confidence_guardrails(&mut signal, inputs.ueba_risk);
add_coverage_signal( add_coverage_signal(
&mut signal, &mut signal,
inputs.agent_coverage_sla, inputs.agent_coverage_sla,
@@ -105,6 +110,8 @@ pub(crate) fn build_risk_narrative_from_report(
let mut signal = NarrativeSignal { let mut signal = NarrativeSignal {
score: 0, score: 0,
level: "low", level: "low",
confidence: "unknown".to_string(),
classification: "insufficient_data".to_string(),
why: Vec::new(), why: Vec::new(),
evidence: Vec::new(), evidence: Vec::new(),
recommended_actions: Vec::new(), recommended_actions: Vec::new(),
@@ -122,6 +129,7 @@ pub(crate) fn build_risk_narrative_from_report(
report.get("workforce_kpi_explain").unwrap_or(&Value::Null), report.get("workforce_kpi_explain").unwrap_or(&Value::Null),
); );
add_ueba_signal(&mut signal, report.get("ueba_risk").unwrap_or(&Value::Null)); add_ueba_signal(&mut signal, report.get("ueba_risk").unwrap_or(&Value::Null));
add_ueba_confidence_guardrails(&mut signal, report.get("ueba_risk").unwrap_or(&Value::Null));
add_coverage_from_report_signal(&mut signal, report); add_coverage_from_report_signal(&mut signal, report);
let selected_heatmap = select_heatmap_value( let selected_heatmap = select_heatmap_value(
report.get("risk_heatmap").and_then(Value::as_array), report.get("risk_heatmap").and_then(Value::as_array),
@@ -331,6 +339,36 @@ fn add_ueba_signal(signal: &mut NarrativeSignal, risk: &Value) {
)); ));
} }
fn add_ueba_confidence_guardrails(signal: &mut NarrativeSignal, risk: &Value) {
let confidence = risk
.get("confidence_level")
.and_then(Value::as_str)
.unwrap_or("unknown");
let classification = risk
.get("classification")
.and_then(Value::as_str)
.unwrap_or("insufficient_data");
signal.confidence = confidence.to_string();
signal.classification = classification.to_string();
if matches!(confidence, "low" | "unknown") {
push_unique(&mut signal.why, "Уверенность в выводе ниже целевого уровня");
push_unique(
&mut signal.recommended_actions,
"Проверить полноту данных до управленческого вывода",
);
push_unique(
&mut signal.limitations,
"Низкая уверенность не подтверждает инцидент без ручной проверки",
);
}
if classification == "needs_investigation" {
push_unique(
&mut signal.recommended_actions,
"Зафиксировать статус Needs Investigation и передать на ручной разбор",
);
}
}
fn add_coverage_signal( fn add_coverage_signal(
signal: &mut NarrativeSignal, signal: &mut NarrativeSignal,
sla: &AgentCoverageSla, sla: &AgentCoverageSla,
@@ -773,6 +811,8 @@ fn narrative_payload(
}, },
"risk_level": signal.level, "risk_level": signal.level,
"risk_score": signal.score, "risk_score": signal.score,
"confidence": signal.confidence,
"classification": signal.classification,
"title": risk_title(signal.level), "title": risk_title(signal.level),
"summary": risk_summary(signal.level, signal.department.as_deref(), &signal.why), "summary": risk_summary(signal.level, signal.department.as_deref(), &signal.why),
"why": signal.why, "why": signal.why,
@@ -691,6 +691,14 @@ function renderRiskNarrative(report) {
<span class="muted">Главный вывод</span> <span class="muted">Главный вывод</span>
<strong>${ui(narrative.title || "Риск не рассчитан")}</strong> <strong>${ui(narrative.title || "Риск не рассчитан")}</strong>
</div> </div>
<div>
<span class="muted">Уверенность</span>
<strong>${ui(narrative.confidence || "unknown")}</strong>
</div>
<div>
<span class="muted">Классификация</span>
<strong>${ui(narrative.classification || "insufficient_data")}</strong>
</div>
<div> <div>
<span class="muted">Модель</span> <span class="muted">Модель</span>
<strong>${ui(narrative.model?.type || "rule_based")}</strong> <strong>${ui(narrative.model?.type || "rule_based")}</strong>
@@ -2216,7 +2224,10 @@ function renderUebaRisk(risk) {
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 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 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"}`; const confidenceLevel = risk.confidence_level || "unknown";
const classification = risk.classification || "insufficient_data";
const evidenceStatus = risk.evidence_status || "not_available";
const baselineReady = `пользователь: ${risk.user_baseline_available ? "да" : "нет"} · подразделение: ${risk.department_baseline_available ? "да" : "нет"}`;
return ` return `
<section class="card ueba-risk-card"> <section class="card ueba-risk-card">
<div class="section-head"> <div class="section-head">
@@ -2229,6 +2240,13 @@ function renderUebaRisk(risk) {
</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>
<div class="quality-grid">
<div><span class="muted">Уровень</span><strong>${ui(risk.level || "unknown")}</strong></div>
<div><span class="muted">Уверенность</span><strong>${ui(confidenceLevel)}</strong></div>
<div><span class="muted">Классификация</span><strong>${ui(classification)}</strong></div>
<div><span class="muted">Материалы</span><strong>${ui(evidenceStatus === "available" ? "доступны" : "нет")}</strong></div>
</div>
${(Array.isArray(risk.confidence_reasons) && risk.confidence_reasons.length) ? `<p class="muted small">Причины уверенности: ${risk.confidence_reasons.slice(0, 4).map(ui).join(" · ")}</p>` : ""}
<div class="list compact-list">${reasons.length ? reasons.map(item => ` <div class="list compact-list">${reasons.length ? reasons.map(item => `
<div class="row compact-row"> <div class="row compact-row">
<strong>${ui(item.label || item.code || "-")}</strong> <strong>${ui(item.label || item.code || "-")}</strong>
+196
View File
@@ -0,0 +1,196 @@
# UEBA Confidence Model
Документ описывает защитный слой интерпретации UEBA Score v1 в AWatch-rus.
Важно: этот слой не меняет scoring, weights, thresholds или severity. Он
объясняет, насколько можно доверять рассчитанному severity в текущем срезе.
## Зачем нужен слой уверенности
UEBA Score отвечает на вопрос:
```text
Насколько сильна обнаруженная аномалия?
```
Confidence отвечает на другой вопрос:
```text
Насколько достаточно данных, чтобы доверять выводу?
```
Поэтому `critical` не означает автоматически подтвержденный инцидент. При
низкой уверенности корректная трактовка:
```text
Высокая аномалия обнаружена, но требуется ручная проверка данных.
```
## Severity
Severity остается частью UEBA Score v1:
| Score | Severity | Смысл |
| --- | --- | --- |
| `0-14` | `normal` | Существенная аномалия не выявлена |
| `15-39` | `low` | Низкий риск, наблюдение |
| `40-69` | `medium` | Требуется внимание |
| `70-84` | `high` | Требуется ручная проверка |
| `85-100` | `critical` | Срочная ручная проверка |
Severity не подтверждает нарушение само по себе.
## Confidence
Поддерживаемые уровни:
| Confidence | Смысл |
| --- | --- |
| `high` | Данные свежие, покрытие достаточное, сигналы согласованы |
| `medium` | Есть частичные пропуски или ограниченное подтверждение |
| `low` | Покрытие ниже порога, отсутствуют источники или evidence |
| `unknown` | Данных недостаточно для оценки уверенности |
## Confidence Contributors
Модель учитывает шесть факторов:
| Фактор | Что проверяется |
| --- | --- |
| `agent_coverage` | Доля ожидаемых рабочих мест со свежей телеметрией |
| `data_freshness` | Свежесть данных по ожидаемым узлам |
| `telemetry_completeness` | Наличие Worktime, приложений и классификации |
| `evidence_presence` | Наличие evidence metadata или screenshots |
| `history_depth` | Глубина baseline и число samples |
| `signal_consistency` | Есть ли независимые подтверждающие сигналы |
Если хотя бы один критичный contributor находится в `low`, общий confidence
становится `low`. Это сделано намеренно: лучше потребовать ручную проверку,
чем выдать высокий score за подтвержденный инцидент.
## Classification
Classification не заменяет severity. Она показывает, как интерпретировать
severity с учетом confidence.
| Classification | Смысл |
| --- | --- |
| `confirmed_risk` | Риск как сигнал подтвержден достаточным качеством данных |
| `likely_risk` | Риск вероятен, но подтверждение неполное |
| `needs_investigation` | Высокий score есть, но уверенность недостаточна |
| `insufficient_data` | Данных недостаточно даже для уверенной оценки риска |
`confirmed_risk` не означает автоматически подтвержденный ИБ-инцидент, DLP
событие или нарушение сотрудника. Это только подтверждение качества risk signal.
## API
`GET /api/ueba` возвращает дополнительные поля:
```json
{
"severity": "critical",
"score": 100,
"confidence": "low",
"confidence_score": 0.8,
"classification": "needs_investigation",
"classification_reason": "agent_coverage:coverage_below_target",
"confidence_reasons": [
"agent_coverage:coverage_below_target"
],
"evidence_status": "not_available"
}
```
Полный объект `risk` также содержит:
- `confidence_level`;
- `classification`;
- `classification_reason`;
- `confidence_reasons`;
- `confidence_contributors`;
- `evidence_status`.
## Risk Narrative
Risk Narrative получает поля:
```json
{
"confidence": "low",
"classification": "needs_investigation"
}
```
При `low` или `unknown` confidence Risk Narrative должен говорить о ручной
проверке и полноте данных, а не о подтвержденном нарушении.
## Action Center
Если UEBA confidence низкий или classification равен `needs_investigation`,
Action Center добавляет действие:
```text
Проверить полноту данных
```
Это действие не исправляет данные автоматически и не меняет scoring. Оно
адресует оператору необходимость проверить покрытие, свежесть и completeness
до жестких управленческих выводов.
## Интерпретация для ролей
### Руководитель
Корректно:
```text
Система видит критичную аномалию, но уверенность низкая. Сначала проверяем
полноту данных, затем принимаем управленческое решение.
```
Некорректно:
```text
Critical означает доказанное нарушение.
```
### ИБ
Корректно:
```text
Critical + low confidence = приоритет ручного triage, не подтвержденный incident.
```
Некорректно:
```text
Critical UEBA автоматически является DLP/SIEM incident.
```
### Эксплуатация
Корректно:
```text
При low confidence сначала проверяются agent coverage, freshness и missing
telemetry.
```
## Ограничения
- Confidence layer не использует ML или LLM.
- Confidence layer не меняет score, severity, thresholds или weights.
- Confidence layer не подтверждает ИБ-инциденты автоматически.
- pfSense readiness остается `contract_only`, если нет фактического ingestion.
## Acceptance Interpretation
Для Pilot/Demo Freeze v1 правильная трактовка:
```text
Severity показывает силу аномалии.
Confidence показывает качество данных.
Classification показывает, можно ли делать вывод или нужен ручной разбор.
```
+6
View File
@@ -42,12 +42,18 @@ activity anomaly
- `score` - число 0-100; - `score` - число 0-100;
- `severity` - `normal`, `low`, `medium`, `high` или `critical`; - `severity` - `normal`, `low`, `medium`, `high` или `critical`;
- `confidence` - уровень уверенности `high`, `medium`, `low` или `unknown`;
- `classification` - интерпретация `confirmed_risk`, `likely_risk`,
`needs_investigation` или `insufficient_data`;
- `confidence_reasons` - причины снижения уверенности;
- `score_components` - пять компонент формулы; - `score_components` - пять компонент формулы;
- `reason_codes` - коды сработавших правил; - `reason_codes` - коды сработавших правил;
- `explanation` - человекочитаемое объяснение; - `explanation` - человекочитаемое объяснение;
- `model.ml_used=false`; - `model.ml_used=false`;
- `model.llm_used=false`. - `model.llm_used=false`.
Подробнее: [UEBA_CONFIDENCE_MODEL_RU.md](UEBA_CONFIDENCE_MODEL_RU.md).
## Ограничения ## Ограничения
UEBA v1 не является SIEM-корреляцией и не является классическим DLP. Это UEBA v1 не является SIEM-корреляцией и не является классическим DLP. Это
@@ -441,3 +441,72 @@ git diff --check
7. Документация. 7. Документация.
8. Результаты проверок. 8. Результаты проверок.
9. Подтверждение, что scoring/weights/thresholds не менялись. 9. Подтверждение, что scoring/weights/thresholds не менялись.
---
## Выполнение
Дата выполнения: 2026-06-07.
Статус: выполнено.
Добавлено:
* UEBA confidence layer;
* confidence contributors:
* `agent_coverage`;
* `data_freshness`;
* `telemetry_completeness`;
* `evidence_presence`;
* `history_depth`;
* `signal_consistency`;
* classification layer:
* `confirmed_risk`;
* `likely_risk`;
* `needs_investigation`;
* `insufficient_data`;
* поля `/api/ueba`:
* `confidence`;
* `confidence_score`;
* `classification`;
* `classification_reason`;
* `confidence_reasons`;
* `confidence_contributors`;
* `evidence_status`;
* поля Risk Narrative:
* `confidence`;
* `classification`;
* Action Center guardrail:
* `Проверить полноту данных` при low/unknown UEBA confidence или
`needs_investigation`;
* Markdown section:
* `UEBA Confidence`;
* документация:
* `docs/UEBA_CONFIDENCE_MODEL_RU.md`.
Не менялось:
* UEBA score calculation;
* UEBA weights;
* UEBA thresholds;
* severity rules;
* Risk Narrative scoring;
* Action Center scoring;
* ML/LLM/DLP/SIEM claims не добавлялись.
Ключевая интерпретация:
```text
Severity = сила аномалии
Confidence = качество данных для вывода
Classification = как трактовать severity с учетом confidence
```
Для случая `critical + low confidence` результат:
```text
classification = needs_investigation
```
Это защищает от неверной трактовки `critical` как автоматически подтвержденного
ИБ-инцидента.
@@ -73,10 +73,24 @@ async function main() {
assert(Array.isArray(kpiExplain.json?.factors), "KPI explain must include factors"); assert(Array.isArray(kpiExplain.json?.factors), "KPI explain must include factors");
assert(kpiExplain.json.factors.some((item) => item.name === "productive_activity"), "KPI explain factors must be deterministic"); assert(kpiExplain.json.factors.some((item) => item.name === "productive_activity"), "KPI explain factors must be deterministic");
const ueba = await request("/api/ueba?role=security");
assert(ueba.response.status === 200, "UEBA API must return 200");
assert(["high", "medium", "low", "unknown"].includes(ueba.json?.confidence), "UEBA must include confidence level");
assert(
["confirmed_risk", "likely_risk", "needs_investigation", "insufficient_data"].includes(ueba.json?.classification),
"UEBA must include stable classification",
);
assert(Array.isArray(ueba.json?.confidence_reasons), "UEBA must include confidence reasons");
const riskNarrative = await request("/api/risk/narrative?role=executive"); const riskNarrative = await request("/api/risk/narrative?role=executive");
assert(riskNarrative.response.status === 200, "Risk narrative must return 200"); assert(riskNarrative.response.status === 200, "Risk narrative must return 200");
assert(typeof riskNarrative.json?.risk_score === "number", "Risk narrative must include risk_score"); assert(typeof riskNarrative.json?.risk_score === "number", "Risk narrative must include risk_score");
assert(["low", "guarded", "medium", "high", "critical"].includes(riskNarrative.json?.risk_level), "Risk narrative must include stable risk_level"); assert(["low", "guarded", "medium", "high", "critical"].includes(riskNarrative.json?.risk_level), "Risk narrative must include stable risk_level");
assert(["high", "medium", "low", "unknown"].includes(riskNarrative.json?.confidence), "Risk narrative must include confidence");
assert(
["confirmed_risk", "likely_risk", "needs_investigation", "insufficient_data"].includes(riskNarrative.json?.classification),
"Risk narrative must include classification",
);
assert(Array.isArray(riskNarrative.json?.why), "Risk narrative must include why list"); assert(Array.isArray(riskNarrative.json?.why), "Risk narrative must include why list");
assert(riskNarrative.json?.model?.type === "rule_based", "Risk narrative must be rule-based"); assert(riskNarrative.json?.model?.type === "rule_based", "Risk narrative must be rule-based");
@@ -97,6 +111,7 @@ async function main() {
"query limits", "query limits",
"role gates", "role gates",
"/api/workforce/kpi/explain", "/api/workforce/kpi/explain",
"/api/ueba",
"/api/risk/narrative", "/api/risk/narrative",
"/api/actions", "/api/actions",
], ],