diff --git a/adk-rust/crates/detmir-portal/src/contracts/openapi.json b/adk-rust/crates/detmir-portal/src/contracts/openapi.json index 6b72747..92e7868 100644 --- a/adk-rust/crates/detmir-portal/src/contracts/openapi.json +++ b/adk-rust/crates/detmir-portal/src/contracts/openapi.json @@ -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": [ diff --git a/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts index 1bec9b9..1873876 100644 --- a/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts +++ b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts @@ -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; getForensics(options?: { role?: PortalRole }): Promise; getUeba(options?: { role?: PortalRole }): Promise; + getRiskNarrative(options?: { + date?: string; + department?: string; + module?: string; + role?: PortalRole; + }): Promise; getPfsense(options?: { role?: PortalRole }): Promise; getIncidents(): Promise; getCases(): Promise; diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 69cd064..e1ab290 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -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"); diff --git a/adk-rust/crates/detmir-portal/src/production/limits.rs b/adk-rust/crates/detmir-portal/src/production/limits.rs index 9ac3852..9857d02 100644 --- a/adk-rust/crates/detmir-portal/src/production/limits.rs +++ b/adk-rust/crates/detmir-portal/src/production/limits.rs @@ -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" ) } diff --git a/adk-rust/crates/detmir-portal/src/risk_narrative.rs b/adk-rust/crates/detmir-portal/src/risk_narrative.rs new file mode 100644 index 0000000..e15848d --- /dev/null +++ b/adk-rust/crates/detmir-portal/src/risk_narrative.rs @@ -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, + department: Option, + module: Option, +} + +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, + evidence: Vec, + recommended_actions: Vec, + limitations: Vec, + department: Option, + 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>, + 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>, + 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, value: &str) { + if !items.iter().any(|item| item == value) { + items.push(value.to_string()); + } +} + +fn risk_narrative_limitations() -> Vec { + 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")) + ); + } +} diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index 92ab227..59b0c8c 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -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; } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index a258889..b9d27a5 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -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 ` +
+
+
+

Риск-нарратив

+

${ui(narrative.summary || "Система связывает показатели активности, полноту данных, оценку риска и очередь проверки.")}

+
+ ${ui(narrative.risk_level || "low")} · ${ui(narrative.risk_score ?? 0)}/100 +
+
+
+ Главный вывод + ${ui(narrative.title || "Риск не рассчитан")} +
+
+ Модель + ${ui(narrative.model?.type || "rule_based")} +
+
+ Уровень + ${ui(narrative.risk_level || "low")} +
+
+
+
+

Почему

+
    ${(why.length ? why : ["Существенных негативных признаков не выявлено"]).map(item => `
  • ${ui(item)}
  • `).join("")}
+
+
+

Подтверждения

+
    ${evidence.map(item => `
  • ${ui(item.label || item.source || "-")}: ${ui(item.value || "-")} ${ui(item.severity || "low")}
  • `).join("") || "
  • Подтверждающие сигналы пока не рассчитаны
  • "}
+
+
+

Дальше

+
    ${actions.map(item => `
  • ${ui(item)}
  • `).join("") || "
  • Продолжить наблюдение
  • "}
+
+
+ ${limitations.length ? `

Ограничения: ${limitations.map(ui).join(" · ")}

` : ""} +
+ `; + } const dashboard = report?.executive_dashboard; const summary = dashboard?.summary || {}; const narrativeStatus = summary.risk_narrative_status || executiveDashboardStatus(dashboard || {}); diff --git a/scripts/awatch-production-hardening-smoke.mjs b/scripts/awatch-production-hardening-smoke.mjs index 2ffc3f5..190682a 100644 --- a/scripts/awatch-production-hardening-smoke.mjs +++ b/scripts/awatch-production-hardening-smoke.mjs @@ -73,6 +73,13 @@ async function main() { assert(Array.isArray(kpiExplain.json?.factors), "KPI explain must include factors"); assert(kpiExplain.json.factors.some((item) => item.name === "productive_activity"), "KPI explain factors must be deterministic"); + const riskNarrative = await request("/api/risk/narrative?role=executive"); + assert(riskNarrative.response.status === 200, "Risk narrative must return 200"); + assert(typeof riskNarrative.json?.risk_score === "number", "Risk narrative must include risk_score"); + assert(["low", "guarded", "medium", "high", "critical"].includes(riskNarrative.json?.risk_level), "Risk narrative must include stable risk_level"); + assert(Array.isArray(riskNarrative.json?.why), "Risk narrative must include why list"); + assert(riskNarrative.json?.model?.type === "rule_based", "Risk narrative must be rule-based"); + console.log(JSON.stringify({ ok: true, baseUrl, @@ -84,6 +91,7 @@ async function main() { "query limits", "role gates", "/api/workforce/kpi/explain", + "/api/risk/narrative", ], }, null, 2)); }