920 lines
30 KiB
Rust
920 lines
30 KiB
Rust
use serde_json::{Value, json};
|
|
|
|
use crate::production::limits::parse_query_date;
|
|
use crate::{
|
|
AgentCoverageSla, ExecutiveDashboard, PortalRole, RiskHeatmapItem, RiskIncidentCandidate,
|
|
SecurityCorrelationItem, SecurityEventsSummary, Snapshot, query_param, role_envelope,
|
|
};
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub(crate) struct RiskNarrativeQuery {
|
|
date: Option<String>,
|
|
department: Option<String>,
|
|
module: Option<String>,
|
|
}
|
|
|
|
impl RiskNarrativeQuery {
|
|
pub(crate) fn from_url(url: &str) -> Self {
|
|
Self {
|
|
date: query_param(url, "date").filter(|value| parse_query_date(value).is_some()),
|
|
department: query_param(url, "department").filter(|value| !value.trim().is_empty()),
|
|
module: query_param(url, "module").filter(|value| !value.trim().is_empty()),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) struct RiskNarrativeInputs<'a> {
|
|
pub(crate) snapshot: &'a Snapshot,
|
|
pub(crate) workforce_kpi_explain: &'a Value,
|
|
pub(crate) ueba_risk: &'a Value,
|
|
pub(crate) agent_coverage_sla: &'a AgentCoverageSla,
|
|
pub(crate) risk_heatmap: &'a [RiskHeatmapItem],
|
|
pub(crate) security_correlation: &'a [SecurityCorrelationItem],
|
|
pub(crate) risk_incident_candidates: &'a [RiskIncidentCandidate],
|
|
pub(crate) executive_dashboard: &'a ExecutiveDashboard,
|
|
pub(crate) security_events_summary: &'a SecurityEventsSummary,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct NarrativeSignal {
|
|
score: u8,
|
|
level: &'static str,
|
|
why: Vec<String>,
|
|
evidence: Vec<Value>,
|
|
recommended_actions: Vec<String>,
|
|
limitations: Vec<String>,
|
|
department: Option<String>,
|
|
generated_at_utc: String,
|
|
}
|
|
|
|
pub(crate) fn build_risk_narrative(
|
|
inputs: RiskNarrativeInputs<'_>,
|
|
role: PortalRole,
|
|
query: &RiskNarrativeQuery,
|
|
) -> Value {
|
|
let selected_heatmap = select_heatmap_item(inputs.risk_heatmap, query.department.as_deref());
|
|
let selected_correlation = select_correlation_item(
|
|
inputs.security_correlation,
|
|
selected_heatmap
|
|
.map(|item| item.department.as_str())
|
|
.or(query.department.as_deref()),
|
|
);
|
|
let mut signal = NarrativeSignal {
|
|
score: 0,
|
|
level: "low",
|
|
why: Vec::new(),
|
|
evidence: Vec::new(),
|
|
recommended_actions: Vec::new(),
|
|
limitations: risk_narrative_limitations(),
|
|
department: selected_heatmap
|
|
.map(|item| item.department.clone())
|
|
.or_else(|| query.department.clone()),
|
|
generated_at_utc: inputs.snapshot.generated_at_utc.clone(),
|
|
};
|
|
|
|
add_workforce_kpi_signal(&mut signal, inputs.workforce_kpi_explain);
|
|
add_ueba_signal(&mut signal, inputs.ueba_risk);
|
|
add_coverage_signal(
|
|
&mut signal,
|
|
inputs.agent_coverage_sla,
|
|
inputs.workforce_kpi_explain,
|
|
);
|
|
add_heatmap_signal(&mut signal, selected_heatmap);
|
|
add_security_correlation_signal(&mut signal, selected_correlation);
|
|
let department_scope = signal.department.clone();
|
|
add_incident_candidate_signal(
|
|
&mut signal,
|
|
inputs.risk_incident_candidates,
|
|
department_scope.as_deref(),
|
|
);
|
|
add_security_events_signal(&mut signal, inputs.security_events_summary);
|
|
add_remote_activity_signal(&mut signal, inputs.workforce_kpi_explain);
|
|
add_pfsense_contract_signal(&mut signal);
|
|
|
|
signal.score = signal.score.min(100);
|
|
signal.level = risk_level(signal.score);
|
|
finalize_recommendations(&mut signal, inputs.executive_dashboard);
|
|
narrative_payload(signal, role, query)
|
|
}
|
|
|
|
pub(crate) fn build_risk_narrative_from_report(
|
|
report: &Value,
|
|
role: PortalRole,
|
|
query: &RiskNarrativeQuery,
|
|
) -> Value {
|
|
let mut signal = NarrativeSignal {
|
|
score: 0,
|
|
level: "low",
|
|
why: Vec::new(),
|
|
evidence: Vec::new(),
|
|
recommended_actions: Vec::new(),
|
|
limitations: risk_narrative_limitations(),
|
|
department: query.department.clone(),
|
|
generated_at_utc: report
|
|
.get("generated_at_utc")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("unknown")
|
|
.to_string(),
|
|
};
|
|
|
|
add_workforce_kpi_signal(
|
|
&mut signal,
|
|
report.get("workforce_kpi_explain").unwrap_or(&Value::Null),
|
|
);
|
|
add_ueba_signal(&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),
|
|
query.department.as_deref(),
|
|
);
|
|
if let Some(item) = selected_heatmap {
|
|
if signal.department.is_none() {
|
|
signal.department = item
|
|
.get("department")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string);
|
|
}
|
|
add_heatmap_value_signal(&mut signal, item);
|
|
}
|
|
let selected_correlation = select_correlation_value(
|
|
report.get("security_correlation").and_then(Value::as_array),
|
|
signal.department.as_deref().or(query.department.as_deref()),
|
|
);
|
|
if let Some(item) = selected_correlation {
|
|
add_security_correlation_value_signal(&mut signal, item);
|
|
}
|
|
add_incident_candidate_value_signal(
|
|
&mut signal,
|
|
report
|
|
.get("risk_incident_candidates")
|
|
.and_then(Value::as_array)
|
|
.map(Vec::as_slice)
|
|
.unwrap_or(&[]),
|
|
);
|
|
add_security_events_value_signal(
|
|
&mut signal,
|
|
report
|
|
.get("security_events_summary")
|
|
.unwrap_or(&Value::Null),
|
|
);
|
|
add_remote_activity_signal(
|
|
&mut signal,
|
|
report.get("workforce_kpi_explain").unwrap_or(&Value::Null),
|
|
);
|
|
add_pfsense_contract_signal(&mut signal);
|
|
|
|
signal.score = signal.score.min(100);
|
|
signal.level = risk_level(signal.score);
|
|
finalize_recommendations_from_report(&mut signal, report);
|
|
narrative_payload(signal, role, query)
|
|
}
|
|
|
|
fn select_heatmap_item<'a>(
|
|
items: &'a [RiskHeatmapItem],
|
|
department: Option<&str>,
|
|
) -> Option<&'a RiskHeatmapItem> {
|
|
if let Some(department) = department {
|
|
items
|
|
.iter()
|
|
.find(|item| item.department.eq_ignore_ascii_case(department))
|
|
} else {
|
|
items.first()
|
|
}
|
|
}
|
|
|
|
fn select_correlation_item<'a>(
|
|
items: &'a [SecurityCorrelationItem],
|
|
department: Option<&str>,
|
|
) -> Option<&'a SecurityCorrelationItem> {
|
|
department
|
|
.and_then(|department| {
|
|
items
|
|
.iter()
|
|
.find(|item| item.department.eq_ignore_ascii_case(department))
|
|
})
|
|
.or_else(|| items.iter().max_by_key(|item| item.correlation_score))
|
|
}
|
|
|
|
fn select_heatmap_value<'a>(
|
|
items: Option<&'a Vec<Value>>,
|
|
department: Option<&str>,
|
|
) -> Option<&'a Value> {
|
|
let items = items?;
|
|
if let Some(department) = department {
|
|
items.iter().find(|item| {
|
|
item.get("department")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|value| value.eq_ignore_ascii_case(department))
|
|
})
|
|
} else {
|
|
items.first()
|
|
}
|
|
}
|
|
|
|
fn select_correlation_value<'a>(
|
|
items: Option<&'a Vec<Value>>,
|
|
department: Option<&str>,
|
|
) -> Option<&'a Value> {
|
|
let items = items?;
|
|
department
|
|
.and_then(|department| {
|
|
items.iter().find(|item| {
|
|
item.get("department")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|value| value.eq_ignore_ascii_case(department))
|
|
})
|
|
})
|
|
.or_else(|| {
|
|
items.iter().max_by_key(|item| {
|
|
item.get("correlation_score")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0)
|
|
})
|
|
})
|
|
}
|
|
|
|
fn add_workforce_kpi_signal(signal: &mut NarrativeSignal, explain: &Value) {
|
|
let kpi_score = explain.get("kpi_score").and_then(Value::as_u64);
|
|
let confidence = explain
|
|
.get("confidence")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("low");
|
|
if let Some(score) = kpi_score {
|
|
let risk = 100_u64.saturating_sub(score.min(100));
|
|
if risk >= 50 {
|
|
add_score(
|
|
signal,
|
|
30,
|
|
"Индекс активности существенно ниже рабочего ориентира",
|
|
);
|
|
} else if risk >= 30 {
|
|
add_score(signal, 20, "Индекс активности ниже среднего уровня");
|
|
} else if risk >= 15 {
|
|
add_score(signal, 10, "Индекс активности немного ниже целевого уровня");
|
|
}
|
|
signal.evidence.push(evidence(
|
|
"workforce_kpi",
|
|
"Индекс активности",
|
|
&format!("{score}%"),
|
|
score_to_severity(score as u8),
|
|
));
|
|
} else {
|
|
add_score(signal, 12, "Индекс активности не рассчитан");
|
|
signal.evidence.push(evidence(
|
|
"workforce_kpi",
|
|
"Индекс активности",
|
|
"нет данных",
|
|
"medium",
|
|
));
|
|
}
|
|
match confidence {
|
|
"low" => add_score(signal, 20, "Низкое доверие к KPI"),
|
|
"medium" => add_score(
|
|
signal,
|
|
10,
|
|
"KPI рассчитан с частичными ограничениями данных",
|
|
),
|
|
_ => {}
|
|
}
|
|
signal.evidence.push(evidence(
|
|
"workforce_kpi",
|
|
"Доверие к KPI",
|
|
confidence,
|
|
match confidence {
|
|
"low" => "high",
|
|
"medium" => "medium",
|
|
_ => "low",
|
|
},
|
|
));
|
|
if let Some(missing) = explain
|
|
.pointer("/coverage/missing_sources")
|
|
.and_then(Value::as_array)
|
|
{
|
|
let count = missing.len();
|
|
if count > 0 {
|
|
add_score(
|
|
signal,
|
|
(count as u8).saturating_mul(6).min(18),
|
|
"Есть пропуски источников данных",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn add_ueba_signal(signal: &mut NarrativeSignal, risk: &Value) {
|
|
let level = risk
|
|
.get("level")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("normal");
|
|
let score = risk
|
|
.get("score")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0)
|
|
.min(100);
|
|
match level {
|
|
"critical" => add_score(signal, 30, "UEBA score находится на критическом уровне"),
|
|
"high" => add_score(signal, 22, "UEBA score повышен до high"),
|
|
"medium" => add_score(signal, 12, "UEBA score находится на среднем уровне"),
|
|
"low" => add_score(signal, 5, "UEBA score показывает низкий, но ненулевой риск"),
|
|
_ => {}
|
|
}
|
|
signal.evidence.push(evidence(
|
|
"ueba",
|
|
"UEBA score",
|
|
&format!("{level} · {score}/100"),
|
|
match level {
|
|
"critical" => "critical",
|
|
"high" => "high",
|
|
"medium" => "medium",
|
|
_ => "low",
|
|
},
|
|
));
|
|
}
|
|
|
|
fn add_coverage_signal(
|
|
signal: &mut NarrativeSignal,
|
|
sla: &AgentCoverageSla,
|
|
workforce_kpi_explain: &Value,
|
|
) {
|
|
let coverage = if sla.expected_nodes > 0 {
|
|
Some(sla.coverage_pct)
|
|
} else {
|
|
workforce_kpi_explain
|
|
.pointer("/coverage/agent_coverage_percent")
|
|
.and_then(Value::as_u64)
|
|
.map(|value| value.min(100) as u8)
|
|
};
|
|
if let Some(value) = coverage {
|
|
if value < 50 {
|
|
add_score(
|
|
signal,
|
|
25,
|
|
"Покрытие агентов критически ниже целевого уровня",
|
|
);
|
|
} else if value < 75 {
|
|
add_score(signal, 18, "Покрытие агентов ниже целевого уровня");
|
|
} else if value < 90 {
|
|
add_score(signal, 8, "Покрытие агентов требует внимания");
|
|
}
|
|
signal.evidence.push(evidence(
|
|
"coverage",
|
|
"Покрытие агентов",
|
|
&format!("{value}%"),
|
|
if value < 50 {
|
|
"high"
|
|
} else if value < 75 {
|
|
"medium"
|
|
} else {
|
|
"low"
|
|
},
|
|
));
|
|
} else {
|
|
add_score(signal, 10, "Покрытие агентов не подтверждено");
|
|
}
|
|
}
|
|
|
|
fn add_coverage_from_report_signal(signal: &mut NarrativeSignal, report: &Value) {
|
|
let coverage = report
|
|
.pointer("/agent_coverage_sla/coverage_pct")
|
|
.and_then(Value::as_u64)
|
|
.or_else(|| {
|
|
report
|
|
.pointer("/workforce_kpi_explain/coverage/agent_coverage_percent")
|
|
.and_then(Value::as_u64)
|
|
})
|
|
.map(|value| value.min(100) as u8);
|
|
if let Some(value) = coverage {
|
|
if value < 50 {
|
|
add_score(
|
|
signal,
|
|
25,
|
|
"Покрытие агентов критически ниже целевого уровня",
|
|
);
|
|
} else if value < 75 {
|
|
add_score(signal, 18, "Покрытие агентов ниже целевого уровня");
|
|
} else if value < 90 {
|
|
add_score(signal, 8, "Покрытие агентов требует внимания");
|
|
}
|
|
signal.evidence.push(evidence(
|
|
"coverage",
|
|
"Покрытие агентов",
|
|
&format!("{value}%"),
|
|
if value < 50 {
|
|
"high"
|
|
} else if value < 75 {
|
|
"medium"
|
|
} else {
|
|
"low"
|
|
},
|
|
));
|
|
}
|
|
}
|
|
|
|
fn add_heatmap_signal(signal: &mut NarrativeSignal, item: Option<&RiskHeatmapItem>) {
|
|
let Some(item) = item else {
|
|
return;
|
|
};
|
|
match item.heat_level.as_str() {
|
|
"CRITICAL" => add_score(signal, 25, "Карта рисков показывает критический уровень"),
|
|
"HIGH" => add_score(signal, 18, "Карта рисков показывает высокий уровень"),
|
|
"MEDIUM" => add_score(signal, 10, "Карта рисков показывает средний уровень"),
|
|
_ => {}
|
|
}
|
|
signal.evidence.push(evidence(
|
|
"risk_heatmap",
|
|
"Карта рисков",
|
|
&format!("{} · {}", item.department, item.heat_level),
|
|
heatmap_severity(&item.heat_level),
|
|
));
|
|
}
|
|
|
|
fn add_heatmap_value_signal(signal: &mut NarrativeSignal, item: &Value) {
|
|
let level = item
|
|
.get("heat_level")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("UNKNOWN");
|
|
match level {
|
|
"CRITICAL" => add_score(signal, 25, "Карта рисков показывает критический уровень"),
|
|
"HIGH" => add_score(signal, 18, "Карта рисков показывает высокий уровень"),
|
|
"MEDIUM" => add_score(signal, 10, "Карта рисков показывает средний уровень"),
|
|
_ => {}
|
|
}
|
|
let department = item
|
|
.get("department")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("-");
|
|
signal.evidence.push(evidence(
|
|
"risk_heatmap",
|
|
"Карта рисков",
|
|
&format!("{department} · {level}"),
|
|
heatmap_severity(level),
|
|
));
|
|
}
|
|
|
|
fn add_security_correlation_signal(
|
|
signal: &mut NarrativeSignal,
|
|
item: Option<&SecurityCorrelationItem>,
|
|
) {
|
|
let Some(item) = item else {
|
|
return;
|
|
};
|
|
let score = item.correlation_score;
|
|
if score >= 85 {
|
|
add_score(signal, 22, "Связь рисков и активности высокая");
|
|
} else if score >= 60 {
|
|
add_score(signal, 15, "Связь рисков и активности повышена");
|
|
} else if score >= 35 {
|
|
add_score(signal, 8, "Есть умеренная связь рисков и активности");
|
|
}
|
|
signal.evidence.push(evidence(
|
|
"security_correlation",
|
|
"Связь рисков и активности",
|
|
&format!("{score}/100"),
|
|
if score >= 85 {
|
|
"high"
|
|
} else if score >= 60 {
|
|
"medium"
|
|
} else {
|
|
"low"
|
|
},
|
|
));
|
|
}
|
|
|
|
fn add_security_correlation_value_signal(signal: &mut NarrativeSignal, item: &Value) {
|
|
let score = item
|
|
.get("correlation_score")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0)
|
|
.min(100) as u8;
|
|
if score >= 85 {
|
|
add_score(signal, 22, "Связь рисков и активности высокая");
|
|
} else if score >= 60 {
|
|
add_score(signal, 15, "Связь рисков и активности повышена");
|
|
} else if score >= 35 {
|
|
add_score(signal, 8, "Есть умеренная связь рисков и активности");
|
|
}
|
|
signal.evidence.push(evidence(
|
|
"security_correlation",
|
|
"Связь рисков и активности",
|
|
&format!("{score}/100"),
|
|
if score >= 85 {
|
|
"high"
|
|
} else if score >= 60 {
|
|
"medium"
|
|
} else {
|
|
"low"
|
|
},
|
|
));
|
|
}
|
|
|
|
fn add_incident_candidate_signal(
|
|
signal: &mut NarrativeSignal,
|
|
candidates: &[RiskIncidentCandidate],
|
|
department: Option<&str>,
|
|
) {
|
|
let count = candidates
|
|
.iter()
|
|
.filter(|item| {
|
|
department.is_none_or(|department| {
|
|
item.department
|
|
.as_deref()
|
|
.is_some_and(|value| value.eq_ignore_ascii_case(department))
|
|
})
|
|
})
|
|
.filter(|item| {
|
|
matches!(
|
|
item.risk_level.as_deref().unwrap_or("UNKNOWN"),
|
|
"HIGH" | "CRITICAL"
|
|
)
|
|
})
|
|
.count();
|
|
if count > 0 {
|
|
add_score(
|
|
signal,
|
|
(count as u8).saturating_mul(10).min(25),
|
|
"Есть кандидаты на ручную проверку",
|
|
);
|
|
}
|
|
signal.evidence.push(evidence(
|
|
"incident_candidates",
|
|
"Кандидаты на проверку",
|
|
&count.to_string(),
|
|
if count > 0 { "medium" } else { "low" },
|
|
));
|
|
}
|
|
|
|
fn add_incident_candidate_value_signal(signal: &mut NarrativeSignal, candidates: &[Value]) {
|
|
let count = candidates
|
|
.iter()
|
|
.filter(|item| {
|
|
matches!(
|
|
item.get("risk_level")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("UNKNOWN"),
|
|
"HIGH" | "CRITICAL"
|
|
)
|
|
})
|
|
.count();
|
|
if count > 0 {
|
|
add_score(
|
|
signal,
|
|
(count as u8).saturating_mul(10).min(25),
|
|
"Есть кандидаты на ручную проверку",
|
|
);
|
|
}
|
|
signal.evidence.push(evidence(
|
|
"incident_candidates",
|
|
"Кандидаты на проверку",
|
|
&count.to_string(),
|
|
if count > 0 { "medium" } else { "low" },
|
|
));
|
|
}
|
|
|
|
fn add_security_events_signal(signal: &mut NarrativeSignal, summary: &SecurityEventsSummary) {
|
|
if summary.fallback_used {
|
|
add_score(
|
|
signal,
|
|
8,
|
|
"Агрегированные события безопасности доступны в резервном режиме",
|
|
);
|
|
}
|
|
if summary.events_24h > 0 {
|
|
add_score(
|
|
signal,
|
|
summary.events_24h.saturating_mul(3).min(15) as u8,
|
|
"Есть агрегированные события безопасности за 24 часа",
|
|
);
|
|
}
|
|
signal.evidence.push(evidence(
|
|
"security_events",
|
|
"События безопасности",
|
|
&summary.events_24h.to_string(),
|
|
if summary.events_24h > 0 {
|
|
"medium"
|
|
} else {
|
|
"low"
|
|
},
|
|
));
|
|
}
|
|
|
|
fn add_security_events_value_signal(signal: &mut NarrativeSignal, summary: &Value) {
|
|
let events = summary
|
|
.get("events_24h")
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0);
|
|
if summary
|
|
.get("fallback_used")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
{
|
|
add_score(
|
|
signal,
|
|
8,
|
|
"Агрегированные события безопасности доступны в резервном режиме",
|
|
);
|
|
}
|
|
if events > 0 {
|
|
add_score(
|
|
signal,
|
|
events.saturating_mul(3).min(15) as u8,
|
|
"Есть агрегированные события безопасности за 24 часа",
|
|
);
|
|
}
|
|
signal.evidence.push(evidence(
|
|
"security_events",
|
|
"События безопасности",
|
|
&events.to_string(),
|
|
if events > 0 { "medium" } else { "low" },
|
|
));
|
|
}
|
|
|
|
fn add_remote_activity_signal(signal: &mut NarrativeSignal, explain: &Value) {
|
|
let factors = explain
|
|
.get("factors")
|
|
.and_then(Value::as_array)
|
|
.map(Vec::as_slice)
|
|
.unwrap_or(&[]);
|
|
let afterhours = factors
|
|
.iter()
|
|
.find(|item| item.get("name").and_then(Value::as_str) == Some("afterhours_activity"));
|
|
if let Some(item) = afterhours {
|
|
let impact = item.get("impact").and_then(Value::as_str).unwrap_or("0");
|
|
if impact.starts_with('-') && impact != "-0" {
|
|
add_score(signal, 8, "Есть признаки активности вне рабочего времени");
|
|
signal.evidence.push(evidence(
|
|
"workforce_kpi",
|
|
"Активность вне рабочего времени",
|
|
impact,
|
|
"medium",
|
|
));
|
|
}
|
|
}
|
|
if factors.iter().any(|item| {
|
|
item.get("name").and_then(Value::as_str) == Some("remote_session_activity")
|
|
&& item.get("impact").and_then(Value::as_str).unwrap_or("0") != "0"
|
|
}) {
|
|
signal.evidence.push(evidence(
|
|
"workforce_kpi",
|
|
"Удаленные сессии",
|
|
"обнаружены",
|
|
"low",
|
|
));
|
|
}
|
|
}
|
|
|
|
fn add_pfsense_contract_signal(signal: &mut NarrativeSignal) {
|
|
add_score(signal, 3, "pfSense находится в contract_only режиме");
|
|
signal.evidence.push(evidence(
|
|
"pfsense",
|
|
"pfSense readiness",
|
|
"contract_only",
|
|
"low",
|
|
));
|
|
}
|
|
|
|
fn add_score(signal: &mut NarrativeSignal, points: u8, reason: &str) {
|
|
signal.score = signal.score.saturating_add(points);
|
|
if !signal.why.iter().any(|item| item == reason) {
|
|
signal.why.push(reason.to_string());
|
|
}
|
|
}
|
|
|
|
fn finalize_recommendations(signal: &mut NarrativeSignal, dashboard: &ExecutiveDashboard) {
|
|
finalize_common_recommendations(signal);
|
|
if dashboard
|
|
.critical_candidates
|
|
.as_ref()
|
|
.is_some_and(|items| !items.is_empty())
|
|
{
|
|
push_unique(
|
|
&mut signal.recommended_actions,
|
|
"Передать кандидатов на проверку в контур ИБ",
|
|
);
|
|
}
|
|
}
|
|
|
|
fn finalize_recommendations_from_report(signal: &mut NarrativeSignal, report: &Value) {
|
|
finalize_common_recommendations(signal);
|
|
if report
|
|
.get("risk_incident_candidates")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|items| !items.is_empty())
|
|
{
|
|
push_unique(
|
|
&mut signal.recommended_actions,
|
|
"Передать кандидатов на проверку в контур ИБ",
|
|
);
|
|
}
|
|
}
|
|
|
|
fn finalize_common_recommendations(signal: &mut NarrativeSignal) {
|
|
if signal
|
|
.why
|
|
.iter()
|
|
.any(|item| item.contains("Покрытие") || item.contains("пропуски"))
|
|
{
|
|
push_unique(
|
|
&mut signal.recommended_actions,
|
|
"Проверить подразделения с низким покрытием данных",
|
|
);
|
|
}
|
|
if signal.why.iter().any(|item| item.contains("UEBA")) {
|
|
push_unique(
|
|
&mut signal.recommended_actions,
|
|
"Передать security-события в контур ИБ для анализа",
|
|
);
|
|
}
|
|
if signal
|
|
.evidence
|
|
.iter()
|
|
.any(|item| item.get("label").and_then(Value::as_str) == Some("Удаленные сессии"))
|
|
{
|
|
push_unique(
|
|
&mut signal.recommended_actions,
|
|
"Проверить рост удаленных сессий",
|
|
);
|
|
}
|
|
if signal.recommended_actions.is_empty() {
|
|
push_unique(
|
|
&mut signal.recommended_actions,
|
|
"Продолжить наблюдение и контролировать полноту данных",
|
|
);
|
|
}
|
|
}
|
|
|
|
fn push_unique(items: &mut Vec<String>, value: &str) {
|
|
if !items.iter().any(|item| item == value) {
|
|
items.push(value.to_string());
|
|
}
|
|
}
|
|
|
|
fn risk_narrative_limitations() -> Vec<String> {
|
|
vec![
|
|
"pfSense находится в contract_only режиме".to_string(),
|
|
"Risk Narrative не является ML-прогнозом".to_string(),
|
|
"Risk Narrative не подтверждает нарушение без ручной проверки".to_string(),
|
|
]
|
|
}
|
|
|
|
fn narrative_payload(
|
|
signal: NarrativeSignal,
|
|
role: PortalRole,
|
|
query: &RiskNarrativeQuery,
|
|
) -> Value {
|
|
json!({
|
|
"ok": true,
|
|
"role_context": role_envelope(role, "risk_narrative"),
|
|
"scope": if signal.department.is_some() { "department" } else { "aggregate" },
|
|
"query": {
|
|
"date": query.date,
|
|
"department": query.department,
|
|
"module": query.module,
|
|
"employee_id_supported": false
|
|
},
|
|
"risk_level": signal.level,
|
|
"risk_score": signal.score,
|
|
"title": risk_title(signal.level),
|
|
"summary": risk_summary(signal.level, signal.department.as_deref(), &signal.why),
|
|
"why": signal.why,
|
|
"evidence": signal.evidence,
|
|
"recommended_actions": signal.recommended_actions,
|
|
"limitations": signal.limitations,
|
|
"model": {
|
|
"type": "rule_based",
|
|
"ml": false,
|
|
"llm": false,
|
|
"predictive": false,
|
|
"version": "risk-narrative-v1"
|
|
},
|
|
"generated_at_utc": signal.generated_at_utc,
|
|
})
|
|
}
|
|
|
|
fn risk_level(score: u8) -> &'static str {
|
|
match score {
|
|
0..=24 => "low",
|
|
25..=49 => "guarded",
|
|
50..=74 => "medium",
|
|
75..=89 => "high",
|
|
_ => "critical",
|
|
}
|
|
}
|
|
|
|
fn risk_title(level: &str) -> &'static str {
|
|
match level {
|
|
"critical" => "Критический рост операционного риска",
|
|
"high" => "Высокий операционный риск",
|
|
"medium" => "Умеренный рост операционного риска",
|
|
"guarded" => "Риск требует наблюдения",
|
|
_ => "Риск низкий",
|
|
}
|
|
}
|
|
|
|
fn risk_summary(level: &str, department: Option<&str>, why: &[String]) -> String {
|
|
let scope = department
|
|
.map(|value| format!("в зоне {value}"))
|
|
.unwrap_or_else(|| "по текущему срезу".to_string());
|
|
let reason = why
|
|
.first()
|
|
.cloned()
|
|
.unwrap_or_else(|| "существенных негативных признаков не выявлено".to_string());
|
|
match level {
|
|
"critical" | "high" => {
|
|
format!("{scope} риск повышен: {reason}. Требуется ручная проверка.")
|
|
}
|
|
"medium" => format!(
|
|
"{scope} есть умеренный риск: {reason}. Нужно проверить причины и полноту данных."
|
|
),
|
|
"guarded" => format!("{scope} риск требует наблюдения: {reason}."),
|
|
_ => format!("{scope} риск низкий: {reason}."),
|
|
}
|
|
}
|
|
|
|
fn evidence(source: &str, label: &str, value: &str, severity: &str) -> Value {
|
|
json!({
|
|
"source": source,
|
|
"label": label,
|
|
"value": value,
|
|
"severity": severity,
|
|
})
|
|
}
|
|
|
|
fn score_to_severity(score: u8) -> &'static str {
|
|
if score < 50 {
|
|
"high"
|
|
} else if score < 75 {
|
|
"medium"
|
|
} else {
|
|
"low"
|
|
}
|
|
}
|
|
|
|
fn heatmap_severity(level: &str) -> &'static str {
|
|
match level {
|
|
"CRITICAL" => "critical",
|
|
"HIGH" => "high",
|
|
"MEDIUM" => "medium",
|
|
_ => "low",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn risk_level_thresholds_are_stable() {
|
|
assert_eq!(risk_level(0), "low");
|
|
assert_eq!(risk_level(24), "low");
|
|
assert_eq!(risk_level(25), "guarded");
|
|
assert_eq!(risk_level(50), "medium");
|
|
assert_eq!(risk_level(75), "high");
|
|
assert_eq!(risk_level(90), "critical");
|
|
}
|
|
|
|
#[test]
|
|
fn report_builder_is_rule_based_and_does_not_support_employee_scope() {
|
|
let report = json!({
|
|
"generated_at_utc": "2026-06-07T10:00:00Z",
|
|
"workforce_kpi_explain": {
|
|
"kpi_score": 62,
|
|
"confidence": "medium",
|
|
"coverage": {
|
|
"agent_coverage_percent": 70,
|
|
"missing_sources": ["applications"]
|
|
},
|
|
"factors": [
|
|
{"name": "afterhours_activity", "impact": "-2"},
|
|
{"name": "remote_session_activity", "impact": "+2"}
|
|
]
|
|
},
|
|
"ueba_risk": {"level": "high", "score": 72},
|
|
"agent_coverage_sla": {"coverage_pct": 70},
|
|
"risk_heatmap": [{"department": "DEPT-1", "heat_level": "HIGH"}],
|
|
"security_correlation": [{"department": "DEPT-1", "correlation_score": 65}],
|
|
"risk_incident_candidates": [{"risk_level": "HIGH"}],
|
|
"security_events_summary": {"events_24h": 2, "fallback_used": false}
|
|
});
|
|
let query = RiskNarrativeQuery {
|
|
department: Some("DEPT-1".to_string()),
|
|
..RiskNarrativeQuery::default()
|
|
};
|
|
let narrative = build_risk_narrative_from_report(&report, PortalRole::Executive, &query);
|
|
assert_eq!(narrative["ok"], true);
|
|
assert_eq!(narrative["query"]["employee_id_supported"], false);
|
|
assert_eq!(narrative["model"]["ml"], false);
|
|
assert_eq!(narrative["model"]["llm"], false);
|
|
assert!(
|
|
narrative["risk_score"].as_u64().unwrap() >= 50,
|
|
"expected elevated risk narrative"
|
|
);
|
|
assert!(
|
|
narrative["why"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|item| item.as_str().unwrap().contains("UEBA"))
|
|
);
|
|
}
|
|
}
|