feat: add UEBA confidence guardrails
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -88,6 +88,7 @@ fn generate_actions(report: &Value) -> Vec<ExecutiveAction> {
|
||||
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<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>) {
|
||||
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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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> {
|
||||
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::<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]
|
||||
fn links_are_gateway_relative() {
|
||||
let links = links();
|
||||
|
||||
@@ -39,6 +39,8 @@ pub(crate) struct RiskNarrativeInputs<'a> {
|
||||
struct NarrativeSignal {
|
||||
score: u8,
|
||||
level: &'static str,
|
||||
confidence: String,
|
||||
classification: String,
|
||||
why: Vec<String>,
|
||||
evidence: Vec<Value>,
|
||||
recommended_actions: Vec<String>,
|
||||
@@ -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,
|
||||
|
||||
@@ -691,6 +691,14 @@ function renderRiskNarrative(report) {
|
||||
<span class="muted">Главный вывод</span>
|
||||
<strong>${ui(narrative.title || "Риск не рассчитан")}</strong>
|
||||
</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>
|
||||
<span class="muted">Модель</span>
|
||||
<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 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 `
|
||||
<section class="card ueba-risk-card">
|
||||
<div class="section-head">
|
||||
@@ -2229,6 +2240,13 @@ function renderUebaRisk(risk) {
|
||||
</div>
|
||||
<span class="badge ${statusClass(risk.status)}">${escapeHtml(risk.level || "unknown")} · ${escapeHtml(risk.score ?? 0)}/100</span>
|
||||
</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="row compact-row">
|
||||
<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;
|
||||
- `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. Это
|
||||
|
||||
@@ -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` как автоматически подтвержденного
|
||||
ИБ-инцидента.
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user