feat(portal): add rule-based risk narrative

This commit is contained in:
igor04091968
2026-06-07 15:46:38 +03:00
parent 5277097032
commit 8fcb49f2c7
8 changed files with 1369 additions and 8 deletions
@@ -36,6 +36,9 @@
{
"name": "ueba"
},
{
"name": "risk"
},
{
"name": "pfsense"
},
@@ -827,6 +830,67 @@
}
}
}
},
"/risk/narrative": {
"get": {
"tags": [
"risk"
],
"summary": "Rule-based executive risk narrative",
"parameters": [
{
"name": "date",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "date"
}
},
{
"name": "department",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "module",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "role",
"in": "query",
"required": false,
"schema": {
"$ref": "#/components/schemas/PortalRole"
}
}
],
"responses": {
"200": {
"description": "Risk narrative payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RiskNarrative"
}
}
}
},
"400": {
"description": "Query limits rejected"
},
"403": {
"description": "Role denied"
}
}
}
}
},
"components": {
@@ -958,7 +1022,7 @@
"$ref": "#/components/schemas/JsonObject"
},
"risk_narrative": {
"$ref": "#/components/schemas/JsonObject"
"$ref": "#/components/schemas/RiskNarrative"
},
"agent_quality": {
"$ref": "#/components/schemas/JsonObject"
@@ -990,6 +1054,121 @@
},
"additionalProperties": true
},
"RiskNarrativeEvidence": {
"type": "object",
"required": [
"source",
"label",
"value",
"severity"
],
"properties": {
"source": {
"type": "string"
},
"label": {
"type": "string"
},
"value": {
"type": "string"
},
"severity": {
"type": "string",
"enum": [
"low",
"medium",
"high",
"critical"
]
}
},
"additionalProperties": true
},
"RiskNarrative": {
"type": "object",
"required": [
"ok",
"risk_level",
"risk_score",
"title",
"summary",
"why",
"evidence",
"recommended_actions",
"limitations",
"model"
],
"properties": {
"ok": {
"type": "boolean"
},
"role_context": {
"$ref": "#/components/schemas/RoleContext"
},
"scope": {
"type": "string",
"enum": [
"aggregate",
"department"
]
},
"query": {
"$ref": "#/components/schemas/JsonObject"
},
"risk_level": {
"type": "string",
"enum": [
"low",
"guarded",
"medium",
"high",
"critical"
]
},
"risk_score": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"title": {
"type": "string"
},
"summary": {
"type": "string"
},
"why": {
"type": "array",
"items": {
"type": "string"
}
},
"evidence": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RiskNarrativeEvidence"
}
},
"recommended_actions": {
"type": "array",
"items": {
"type": "string"
}
},
"limitations": {
"type": "array",
"items": {
"type": "string"
}
},
"model": {
"$ref": "#/components/schemas/JsonObject"
},
"generated_at_utc": {
"type": "string"
}
},
"additionalProperties": true
},
"UebaPayload": {
"type": "object",
"required": [
+32 -6
View File
@@ -58,12 +58,32 @@ export interface ExecutiveDashboard {
}
export interface RiskNarrative {
status?: "NORMAL" | "ATTENTION" | "HIGH_RISK" | "CRITICAL" | string;
title?: string;
summary?: string;
main_reason?: string;
recommendation?: string;
supporting_layers?: JsonObject[];
ok: boolean;
role_context?: RoleContext;
scope?: "aggregate" | "department" | string;
query?: {
date?: string | null;
department?: string | null;
module?: string | null;
employee_id_supported: false;
[key: string]: unknown;
};
risk_level: "low" | "guarded" | "medium" | "high" | "critical" | string;
risk_score: number;
title: string;
summary: string;
why: string[];
evidence: Array<{
source: string;
label: string;
value: string;
severity: "low" | "medium" | "high" | "critical" | string;
[key: string]: unknown;
}>;
recommended_actions: string[];
limitations: string[];
model?: JsonObject;
generated_at_utc?: ISODateTime;
[key: string]: unknown;
}
@@ -277,6 +297,12 @@ export interface DetMirPortalApi {
getSecurity(options?: { role?: PortalRole }): Promise<ReportsResponse>;
getForensics(options?: { role?: PortalRole }): Promise<ReportsResponse>;
getUeba(options?: { role?: PortalRole }): Promise<UebaResponse>;
getRiskNarrative(options?: {
date?: string;
department?: string;
module?: string;
role?: PortalRole;
}): Promise<RiskNarrative>;
getPfsense(options?: { role?: PortalRole }): Promise<PfsenseReadinessResponse>;
getIncidents(): Promise<JsonObject>;
getCases(): Promise<CaseListResponse>;
+132 -1
View File
@@ -24,6 +24,7 @@ use sha2::{Digest, Sha256};
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
mod production;
mod risk_narrative;
mod workforce_kpi_explain;
use production::{
@@ -32,6 +33,9 @@ use production::{
record_ingestion_rejected, record_report_generated, render_prometheus_metrics,
validate_api_query_limits, validate_portal_config,
};
use risk_narrative::{
RiskNarrativeInputs, RiskNarrativeQuery, build_risk_narrative, build_risk_narrative_from_report,
};
use workforce_kpi_explain::{KpiExplainQuery, build_workforce_kpi_explain};
const INDEX_HTML: &str = include_str!("static/index.html");
@@ -1302,6 +1306,7 @@ struct ReportMarkdownContext<'a> {
executive_dashboard: &'a ExecutiveDashboard,
workforce_policy: &'a Value,
workforce_kpi_explain: &'a Value,
risk_narrative: &'a Value,
ueba_risk: &'a Value,
business_risk: &'a [BusinessRiskItem],
business_risk_history: &'a [BusinessRiskHistoryItem],
@@ -1546,6 +1551,14 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
&build_workforce_kpi_explain(&snapshot, &policy_explain, role, &query, anonymize),
)
}
"/api/risk/narrative" => {
let report = build_report_payload(args, snapshot_cache, anonymize);
let query = RiskNarrativeQuery::from_url(&url);
respond_json(
request,
&build_risk_narrative_from_report(&report, role, &query),
)
}
"/api/owner" => {
if !role.can_access("security") {
return respond_forbidden(request, role, "security");
@@ -1752,7 +1765,8 @@ fn api_contract_summary() -> Value {
{"method": "GET", "path": "/api/dlp/evidence", "purpose": "DLP evidence list"},
{"method": "GET", "path": "/api/readiness/latest", "purpose": "latest readiness status"},
{"method": "GET", "path": "/api/workforce/policy/explain", "purpose": "workforce policy explanation"},
{"method": "GET", "path": "/api/workforce/kpi/explain", "purpose": "rule-based Workforce KPI explanation"}
{"method": "GET", "path": "/api/workforce/kpi/explain", "purpose": "rule-based Workforce KPI explanation"},
{"method": "GET", "path": "/api/risk/narrative", "purpose": "rule-based executive risk narrative"}
]
})
}
@@ -3306,6 +3320,21 @@ fn build_reports(
&ueba_baseline,
ueba_policy_path,
);
let risk_narrative = build_risk_narrative(
RiskNarrativeInputs {
snapshot,
workforce_kpi_explain: &workforce_kpi_explain,
ueba_risk: &ueba_risk,
agent_coverage_sla: &agent_coverage_sla,
risk_heatmap: &risk_heatmap,
security_correlation: &security_correlation,
risk_incident_candidates: &risk_incident_candidates,
executive_dashboard: &executive_dashboard,
security_events_summary: &security_events_summary,
},
PortalRole::Executive,
&RiskNarrativeQuery::default(),
);
let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0
{
"Контур DetMir работает штатно, критичных действий не требуется"
@@ -3413,6 +3442,7 @@ fn build_reports(
executive_dashboard: &executive_dashboard,
workforce_policy: &workforce_policy_explain,
workforce_kpi_explain: &workforce_kpi_explain,
risk_narrative: &risk_narrative,
ueba_risk: &ueba_risk,
business_risk: &business_risk,
business_risk_history: &business_risk_history,
@@ -3433,6 +3463,7 @@ fn build_reports(
"headline": headline,
"executive_points": executive_points,
"executive_dashboard": executive_dashboard,
"risk_narrative": risk_narrative,
"kpis": [
report_kpi("Оценка риска", format!("{}/100", ueba_risk.get("score").and_then(Value::as_u64).unwrap_or(0)), ueba_risk.get("status").and_then(Value::as_str).unwrap_or("UNKNOWN").to_string(), ueba_risk.get("summary").and_then(Value::as_str).unwrap_or("оценка риска")),
report_kpi("Качество данных", agent_quality.quality_status.clone(), agent_quality.quality_status.clone(), &format!("источник: {}", agent_quality.collector_source)),
@@ -3595,6 +3626,7 @@ fn role_filtered_report(report: Value, role: PortalRole) -> Value {
for key in [
"executive_points",
"executive_dashboard",
"risk_narrative",
"kpis",
"business_risk",
"business_risk_history_summary",
@@ -3615,6 +3647,7 @@ fn role_filtered_report(report: Value, role: PortalRole) -> Value {
PortalRole::Manager => {
for key in [
"executive_dashboard",
"risk_narrative",
"kpis",
"business_risk",
"risk_heatmap",
@@ -3634,10 +3667,12 @@ fn role_filtered_report(report: Value, role: PortalRole) -> Value {
PortalRole::Security => {
for key in [
"ueba_risk",
"risk_narrative",
"ueba_baseline",
"security_events_summary",
"security_correlation",
"risk_incident_candidates",
"risk_narrative",
"incident_review_audit_summary",
"business_risk",
"risk_heatmap",
@@ -7141,6 +7176,7 @@ fn render_report_markdown(
text.push_str("# DetMir оперативный отчет\n\n");
text.push_str(&format!("Дата снимка: {}\n\n", snapshot.generated_at_utc));
text.push_str(&format!("Итог: {headline}\n\n"));
append_risk_narrative_markdown(&mut text, context.risk_narrative);
append_linked_risk_narrative_markdown(
&mut text,
context.risk_heatmap,
@@ -7250,6 +7286,76 @@ fn render_report_markdown(
text
}
fn append_risk_narrative_markdown(text: &mut String, narrative: &Value) {
text.push_str("\n## Риск-нарратив\n\n");
text.push_str(&format!(
"- Уровень риска: {}\n",
narrative
.get("risk_level")
.and_then(Value::as_str)
.unwrap_or("low")
));
text.push_str(&format!(
"- Risk score: {}/100\n",
narrative
.get("risk_score")
.and_then(Value::as_u64)
.unwrap_or(0)
));
text.push_str(&format!(
"- Вывод: {}\n",
narrative
.get("title")
.and_then(Value::as_str)
.unwrap_or("Риск не рассчитан")
));
text.push_str(&format!(
"- Что происходит: {}\n",
narrative
.get("summary")
.and_then(Value::as_str)
.unwrap_or("нет данных")
));
append_string_list_markdown(
text,
"### Почему система так считает",
narrative.get("why").and_then(Value::as_array),
"существенных причин не выявлено",
);
text.push_str("\n### Подтверждающие сигналы\n\n");
for item in narrative
.get("evidence")
.and_then(Value::as_array)
.into_iter()
.flatten()
.take(12)
{
text.push_str(&format!(
"- {}: {} ({})\n",
item.get("label").and_then(Value::as_str).unwrap_or("-"),
item.get("value").and_then(Value::as_str).unwrap_or("-"),
item.get("severity")
.and_then(Value::as_str)
.unwrap_or("low")
));
}
append_string_list_markdown(
text,
"### Что делать дальше",
narrative
.get("recommended_actions")
.and_then(Value::as_array),
"продолжить наблюдение",
);
append_string_list_markdown(
text,
"### Ограничения вывода",
narrative.get("limitations").and_then(Value::as_array),
"ограничения не указаны",
);
text.push('\n');
}
fn append_executive_dashboard_markdown(text: &mut String, dashboard: &ExecutiveDashboard) {
text.push_str("\n## Сводка руководителя\n\n");
text.push_str(&format!(
@@ -10549,6 +10655,7 @@ mod tests {
"/executive",
"/workforce",
"/workforce/kpi/explain",
"/risk/narrative",
"/security",
"/forensics",
"/ueba",
@@ -10570,6 +10677,7 @@ mod tests {
"UebaResponse",
"PfsenseReadinessResponse",
"WorkforceKpiExplainResponse",
"RiskNarrative",
"CaseListResponse",
"IncidentReviewRequest",
"export interface DetMirPortalApi",
@@ -12101,12 +12209,35 @@ mod tests {
.unwrap()
.contains("## Оценка риска")
);
assert!(
report["markdown"]
.as_str()
.unwrap()
.contains("## Риск-нарратив")
);
assert!(
report["markdown"]
.as_str()
.unwrap()
.contains("## Объяснение индекса активности")
);
assert!(report["risk_narrative"].is_object());
assert_eq!(report["risk_narrative"]["ok"], true);
assert_eq!(report["risk_narrative"]["model"]["type"], "rule_based");
assert_eq!(report["risk_narrative"]["model"]["ml"], false);
assert_eq!(report["risk_narrative"]["model"]["llm"], false);
assert_eq!(
report["risk_narrative"]["query"]["employee_id_supported"],
false
);
assert!(report["risk_narrative"]["risk_score"].as_u64().unwrap() <= 100);
assert!(
report["risk_narrative"]["why"]
.as_array()
.unwrap()
.iter()
.any(|item| item.as_str().unwrap().contains("Карта рисков"))
);
assert!(report["workforce_kpi_explain"].is_object());
assert_eq!(report["workforce_policy"]["configured"], false);
assert_eq!(report["workforce"]["trend_status"], "daily_only");
@@ -125,6 +125,7 @@ pub(crate) fn is_limited_api_route(path: &str) -> bool {
| "/api/ueba"
| "/api/pfsense"
| "/api/workforce/kpi/explain"
| "/api/risk/narrative"
)
}
@@ -0,0 +1,919 @@
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"))
);
}
}
@@ -984,6 +984,53 @@ h1 {
margin: 12px 0;
}
.risk-narrative-card {
margin: 12px 0;
}
.risk-narrative-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 12px 0;
}
.risk-narrative-grid > div {
display: grid;
gap: 4px;
min-height: 64px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--soft);
}
.risk-narrative-grid strong {
overflow-wrap: anywhere;
}
.risk-narrative-columns {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.risk-narrative-columns h4 {
margin: 0 0 6px;
font-size: 14px;
}
.risk-narrative-columns ul {
display: grid;
gap: 6px;
margin: 0;
padding-left: 18px;
}
.risk-narrative-columns li {
overflow-wrap: anywhere;
}
.ueba-risk-card {
margin: 12px 0;
}
@@ -1226,6 +1273,7 @@ pre {
.anomaly-panel,
.risk-panel { grid-column: auto; }
.leader-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.risk-narrative-columns { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
@@ -1253,6 +1301,8 @@ pre {
.band-head { flex-direction: column; align-items: flex-start; }
.row { grid-template-columns: 1fr; }
.executive-grid { grid-template-columns: 1fr; }
.risk-narrative-grid { grid-template-columns: 1fr; }
.risk-narrative-columns { grid-template-columns: 1fr; }
.analytics-grid { grid-template-columns: 1fr; }
.incident-row { grid-template-columns: 1fr; }
.evidence-row { grid-template-columns: 1fr; }
@@ -671,6 +671,53 @@ function renderExecutiveDashboard(report) {
}
function renderRiskNarrative(report) {
const narrative = report?.risk_narrative;
if (narrative) {
const why = Array.isArray(narrative.why) ? narrative.why.slice(0, 5) : [];
const evidence = Array.isArray(narrative.evidence) ? narrative.evidence.slice(0, 6) : [];
const actions = Array.isArray(narrative.recommended_actions) ? narrative.recommended_actions.slice(0, 4) : [];
const limitations = Array.isArray(narrative.limitations) ? narrative.limitations.slice(0, 3) : [];
return `
<section class="card risk-narrative-card">
<div class="section-head">
<div>
<h3 ${tooltip("Управленческий вывод: что происходит, насколько это рискованно, почему система так считает и что делать дальше.")}>Риск-нарратив</h3>
<p class="muted">${ui(narrative.summary || "Система связывает показатели активности, полноту данных, оценку риска и очередь проверки.")}</p>
</div>
<span class="badge ${statusClass(narrative.risk_level)}">${ui(narrative.risk_level || "low")} · ${ui(narrative.risk_score ?? 0)}/100</span>
</div>
<div class="risk-narrative-grid">
<div>
<span class="muted">Главный вывод</span>
<strong>${ui(narrative.title || "Риск не рассчитан")}</strong>
</div>
<div>
<span class="muted">Модель</span>
<strong>${ui(narrative.model?.type || "rule_based")}</strong>
</div>
<div>
<span class="muted">Уровень</span>
<strong>${ui(narrative.risk_level || "low")}</strong>
</div>
</div>
<div class="risk-narrative-columns">
<div>
<h4>Почему</h4>
<ul>${(why.length ? why : ["Существенных негативных признаков не выявлено"]).map(item => `<li>${ui(item)}</li>`).join("")}</ul>
</div>
<div>
<h4>Подтверждения</h4>
<ul>${evidence.map(item => `<li><strong>${ui(item.label || item.source || "-")}</strong>: ${ui(item.value || "-")} <span class="badge ${statusClass(item.severity)}">${ui(item.severity || "low")}</span></li>`).join("") || "<li>Подтверждающие сигналы пока не рассчитаны</li>"}</ul>
</div>
<div>
<h4>Дальше</h4>
<ul>${actions.map(item => `<li>${ui(item)}</li>`).join("") || "<li>Продолжить наблюдение</li>"}</ul>
</div>
</div>
${limitations.length ? `<p class="muted small">Ограничения: ${limitations.map(ui).join(" · ")}</p>` : ""}
</section>
`;
}
const dashboard = report?.executive_dashboard;
const summary = dashboard?.summary || {};
const narrativeStatus = summary.risk_narrative_status || executiveDashboardStatus(dashboard || {});