From 2b41fb14c7acbb3570cf6583ad56e51305d53b7a Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Sun, 7 Jun 2026 16:39:49 +0300 Subject: [PATCH] feat(portal): add executive action center --- README.md | 1 + .../detmir-portal/src/contracts/openapi.json | 135 ++++++ .../src/contracts/typescript.d.ts | 26 ++ .../detmir-portal/src/executive_actions.rs | 424 ++++++++++++++++++ adk-rust/crates/detmir-portal/src/main.rs | 185 +++++++- .../detmir-portal/src/production/limits.rs | 1 + .../crates/detmir-portal/src/static/app.css | 79 ++++ .../crates/detmir-portal/src/static/app.js | 61 +++ docs/EXECUTIVE_ACTION_CENTER_RU.md | 98 ++++ .../TASK_006_EXECUTIVE_ACTION_CENTER.md | 55 ++- scripts/awatch-production-hardening-smoke.mjs | 7 + 11 files changed, 1069 insertions(+), 3 deletions(-) create mode 100644 adk-rust/crates/detmir-portal/src/executive_actions.rs create mode 100644 docs/EXECUTIVE_ACTION_CENTER_RU.md diff --git a/README.md b/README.md index c99eff3..7ba59eb 100755 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ collectors. - [Pilot v1.0 evidence](docs/PILOT_V1_EVIDENCE_RU.md) - [Production readiness портала](docs/PRODUCTION_READINESS_RU.md) - [Explainable Workforce KPI](docs/EXPLAINABLE_KPI_RU.md) +- [Executive Action Center](docs/EXECUTIVE_ACTION_CENTER_RU.md) - [Rust Agent baseline](docs/RUST_AGENT_BASELINE_RU.md) - [Итог production-расследования 2026-06-07](docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md) - [Runbook восстановления worktime reports](docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md) diff --git a/adk-rust/crates/detmir-portal/src/contracts/openapi.json b/adk-rust/crates/detmir-portal/src/contracts/openapi.json index 92e7868..3eabca5 100644 --- a/adk-rust/crates/detmir-portal/src/contracts/openapi.json +++ b/adk-rust/crates/detmir-portal/src/contracts/openapi.json @@ -39,6 +39,9 @@ { "name": "risk" }, + { + "name": "actions" + }, { "name": "pfsense" }, @@ -891,6 +894,42 @@ } } } + }, + "/actions": { + "get": { + "tags": [ + "actions" + ], + "summary": "Rule-based executive action center", + "parameters": [ + { + "name": "role", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PortalRole" + } + } + ], + "responses": { + "200": { + "description": "Recommended actions payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionCenterResponse" + } + } + } + }, + "400": { + "description": "Query limits rejected" + }, + "403": { + "description": "Role denied" + } + } + } } }, "components": { @@ -1024,6 +1063,12 @@ "risk_narrative": { "$ref": "#/components/schemas/RiskNarrative" }, + "recommended_actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionItem" + } + }, "agent_quality": { "$ref": "#/components/schemas/JsonObject" }, @@ -1054,6 +1099,96 @@ }, "additionalProperties": true }, + "ActionItem": { + "type": "object", + "required": [ + "priority", + "title", + "summary", + "owner_role", + "recommended_deadline", + "reason_codes", + "evidence" + ], + "properties": { + "priority": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "owner_role": { + "type": "string", + "enum": [ + "executive", + "manager", + "security", + "forensics", + "admin" + ] + }, + "recommended_deadline": { + "type": "string" + }, + "reason_codes": { + "type": "array", + "items": { + "type": "string" + } + }, + "evidence": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": true + }, + "ActionCenterResponse": { + "type": "object", + "required": [ + "ok", + "actions", + "model" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "role_context": { + "$ref": "#/components/schemas/RoleContext" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionItem" + } + }, + "model": { + "$ref": "#/components/schemas/JsonObject" + }, + "generated_at_utc": { + "type": "string" + }, + "limitations": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": true + }, "RiskNarrativeEvidence": { "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 1873876..6e7486c 100644 --- a/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts +++ b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts @@ -87,6 +87,30 @@ export interface RiskNarrative { [key: string]: unknown; } +export type ActionPriority = "low" | "medium" | "high" | "critical"; +export type ActionOwnerRole = "executive" | "manager" | "security" | "forensics" | "admin"; + +export interface ActionItem { + priority: ActionPriority | string; + title: string; + summary: string; + owner_role: ActionOwnerRole | string; + recommended_deadline: string; + reason_codes: string[]; + evidence: string[]; + [key: string]: unknown; +} + +export interface ActionCenterResponse { + ok: boolean; + role_context?: RoleContext; + actions: ActionItem[]; + model?: JsonObject; + generated_at_utc?: ISODateTime; + limitations?: string[]; + [key: string]: unknown; +} + export interface AgentQuality { collector_source?: string; collector_error?: string | null; @@ -218,6 +242,7 @@ export interface ReportsResponse { executive_points?: string[]; executive_dashboard?: ExecutiveDashboard; risk_narrative?: RiskNarrative; + recommended_actions?: ActionItem[]; agent_quality?: AgentQuality; agent_coverage_sla?: AgentCoverageSla; business_risk?: BusinessRiskItem[]; @@ -303,6 +328,7 @@ export interface DetMirPortalApi { module?: string; role?: PortalRole; }): Promise; + getActions(options?: { role?: PortalRole }): Promise; getPfsense(options?: { role?: PortalRole }): Promise; getIncidents(): Promise; getCases(): Promise; diff --git a/adk-rust/crates/detmir-portal/src/executive_actions.rs b/adk-rust/crates/detmir-portal/src/executive_actions.rs new file mode 100644 index 0000000..4c7cf22 --- /dev/null +++ b/adk-rust/crates/detmir-portal/src/executive_actions.rs @@ -0,0 +1,424 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{PortalRole, role_envelope}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct ExecutiveAction { + pub priority: ActionPriority, + pub title: String, + pub summary: String, + pub owner_role: ActionOwnerRole, + pub recommended_deadline: String, + pub reason_codes: Vec, + pub evidence: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ActionPriority { + Low, + Medium, + High, + Critical, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ActionOwnerRole { + Executive, + Manager, + Security, + Forensics, + Admin, +} + +pub(crate) fn build_action_center_from_report(report: &Value, role: PortalRole) -> Value { + let actions = generate_actions(report) + .into_iter() + .filter(|action| action_visible_for_role(action.owner_role, role)) + .collect::>(); + json!({ + "ok": true, + "role_context": role_envelope(role, "actions"), + "actions": actions, + "model": { + "type": "rule_based", + "version": "executive-action-center-v1", + "ml": false, + "llm": false, + "auto_remediation": false + }, + "generated_at_utc": Utc::now().to_rfc3339(), + "limitations": [ + "Рекомендуемые действия не выполняются автоматически", + "Action Center не блокирует пользователей и не меняет политики", + "Все действия требуют ручного подтверждения ответственным контуром" + ] + }) +} + +pub(crate) fn filter_actions_for_role(actions: &Value, role: PortalRole) -> Value { + let filtered = actions + .as_array() + .into_iter() + .flatten() + .filter(|item| { + item.get("owner_role") + .and_then(Value::as_str) + .and_then(parse_owner_role) + .is_some_and(|owner| action_visible_for_role(owner, role)) + }) + .cloned() + .collect::>(); + Value::Array(filtered) +} + +pub(crate) fn actions_from_center(center: &Value) -> Value { + center + .get("actions") + .and_then(Value::as_array) + .cloned() + .map(Value::Array) + .unwrap_or_else(|| Value::Array(Vec::new())) +} + +fn generate_actions(report: &Value) -> Vec { + let mut actions = Vec::new(); + add_workforce_kpi_action(report, &mut actions); + add_coverage_action(report, &mut actions); + add_ueba_action(report, &mut actions); + add_security_correlation_action(report, &mut actions); + add_incident_candidate_action(report, &mut actions); + add_risk_narrative_action(report, &mut actions); + if actions.is_empty() { + actions.push(ExecutiveAction { + priority: ActionPriority::Low, + title: "Продолжить наблюдение".to_string(), + summary: "Критичных управленческих действий по текущему срезу не требуется".to_string(), + owner_role: ActionOwnerRole::Manager, + recommended_deadline: "72h".to_string(), + reason_codes: vec!["NORMAL_OBSERVATION".to_string()], + evidence: vec!["Критичные сигналы не выявлены".to_string()], + }); + } + actions.sort_by(|left, right| { + right + .priority + .cmp(&left.priority) + .then_with(|| left.owner_role.as_str().cmp(right.owner_role.as_str())) + .then_with(|| left.title.cmp(&right.title)) + }); + actions +} + +fn add_workforce_kpi_action(report: &Value, actions: &mut Vec) { + let Some(score) = report + .pointer("/workforce_kpi_explain/kpi_score") + .and_then(Value::as_u64) + else { + return; + }; + if score >= 70 { + return; + } + let mut reason_codes = vec!["LOW_WORKFORCE_KPI".to_string()]; + let mut evidence = vec![format!("Workforce KPI ниже целевого уровня: {score}%")]; + if has_kpi_factor(report, "remote_session_activity") { + reason_codes.push("HIGH_REMOTE_ACTIVITY".to_string()); + evidence.push("Рост удаленных сессий влияет на управленческий риск".to_string()); + } + if let Some(confidence) = report + .pointer("/workforce_kpi_explain/confidence") + .and_then(Value::as_str) + .filter(|value| *value == "low") + { + reason_codes.push("LOW_KPI_CONFIDENCE".to_string()); + evidence.push(format!("Доверие к KPI: {confidence}")); + } + actions.push(ExecutiveAction { + priority: if score < 50 { + ActionPriority::Critical + } else { + ActionPriority::High + }, + title: "Проверить подразделение с низким индексом активности".to_string(), + summary: + "Индекс активности ниже управленческого порога; требуется проверка причины просадки" + .to_string(), + owner_role: ActionOwnerRole::Manager, + recommended_deadline: if score < 50 { "4h" } else { "24h" }.to_string(), + reason_codes, + evidence, + }); +} + +fn add_coverage_action(report: &Value, actions: &mut Vec) { + let coverage = report + .pointer("/agent_coverage_sla/coverage_pct") + .or_else(|| report.pointer("/workforce_kpi_explain/coverage/agent_coverage_percent")) + .and_then(Value::as_u64) + .unwrap_or(100); + let sla_status = report + .pointer("/agent_coverage_sla/sla_status") + .and_then(Value::as_str) + .unwrap_or("OK"); + if coverage >= 80 && !matches!(sla_status, "WARNING" | "CRITICAL") { + return; + } + actions.push(ExecutiveAction { + priority: if coverage < 60 || sla_status == "CRITICAL" { + ActionPriority::Critical + } else { + ActionPriority::High + }, + title: "Проверить состояние агентов".to_string(), + summary: "Полнота данных ниже целевого уровня; показатели могут быть нерепрезентативны" + .to_string(), + owner_role: ActionOwnerRole::Admin, + recommended_deadline: if coverage < 60 { "4h" } else { "24h" }.to_string(), + reason_codes: vec!["LOW_COVERAGE".to_string()], + evidence: vec![ + format!("Покрытие агентов: {coverage}%"), + format!("SLA полноты данных: {sla_status}"), + ], + }); +} + +fn add_ueba_action(report: &Value, actions: &mut Vec) { + let score = report + .pointer("/ueba_risk/score") + .and_then(Value::as_u64) + .unwrap_or(0); + let level = report + .pointer("/ueba_risk/level") + .and_then(Value::as_str) + .unwrap_or("low"); + if score < 70 && !matches!(level, "high" | "critical") { + return; + } + actions.push(ExecutiveAction { + priority: if score >= 90 || level == "critical" { + ActionPriority::Critical + } else { + ActionPriority::High + }, + title: "Передать данные в контур ИБ".to_string(), + summary: "UEBA score повышен; требуется ручная проверка безопасности".to_string(), + owner_role: ActionOwnerRole::Security, + recommended_deadline: if score >= 90 { "4h" } else { "24h" }.to_string(), + reason_codes: vec!["HIGH_UEBA".to_string()], + evidence: vec![format!("UEBA score: {score}, уровень: {level}")], + }); +} + +fn add_security_correlation_action(report: &Value, actions: &mut Vec) { + let max_score = report + .get("security_correlation") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("correlation_score").and_then(Value::as_u64)) + .max() + .unwrap_or(0); + if max_score < 60 { + return; + } + actions.push(ExecutiveAction { + priority: if max_score >= 80 { + ActionPriority::Critical + } else { + ActionPriority::High + }, + title: "Проверить связь активности и ИБ-событий".to_string(), + summary: "Есть корреляция между операционным риском и событиями безопасности".to_string(), + owner_role: ActionOwnerRole::Security, + recommended_deadline: "24h".to_string(), + reason_codes: vec!["HIGH_SECURITY_CORRELATION".to_string()], + evidence: vec![format!("Security correlation score: {max_score}")], + }); +} + +fn add_incident_candidate_action(report: &Value, actions: &mut Vec) { + let Some(candidates) = report + .get("risk_incident_candidates") + .and_then(Value::as_array) + else { + return; + }; + if candidates.is_empty() { + return; + } + let critical = candidates.iter().any(|item| { + item.get("risk_level") + .and_then(Value::as_str) + .is_some_and(|level| level.eq_ignore_ascii_case("critical")) + }); + actions.push(ExecutiveAction { + priority: if critical { + ActionPriority::Critical + } else { + ActionPriority::High + }, + title: "Провести расследование кандидатов".to_string(), + summary: "В очереди есть кандидаты на проверку; требуется ручной разбор и фиксация решения" + .to_string(), + owner_role: ActionOwnerRole::Forensics, + recommended_deadline: if critical { "4h" } else { "24h" }.to_string(), + reason_codes: vec!["INCIDENT_CANDIDATE".to_string()], + evidence: vec![format!("Кандидатов на проверку: {}", candidates.len())], + }); +} + +fn add_risk_narrative_action(report: &Value, actions: &mut Vec) { + let score = report + .pointer("/risk_narrative/risk_score") + .and_then(Value::as_u64) + .unwrap_or(0); + if score < 75 { + return; + } + let level = report + .pointer("/risk_narrative/risk_level") + .and_then(Value::as_str) + .unwrap_or("high"); + actions.push(ExecutiveAction { + priority: if score >= 90 { + ActionPriority::Critical + } else { + ActionPriority::High + }, + title: "Назначить владельца корректирующих действий".to_string(), + summary: "Риск-нарратив показывает высокий управленческий риск; нужен ответственный и срок контроля" + .to_string(), + owner_role: ActionOwnerRole::Executive, + recommended_deadline: if score >= 90 { "4h" } else { "24h" }.to_string(), + reason_codes: vec!["RISK_NARRATIVE_HIGH".to_string()], + evidence: vec![format!("Risk Narrative: {score}/100, уровень: {level}")], + }); +} + +fn has_kpi_factor(report: &Value, factor_name: &str) -> bool { + report + .pointer("/workforce_kpi_explain/factors") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|item| item.get("name").and_then(Value::as_str) == Some(factor_name)) +} + +fn action_visible_for_role(owner_role: ActionOwnerRole, role: PortalRole) -> bool { + match role { + PortalRole::Admin => true, + PortalRole::Executive => matches!( + owner_role, + ActionOwnerRole::Executive | ActionOwnerRole::Manager | ActionOwnerRole::Admin + ), + PortalRole::Manager => matches!(owner_role, ActionOwnerRole::Manager), + PortalRole::Security => { + matches!( + owner_role, + ActionOwnerRole::Security | ActionOwnerRole::Forensics + ) + } + PortalRole::Forensics => { + matches!( + owner_role, + ActionOwnerRole::Forensics | ActionOwnerRole::Security + ) + } + } +} + +fn parse_owner_role(value: &str) -> Option { + match value { + "executive" => Some(ActionOwnerRole::Executive), + "manager" => Some(ActionOwnerRole::Manager), + "security" => Some(ActionOwnerRole::Security), + "forensics" => Some(ActionOwnerRole::Forensics), + "admin" => Some(ActionOwnerRole::Admin), + _ => None, + } +} + +impl ActionOwnerRole { + fn as_str(self) -> &'static str { + match self { + Self::Executive => "executive", + Self::Manager => "manager", + Self::Security => "security", + Self::Forensics => "forensics", + Self::Admin => "admin", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_report() -> Value { + json!({ + "workforce_kpi_explain": { + "kpi_score": 48, + "confidence": "low", + "coverage": {"agent_coverage_percent": 58}, + "factors": [{"name": "remote_session_activity"}] + }, + "ueba_risk": {"score": 91, "level": "critical"}, + "agent_coverage_sla": {"coverage_pct": 58, "sla_status": "CRITICAL"}, + "security_correlation": [{"correlation_score": 81}], + "risk_incident_candidates": [{"risk_level": "CRITICAL"}], + "risk_narrative": {"risk_score": 92, "risk_level": "critical"} + }) + } + + #[test] + fn generates_rule_based_actions_with_priorities() { + let payload = build_action_center_from_report(&sample_report(), PortalRole::Admin); + let actions = payload["actions"].as_array().unwrap(); + assert!(actions.len() >= 5); + assert_eq!(payload["model"]["type"], "rule_based"); + assert_eq!(payload["model"]["ml"], false); + assert_eq!(payload["model"]["llm"], false); + assert!(actions.iter().any(|item| { + item["reason_codes"] + .as_array() + .unwrap() + .iter() + .any(|code| code == "LOW_WORKFORCE_KPI") + })); + assert!(actions.iter().any(|item| item["priority"] == "critical")); + } + + #[test] + fn filters_actions_by_role() { + let admin = build_action_center_from_report(&sample_report(), PortalRole::Admin); + let security = filter_actions_for_role(&actions_from_center(&admin), PortalRole::Security); + let security_actions = security.as_array().unwrap(); + assert!(!security_actions.is_empty()); + assert!( + security_actions + .iter() + .all(|item| item["owner_role"] == "security" || item["owner_role"] == "forensics") + ); + + let manager = filter_actions_for_role(&actions_from_center(&admin), PortalRole::Manager); + let manager_actions = manager.as_array().unwrap(); + assert!( + manager_actions + .iter() + .all(|item| item["owner_role"] == "manager") + ); + } + + #[test] + fn emits_observation_when_no_rule_matches() { + let payload = build_action_center_from_report(&json!({}), PortalRole::Executive); + let actions = payload["actions"].as_array().unwrap(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0]["priority"], "low"); + assert_eq!(actions[0]["reason_codes"][0], "NORMAL_OBSERVATION"); + } +} diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index e1ab290..3bf1f7e 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -23,10 +23,14 @@ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; +mod executive_actions; mod production; mod risk_narrative; mod workforce_kpi_explain; +use executive_actions::{ + actions_from_center, build_action_center_from_report, filter_actions_for_role, +}; use production::{ build_healthz, build_readyz, build_version, http_request_metadata, is_limited_api_route, log_http_request, mark_request_started, record_http_metric, record_ingestion_accepted, @@ -1316,6 +1320,7 @@ struct ReportMarkdownContext<'a> { security_events_summary: &'a SecurityEventsSummary, risk_incident_candidates: &'a [RiskIncidentCandidate], incident_review_audit_summary: &'a IncidentReviewAuditSummary, + recommended_actions: &'a Value, } struct ReportRuntimeInputs<'a> { @@ -1559,6 +1564,10 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) &build_risk_narrative_from_report(&report, role, &query), ) } + "/api/actions" => { + let report = build_report_payload(args, snapshot_cache, anonymize); + respond_json(request, &build_action_center_from_report(&report, role)) + } "/api/owner" => { if !role.can_access("security") { return respond_forbidden(request, role, "security"); @@ -1766,7 +1775,8 @@ fn api_contract_summary() -> Value { {"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/risk/narrative", "purpose": "rule-based executive risk narrative"} + {"method": "GET", "path": "/api/risk/narrative", "purpose": "rule-based executive risk narrative"}, + {"method": "GET", "path": "/api/actions", "purpose": "rule-based executive action center"} ] }) } @@ -3335,6 +3345,16 @@ fn build_reports( PortalRole::Executive, &RiskNarrativeQuery::default(), ); + let action_signal_report = json!({ + "workforce_kpi_explain": workforce_kpi_explain, + "ueba_risk": ueba_risk, + "agent_coverage_sla": agent_coverage_sla, + "security_correlation": security_correlation, + "risk_incident_candidates": risk_incident_candidates, + "risk_narrative": risk_narrative, + }); + let action_center = build_action_center_from_report(&action_signal_report, PortalRole::Admin); + let recommended_actions = actions_from_center(&action_center); let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0 { "Контур DetMir работает штатно, критичных действий не требуется" @@ -3452,6 +3472,7 @@ fn build_reports( security_events_summary: &security_events_summary, risk_incident_candidates: &risk_incident_candidates, incident_review_audit_summary: &incident_review_audit_summary, + recommended_actions: &recommended_actions, }, ); json!({ @@ -3464,6 +3485,7 @@ fn build_reports( "executive_points": executive_points, "executive_dashboard": executive_dashboard, "risk_narrative": risk_narrative, + "recommended_actions": recommended_actions, "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)), @@ -3708,6 +3730,12 @@ fn role_filtered_report(report: Value, role: PortalRole) -> Value { } PortalRole::Admin => {} } + if let Some(actions) = object.get("recommended_actions") { + out.insert( + "recommended_actions".to_string(), + filter_actions_for_role(actions, role), + ); + } out.insert("ok".to_string(), Value::Bool(true)); Value::Object(out) } @@ -7182,6 +7210,7 @@ fn render_report_markdown( context.risk_heatmap, context.security_correlation, ); + append_recommended_actions_markdown(&mut text, context.recommended_actions); append_executive_dashboard_markdown(&mut text, context.executive_dashboard); text.push_str("## Ключевые показатели\n\n"); text.push_str(&format!("- Общий статус: {}\n", summary.severity)); @@ -7356,6 +7385,58 @@ fn append_risk_narrative_markdown(text: &mut String, narrative: &Value) { text.push('\n'); } +fn append_recommended_actions_markdown(text: &mut String, actions: &Value) { + text.push_str("\n## Рекомендуемые действия\n\n"); + let Some(items) = actions.as_array().filter(|items| !items.is_empty()) else { + text.push_str("- Критичных управленческих действий по текущему срезу не требуется.\n"); + return; + }; + for action in items.iter().take(12) { + text.push_str(&format!( + "- [{}] {} — {}; срок: {}; адресат: {}; причины: {}\n", + action + .get("priority") + .and_then(Value::as_str) + .unwrap_or("low"), + action + .get("title") + .and_then(Value::as_str) + .unwrap_or("Действие не указано"), + action + .get("summary") + .and_then(Value::as_str) + .unwrap_or("обоснование не указано"), + action + .get("recommended_deadline") + .and_then(Value::as_str) + .unwrap_or("72h"), + action + .get("owner_role") + .and_then(Value::as_str) + .unwrap_or("manager"), + action + .get("reason_codes") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>() + .join(", ") + )); + let evidence = action + .get("evidence") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .take(3) + .collect::>(); + if !evidence.is_empty() { + text.push_str(&format!(" - Обоснование: {}\n", evidence.join("; "))); + } + } +} + fn append_executive_dashboard_markdown(text: &mut String, dashboard: &ExecutiveDashboard) { text.push_str("\n## Сводка руководителя\n\n"); text.push_str(&format!( @@ -10637,6 +10718,12 @@ mod tests { "demo navigation missing {marker}" ); } + assert!(APP_JS.contains("function renderActionCenter")); + assert!(APP_JS.contains("Рекомендуемые действия ИБ")); + assert!(APP_CSS.contains(".action-center-card")); + assert!(!APP_CSS.contains("1 px")); + assert!(!APP_CSS.contains("1 fr")); + assert!(!APP_CSS.contains("8 px")); } #[test] @@ -10656,6 +10743,7 @@ mod tests { "/workforce", "/workforce/kpi/explain", "/risk/narrative", + "/actions", "/security", "/forensics", "/ueba", @@ -10678,6 +10766,8 @@ mod tests { "PfsenseReadinessResponse", "WorkforceKpiExplainResponse", "RiskNarrative", + "ActionItem", + "ActionCenterResponse", "CaseListResponse", "IncidentReviewRequest", "export interface DetMirPortalApi", @@ -10750,7 +10840,36 @@ mod tests { "security_correlation": [], "risk_incident_candidates": [{"id": "candidate-demo"}], "ueba_risk": {"score": 10, "level": "low", "status": "WARN", "reasons": [{"code": "activity_anomaly"}]}, - "incident_review_audit_summary": {"total_changes": 0} + "incident_review_audit_summary": {"total_changes": 0}, + "recommended_actions": [ + { + "priority": "high", + "title": "Проверить подразделение", + "summary": "Низкий Workforce KPI требует ручного разбора", + "owner_role": "manager", + "recommended_deadline": "24h", + "reason_codes": ["LOW_WORKFORCE_KPI"], + "evidence": ["Workforce KPI ниже порога"] + }, + { + "priority": "high", + "title": "Передать в ИБ", + "summary": "UEBA score требует проверки ИБ", + "owner_role": "security", + "recommended_deadline": "24h", + "reason_codes": ["HIGH_UEBA"], + "evidence": ["UEBA score high"] + }, + { + "priority": "high", + "title": "Провести расследование кандидата", + "summary": "Кандидат требует ручного разбора", + "owner_role": "forensics", + "recommended_deadline": "24h", + "reason_codes": ["INCIDENT_CANDIDATE"], + "evidence": ["Есть кандидат на проверку"] + } + ] }); let executive = role_filtered_report(report.clone(), PortalRole::Executive); @@ -10759,16 +10878,58 @@ mod tests { assert!(executive.get("security_events_summary").is_some()); assert!(executive.get("risk_incident_candidates").is_none()); assert!(executive.get("security_correlation").is_none()); + assert_eq!( + executive["recommended_actions"].as_array().unwrap().len(), + 1 + ); + assert_eq!(executive["recommended_actions"][0]["owner_role"], "manager"); let security = role_filtered_report(report.clone(), PortalRole::Security); assert!(security.get("ueba_risk").is_some()); assert!(security.get("risk_incident_candidates").is_some()); assert!(security.get("workforce").is_none()); assert!(security.get("workforce_policy").is_none()); + assert_eq!(security["recommended_actions"].as_array().unwrap().len(), 2); + assert!( + security["recommended_actions"] + .as_array() + .unwrap() + .iter() + .any(|action| action["owner_role"] == "security" + && action["reason_codes"][0] == "HIGH_UEBA") + ); + assert!( + security["recommended_actions"] + .as_array() + .unwrap() + .iter() + .any(|action| action["owner_role"] == "forensics" + && action["reason_codes"][0] == "INCIDENT_CANDIDATE") + ); let forensics = role_filtered_report(report, PortalRole::Forensics); assert!(forensics.get("forensics").is_some()); assert!(forensics.get("workforce").is_none()); + assert_eq!( + forensics["recommended_actions"].as_array().unwrap().len(), + 2 + ); + assert!( + forensics["recommended_actions"] + .as_array() + .unwrap() + .iter() + .any(|action| action["owner_role"] == "security" + && action["reason_codes"][0] == "HIGH_UEBA") + ); + assert!( + forensics["recommended_actions"] + .as_array() + .unwrap() + .iter() + .any(|action| action["owner_role"] == "forensics" + && action["reason_codes"][0] == "INCIDENT_CANDIDATE") + ); } #[test] @@ -12221,6 +12382,26 @@ mod tests { .unwrap() .contains("## Объяснение индекса активности") ); + assert!(report["recommended_actions"].is_array()); + assert!(!report["recommended_actions"].as_array().unwrap().is_empty()); + assert!( + report["recommended_actions"] + .as_array() + .unwrap() + .iter() + .any(|item| item["reason_codes"] + .as_array() + .unwrap() + .iter() + .any(|code| code == "LOW_COVERAGE")) + ); + 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"); diff --git a/adk-rust/crates/detmir-portal/src/production/limits.rs b/adk-rust/crates/detmir-portal/src/production/limits.rs index 9857d02..07b7bb6 100644 --- a/adk-rust/crates/detmir-portal/src/production/limits.rs +++ b/adk-rust/crates/detmir-portal/src/production/limits.rs @@ -126,6 +126,7 @@ pub(crate) fn is_limited_api_route(path: &str) -> bool { | "/api/pfsense" | "/api/workforce/kpi/explain" | "/api/risk/narrative" + | "/api/actions" ) } diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index 59b0c8c..03593b7 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -988,6 +988,83 @@ h1 { margin: 12px 0; } +.action-center-card { + margin: 12px 0; +} + +.security-actions-card { + border-left: 4px solid #7c2d12; +} + +.action-center-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.action-item { + display: grid; + gap: 8px; + min-height: 196px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--soft); +} + +.action-item-head { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.action-item-head strong, +.action-item p, +.action-item li { + overflow-wrap: anywhere; +} + +.action-item p { + margin: 0; +} + +.action-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + color: var(--muted); + font-size: 13px; +} + +.action-meta span { + padding: 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.action-reasons { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.action-reasons span { + padding: 4px 7px; + border-radius: 999px; + background: var(--panel); + color: var(--muted); + font-size: 12px; +} + +.action-item ul { + display: grid; + gap: 6px; + margin: 0; + padding-left: 18px; +} + .risk-narrative-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -1303,6 +1380,8 @@ pre { .executive-grid { grid-template-columns: 1fr; } .risk-narrative-grid { grid-template-columns: 1fr; } .risk-narrative-columns { grid-template-columns: 1fr; } + .action-center-list { grid-template-columns: 1fr; } + .action-meta { 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 b9d27a5..5e82ae3 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -738,6 +738,64 @@ function renderRiskNarrative(report) { `; } +function renderActionCenter(actions, options = {}) { + const items = Array.isArray(actions) ? actions.slice(0, 6) : []; + if (!items.length) { + return ` +
+
+
+

${ui(options.title || "Рекомендуемые действия")}

+

Критичных действий по текущему срезу не требуется.

+
+ low +
+
+ `; + } + return ` +
+
+
+

${ui(options.title || "Рекомендуемые действия")}

+

Рекомендации сформированы по Workforce KPI, UEBA, покрытию данных, корреляции безопасности и кандидатам на проверку.

+
+ ${ui(items[0]?.priority || "low")} +
+
+ ${items.map(action => ` +
+
+ ${ui(action.priority || "low")} + ${ui(action.title || "Действие не указано")} +
+

${ui(action.summary || "Описание действия не указано")}

+
+ Срок ${ui(action.recommended_deadline || "72h")} + Адресат ${ui(actionOwnerLabel(action.owner_role))} +
+
+ ${(Array.isArray(action.reason_codes) ? action.reason_codes : []).slice(0, 4).map(code => `${ui(code)}`).join("")} +
+
    ${(Array.isArray(action.evidence) ? action.evidence : []).slice(0, 3).map(item => `
  • ${ui(item)}
  • `).join("")}
+
+ `).join("")} +
+
+ `; +} + +function actionOwnerLabel(value) { + const map = { + executive: "Руководитель", + manager: "Руководитель подразделения", + security: "ИБ", + forensics: "Расследования", + admin: "Эксплуатация" + }; + return map[value] || value || "Ответственный"; +} + function optionalPercent(value) { const number = Number(value); return Number.isFinite(number) ? `${Math.round(number)}%` : "нет данных"; @@ -1289,6 +1347,7 @@ function renderOperatorRoleContent(mode, data, report, extras = {}) { function renderExecutiveView(report, incidents) { return ` ${renderRiskNarrative(report)} + ${renderActionCenter(report?.recommended_actions, { title: "Рекомендуемые действия" })} ${renderExecutiveDashboard(report)} ${renderKpiExplain(report?.workforce_kpi_explain)} ${renderSecurityEventsSummary(report?.security_events_summary, { compact: true })} @@ -1301,6 +1360,7 @@ function renderExecutiveView(report, incidents) { function renderSecurityView(data, report, extras = {}) { const cases = Array.isArray(extras.cases?.cases) ? extras.cases.cases : []; return ` + ${renderActionCenter(report?.recommended_actions, { title: "Рекомендуемые действия ИБ", security: true })} ${renderSecurityEventsSummary(report?.security_events_summary)} ${renderRiskIncidentCandidates(report?.risk_incident_candidates)} ${renderSecurityCorrelation(report?.security_correlation)} @@ -2933,6 +2993,7 @@ function renderReports(data) { ${renderPeriodBanner(data)} ${renderRiskNarrative(data)} + ${renderActionCenter(data.recommended_actions, { title: "Рекомендуемые действия" })} ${renderExecutiveDashboard(data)} ${renderReportTypeCards()}
diff --git a/docs/EXECUTIVE_ACTION_CENTER_RU.md b/docs/EXECUTIVE_ACTION_CENTER_RU.md new file mode 100644 index 0000000..ae00192 --- /dev/null +++ b/docs/EXECUTIVE_ACTION_CENTER_RU.md @@ -0,0 +1,98 @@ +# Executive Action Center + +Executive Action Center преобразует существующую аналитику AWatch-rus в +конкретные управленческие действия. + +## Назначение + +Модуль отвечает на вопросы: + +- что рекомендуется сделать; +- насколько это срочно; +- кому адресовано действие; +- почему система предлагает именно это действие. + +Action Center не выполняет действия автоматически. Он не блокирует +пользователей, не меняет политики и не является DLP/EDR/SIEM. + +## Контракт действия + +```json +{ + "priority": "high", + "title": "Проверить подразделение с низким индексом активности", + "summary": "Индекс активности ниже управленческого порога; требуется проверка причины просадки", + "owner_role": "manager", + "recommended_deadline": "24h", + "reason_codes": [ + "LOW_WORKFORCE_KPI", + "HIGH_REMOTE_ACTIVITY" + ], + "evidence": [ + "Workforce KPI ниже целевого уровня: 62%", + "Рост удаленных сессий влияет на управленческий риск" + ] +} +``` + +## API + +Endpoint: + +```text +GET /api/actions +``` + +Поддерживается role filtering через `role` query parameter или +`X-AWatch-Role` header. + +`/api/reports` дополнительно содержит: + +```json +{ + "recommended_actions": [] +} +``` + +## Уровни приоритета + +- `low` - наблюдение, контроль на следующем регулярном срезе; +- `medium` - требуется плановая проверка; +- `high` - требуется действие в течение 24 часов; +- `critical` - требуется срочная ручная проверка, обычно до 4 часов. + +## Rule-based правила + +| Reason code | Сигнал | Рекомендуемое действие | Адресат | +|---|---|---|---| +| `LOW_WORKFORCE_KPI` | Workforce KPI ниже целевого уровня | Проверить подразделение с низким индексом активности | `manager` | +| `HIGH_REMOTE_ACTIVITY` | Фактор удаленных сессий в explainability | Проверить причину роста удаленных сессий | `manager` | +| `LOW_COVERAGE` | Низкое покрытие агентов или плохой SLA полноты данных | Проверить состояние агентов | `admin` | +| `HIGH_UEBA` | Повышенный UEBA score | Передать данные в контур ИБ | `security` | +| `HIGH_SECURITY_CORRELATION` | Высокая корреляция активности и ИБ-событий | Проверить связь активности и ИБ-событий | `security` | +| `INCIDENT_CANDIDATE` | Есть кандидаты на проверку | Провести расследование кандидатов | `forensics` | +| `RISK_NARRATIVE_HIGH` | Высокий Risk Narrative score | Назначить владельца корректирующих действий | `executive` | +| `NORMAL_OBSERVATION` | Критичные правила не сработали | Продолжить наблюдение | `manager` | + +Все правила детерминированные. ML, LLM и прогнозные модели не используются. + +## Ролевое отображение + +- `executive` видит управленческие, менеджерские и эксплуатационные действия; +- `manager` видит действия для руководителя подразделения; +- `security` видит действия ИБ и действия, которые нужно передать в + расследование; +- `forensics` видит действия расследований и связанные действия ИБ; +- `admin` видит полный список. + +Сервер фильтрует действия по роли. UI скрывает нерелевантные действия только +после серверной фильтрации. + +## Ограничения + +- Action Center является decision-support слоем, а не auto-remediation. +- Рекомендации требуют ручной проверки ответственным контуром. +- Модуль не подтверждает нарушение сам по себе. +- pfSense остается `contract_only`, если ingestion отдельно не включен и не + прошел приемку. +- Planned/Future collectors не считаются реализованными источниками данных. diff --git a/docs/roadmap/TASK_006_EXECUTIVE_ACTION_CENTER.md b/docs/roadmap/TASK_006_EXECUTIVE_ACTION_CENTER.md index 941289c..ebb611c 100644 --- a/docs/roadmap/TASK_006_EXECUTIVE_ACTION_CENTER.md +++ b/docs/roadmap/TASK_006_EXECUTIVE_ACTION_CENTER.md @@ -253,4 +253,57 @@ docs/EXECUTIVE_ACTION_CENTER_RU.md 6. Документация. 7. Тесты. 8. Проверки. -9. Ограничения. \ No newline at end of file +9. Ограничения. + +## Выполнение + +Статус: done. + +Файлы: + +- `adk-rust/crates/detmir-portal/src/executive_actions.rs`; +- `adk-rust/crates/detmir-portal/src/main.rs`; +- `adk-rust/crates/detmir-portal/src/static/app.js`; +- `adk-rust/crates/detmir-portal/src/static/app.css`; +- `adk-rust/crates/detmir-portal/src/contracts/openapi.json`; +- `adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts`; +- `adk-rust/crates/detmir-portal/src/production/limits.rs`; +- `scripts/awatch-production-hardening-smoke.mjs`; +- `docs/EXECUTIVE_ACTION_CENTER_RU.md`; +- `README.md`. + +Новые endpoints: + +- `GET /api/actions`. + +Action model: + +- `priority`; +- `title`; +- `summary`; +- `owner_role`; +- `recommended_deadline`; +- `reason_codes`; +- `evidence`. + +Rule engine: + +- deterministic rule-based; +- использует существующие сигналы Workforce KPI, UEBA, coverage, security + correlation, incident candidates и Risk Narrative; +- не использует ML/LLM; +- не выполняет auto-remediation. + +UI: + +- Executive View показывает блок `Рекомендуемые действия`; +- Security View показывает блок `Рекомендуемые действия ИБ` с ИБ-действиями и + действиями, передаваемыми в расследование; +- Markdown report содержит раздел `## Рекомендуемые действия`. + +Ограничения: + +- рекомендации не выполняются автоматически; +- пользователи не блокируются; +- политики не меняются; +- DLP/EDR/ML/LLM не добавлялись. diff --git a/scripts/awatch-production-hardening-smoke.mjs b/scripts/awatch-production-hardening-smoke.mjs index 190682a..3326e2c 100644 --- a/scripts/awatch-production-hardening-smoke.mjs +++ b/scripts/awatch-production-hardening-smoke.mjs @@ -80,6 +80,12 @@ async function main() { 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"); + const actions = await request("/api/actions?role=executive"); + assert(actions.response.status === 200, "Actions API must return 200"); + assert(Array.isArray(actions.json?.actions), "Actions API must include actions array"); + assert(actions.json?.model?.type === "rule_based", "Actions model must be rule-based"); + assert(actions.json?.model?.auto_remediation === false, "Actions must not enable auto-remediation"); + console.log(JSON.stringify({ ok: true, baseUrl, @@ -92,6 +98,7 @@ async function main() { "role gates", "/api/workforce/kpi/explain", "/api/risk/narrative", + "/api/actions", ], }, null, 2)); }