feat: add UEBA confidence guardrails
This commit is contained in:
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 показывает, можно ли делать вывод или нужен ручной разбор.
|
||||||
|
```
|
||||||
@@ -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",
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user