feat(portal): add rule-based risk narrative
This commit is contained in:
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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 || {});
|
||||
|
||||
Reference in New Issue
Block a user