diff --git a/adk-rust/crates/detmir-portal/src/contracts/openapi.json b/adk-rust/crates/detmir-portal/src/contracts/openapi.json index 3eabca5..b3df61a 100644 --- a/adk-rust/crates/detmir-portal/src/contracts/openapi.json +++ b/adk-rust/crates/detmir-portal/src/contracts/openapi.json @@ -1265,6 +1265,24 @@ "minimum": 0, "maximum": 100 }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "unknown" + ] + }, + "classification": { + "type": "string", + "enum": [ + "confirmed_risk", + "likely_risk", + "needs_investigation", + "insufficient_data" + ] + }, "title": { "type": "string" }, @@ -1310,6 +1328,8 @@ "ok", "score", "severity", + "confidence", + "classification", "score_components", "reason_codes", "model" @@ -1339,6 +1359,54 @@ "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": { "type": "object", "required": [ diff --git a/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts index 6e7486c..1cb8af9 100644 --- a/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts +++ b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts @@ -70,6 +70,8 @@ export interface RiskNarrative { }; risk_level: "low" | "guarded" | "medium" | "high" | "critical" | string; risk_score: number; + confidence?: "high" | "medium" | "low" | "unknown" | string; + classification?: "confirmed_risk" | "likely_risk" | "needs_investigation" | "insufficient_data" | string; title: string; summary: string; why: string[]; @@ -257,6 +259,13 @@ export interface UebaResponse { score: number | null; severity: "normal" | "low" | "medium" | "high" | "critical" | 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: { activity_anomaly: number; time_anomaly: number; diff --git a/adk-rust/crates/detmir-portal/src/executive_actions.rs b/adk-rust/crates/detmir-portal/src/executive_actions.rs index 4c7cf22..078055c 100644 --- a/adk-rust/crates/detmir-portal/src/executive_actions.rs +++ b/adk-rust/crates/detmir-portal/src/executive_actions.rs @@ -88,6 +88,7 @@ fn generate_actions(report: &Value) -> Vec { let mut actions = Vec::new(); add_workforce_kpi_action(report, &mut actions); add_coverage_action(report, &mut actions); + add_ueba_confidence_action(report, &mut actions); add_ueba_action(report, &mut actions); add_security_correlation_action(report, &mut actions); add_incident_candidate_action(report, &mut actions); @@ -186,6 +187,68 @@ fn add_coverage_action(report: &Value, actions: &mut Vec) { }); } +fn add_ueba_confidence_action(report: &Value, actions: &mut Vec) { + 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::>() + }) + .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) { let score = report .pointer("/ueba_risk/score") @@ -389,6 +452,13 @@ mod tests { .iter() .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")); } diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index a15cff4..a79a34f 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -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), "severity": report.pointer("/ueba_risk/level").cloned().unwrap_or_else(|| json!("normal")), "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!({ "activity_anomaly": 0, "time_anomaly": 0, @@ -6370,6 +6380,288 @@ fn ueba_confidence( (confidence.clamp(0.0, 1.0) * 100.0).round() / 100.0 } +fn confidence_contributor( + name: &str, + level: &str, + reason: &str, + detail: impl Into, +) -> 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, + Vec, + &'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::>(); + 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::>(); + 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 { let mut out = Vec::new(); for reason in reasons { @@ -6578,6 +6870,21 @@ fn build_ueba_risk( let (level, status) = ueba_risk_level(score); let score_components = ueba_score_components(&reasons, score); 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( metrics, workforce_policy, @@ -6598,6 +6905,13 @@ fn build_ueba_risk( "score_components": score_components, "reason_codes": reason_codes, "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, "baseline_status": ueba_baseline .get("baseline_status") @@ -7331,6 +7645,20 @@ fn append_risk_narrative_markdown(text: &mut String, narrative: &Value) { .and_then(Value::as_u64) .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!( "- Вывод: {}\n", narrative @@ -8094,6 +8422,18 @@ fn append_ueba_risk_markdown(text: &mut String, risk: &Value) { .unwrap_or(0.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!( "- Обычный профиль: {}\n", 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) { 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"); let reasons = risk .get("reasons") @@ -10953,6 +11324,9 @@ mod tests { let ueba = build_ueba_api_payload(&report, PortalRole::Security); assert_eq!(ueba["score"], 55); 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"]["application_anomaly"], 20); assert_eq!(ueba["reason_codes"][0], "activity_anomaly"); @@ -10970,6 +11344,96 @@ mod tests { 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::>(); + 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] fn links_are_gateway_relative() { let links = links(); diff --git a/adk-rust/crates/detmir-portal/src/risk_narrative.rs b/adk-rust/crates/detmir-portal/src/risk_narrative.rs index e15848d..373e60d 100644 --- a/adk-rust/crates/detmir-portal/src/risk_narrative.rs +++ b/adk-rust/crates/detmir-portal/src/risk_narrative.rs @@ -39,6 +39,8 @@ pub(crate) struct RiskNarrativeInputs<'a> { struct NarrativeSignal { score: u8, level: &'static str, + confidence: String, + classification: String, why: Vec, evidence: Vec, recommended_actions: Vec, @@ -62,6 +64,8 @@ pub(crate) fn build_risk_narrative( let mut signal = NarrativeSignal { score: 0, level: "low", + confidence: "unknown".to_string(), + classification: "insufficient_data".to_string(), why: Vec::new(), evidence: 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_ueba_signal(&mut signal, inputs.ueba_risk); + add_ueba_confidence_guardrails(&mut signal, inputs.ueba_risk); add_coverage_signal( &mut signal, inputs.agent_coverage_sla, @@ -105,6 +110,8 @@ pub(crate) fn build_risk_narrative_from_report( let mut signal = NarrativeSignal { score: 0, level: "low", + confidence: "unknown".to_string(), + classification: "insufficient_data".to_string(), why: Vec::new(), evidence: 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), ); 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); let selected_heatmap = select_heatmap_value( 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( signal: &mut NarrativeSignal, sla: &AgentCoverageSla, @@ -773,6 +811,8 @@ fn narrative_payload( }, "risk_level": signal.level, "risk_score": signal.score, + "confidence": signal.confidence, + "classification": signal.classification, "title": risk_title(signal.level), "summary": risk_summary(signal.level, signal.department.as_deref(), &signal.why), "why": signal.why, diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 5e82ae3..a4575c9 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -691,6 +691,14 @@ function renderRiskNarrative(report) { Главный вывод ${ui(narrative.title || "Риск не рассчитан")} +
+ Уверенность + ${ui(narrative.confidence || "unknown")} +
+
+ Классификация + ${ui(narrative.classification || "insufficient_data")} +
Модель ${ui(narrative.model?.type || "rule_based")} @@ -2216,7 +2224,10 @@ function renderUebaRisk(risk) { const reasons = Array.isArray(risk.reasons) ? risk.reasons.slice(0, 12) : []; const sources = Array.isArray(risk.risk_sources) ? risk.risk_sources.join(", ") : "-"; const confidence = Number.isFinite(Number(risk.confidence)) ? `${Math.round(Number(risk.confidence) * 100)}%` : "0%"; - const baselineReady = `user: ${risk.user_baseline_available ? "yes" : "no"} · dept: ${risk.department_baseline_available ? "yes" : "no"}`; + 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 `
@@ -2229,6 +2240,13 @@ function renderUebaRisk(risk) {
${escapeHtml(risk.level || "unknown")} · ${escapeHtml(risk.score ?? 0)}/100
+
+
Уровень${ui(risk.level || "unknown")}
+
Уверенность${ui(confidenceLevel)}
+
Классификация${ui(classification)}
+
Материалы${ui(evidenceStatus === "available" ? "доступны" : "нет")}
+
+ ${(Array.isArray(risk.confidence_reasons) && risk.confidence_reasons.length) ? `

Причины уверенности: ${risk.confidence_reasons.slice(0, 4).map(ui).join(" · ")}

` : ""}
${reasons.length ? reasons.map(item => `
${ui(item.label || item.code || "-")} diff --git a/docs/UEBA_CONFIDENCE_MODEL_RU.md b/docs/UEBA_CONFIDENCE_MODEL_RU.md new file mode 100644 index 0000000..9b5de97 --- /dev/null +++ b/docs/UEBA_CONFIDENCE_MODEL_RU.md @@ -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 показывает, можно ли делать вывод или нужен ручной разбор. +``` diff --git a/docs/UEBA_SCORE_RU.md b/docs/UEBA_SCORE_RU.md index 762432d..439ecf6 100644 --- a/docs/UEBA_SCORE_RU.md +++ b/docs/UEBA_SCORE_RU.md @@ -42,12 +42,18 @@ activity anomaly - `score` - число 0-100; - `severity` - `normal`, `low`, `medium`, `high` или `critical`; +- `confidence` - уровень уверенности `high`, `medium`, `low` или `unknown`; +- `classification` - интерпретация `confirmed_risk`, `likely_risk`, + `needs_investigation` или `insufficient_data`; +- `confidence_reasons` - причины снижения уверенности; - `score_components` - пять компонент формулы; - `reason_codes` - коды сработавших правил; - `explanation` - человекочитаемое объяснение; - `model.ml_used=false`; - `model.llm_used=false`. +Подробнее: [UEBA_CONFIDENCE_MODEL_RU.md](UEBA_CONFIDENCE_MODEL_RU.md). + ## Ограничения UEBA v1 не является SIEM-корреляцией и не является классическим DLP. Это diff --git a/docs/roadmap/TASK_016_UEBA_CONFIDENCE_GUARDRAILS.md b/docs/roadmap/TASK_016_UEBA_CONFIDENCE_GUARDRAILS.md index e5793ba..124d826 100644 --- a/docs/roadmap/TASK_016_UEBA_CONFIDENCE_GUARDRAILS.md +++ b/docs/roadmap/TASK_016_UEBA_CONFIDENCE_GUARDRAILS.md @@ -441,3 +441,72 @@ git diff --check 7. Документация. 8. Результаты проверок. 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` как автоматически подтвержденного +ИБ-инцидента. diff --git a/scripts/awatch-production-hardening-smoke.mjs b/scripts/awatch-production-hardening-smoke.mjs index 3326e2c..a40aede 100644 --- a/scripts/awatch-production-hardening-smoke.mjs +++ b/scripts/awatch-production-hardening-smoke.mjs @@ -73,10 +73,24 @@ async function main() { 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"); + 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"); assert(riskNarrative.response.status === 200, "Risk narrative must return 200"); 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(["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(riskNarrative.json?.model?.type === "rule_based", "Risk narrative must be rule-based"); @@ -97,6 +111,7 @@ async function main() { "query limits", "role gates", "/api/workforce/kpi/explain", + "/api/ueba", "/api/risk/narrative", "/api/actions", ],