From fca5b574550108e71d45a2bd91d2c847266f32a9 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 4 Jun 2026 14:22:50 +0300 Subject: [PATCH] feat(portal): export investigation packs --- adk-rust/crates/detmir-portal/src/main.rs | 385 ++++++++++++++++++ .../crates/detmir-portal/src/static/app.css | 7 + .../crates/detmir-portal/src/static/app.js | 10 +- docs/BUSINESS_RISK_RU.md | 45 ++ 4 files changed, 444 insertions(+), 3 deletions(-) diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 631939c..a2a96c0 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -355,6 +355,33 @@ struct RiskIncidentCandidate { incident_review_audit: Vec, } +#[derive(Clone, Debug, Serialize)] +struct InvestigationPack { + candidate_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + department: Option, + #[serde(skip_serializing_if = "Option::is_none")] + owner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + hostname: Option, + #[serde(skip_serializing_if = "Option::is_none")] + risk_level: Option, + reasons: Vec, + evidence: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + first_seen_utc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + last_seen_utc: Option, + current_review_status: String, + #[serde(skip_serializing_if = "Option::is_none")] + review_comment: Option, + review_audit_history: Vec, + trust_kpi_snapshot: Value, + agent_quality_snapshot: Value, + business_risk_snapshot: Value, + markdown: String, +} + #[derive(Debug, Serialize)] struct HealthResponse { ok: bool, @@ -921,6 +948,9 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) if path == "/api/dlp/evidence" { return respond_json(request, &build_dlp_evidence_response(args)); } + if let Some(candidate_id) = parse_investigation_pack_path(&path) { + return handle_investigation_pack(request, args, snapshot_cache, &url, &candidate_id); + } if let Some((evidence_id, download)) = parse_evidence_screenshot_path(&path) { return handle_evidence_screenshot(request, args, &evidence_id, download); } @@ -1189,6 +1219,21 @@ fn query_flag(url: &str, key: &str) -> bool { }) } +fn query_param(url: &str, key: &str) -> Option { + let query = url.split_once('?').map(|(_, query)| query)?; + query.split('&').find_map(|pair| { + let (name, value) = pair.split_once('=').unwrap_or((pair, "")); + (name == key && !value.is_empty()).then(|| value.to_string()) + }) +} + +fn parse_investigation_pack_path(path: &str) -> Option { + path.strip_prefix("/api/investigation-pack/") + .map(str::trim) + .filter(|value| !value.is_empty() && !value.contains('/')) + .map(ToString::to_string) +} + fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot { let mut guard = cache.lock().expect("snapshot cache mutex poisoned"); if let Some(cached) = guard.as_ref() { @@ -2701,6 +2746,226 @@ fn build_risk_incident_candidates( .collect() } +fn build_reviewed_risk_incident_candidates( + snapshot: &Snapshot, + incident_reviews: &IncidentReviewFile, + audit_entries: &[IncidentReviewAuditEntry], +) -> (Vec, Vec) { + let department_items = workforce_rollup_items(snapshot, "department_rollups"); + let business_risk = build_business_risk(snapshot, &department_items); + let business_risk_history = build_business_risk_history(snapshot); + let mut candidates = + build_risk_incident_candidates(snapshot, &business_risk, &business_risk_history); + apply_incident_reviews_to_candidates(&mut candidates, incident_reviews, audit_entries); + (candidates, business_risk) +} + +fn build_investigation_pack( + snapshot: &Snapshot, + candidate_id: &str, + incident_reviews: &IncidentReviewFile, + audit_entries: &[IncidentReviewAuditEntry], +) -> Result { + let candidate_id = validate_short_token(candidate_id, "candidate_id", 128)?; + let (candidates, business_risk) = + build_reviewed_risk_incident_candidates(snapshot, incident_reviews, audit_entries); + let candidate = candidates + .iter() + .find(|item| item.id == candidate_id) + .ok_or_else(|| anyhow!("candidate not found: {candidate_id}"))?; + let related_business_risk = candidate.department.as_deref().and_then(|department| { + business_risk + .iter() + .find(|item| item.department == department) + }); + let reasons = investigation_reasons(candidate, related_business_risk); + let review = &candidate.incident_review; + let trust_kpi_snapshot = investigation_trust_kpi_snapshot( + candidate, + related_business_risk, + &snapshot.agent_quality, + &snapshot.agent_quality_nodes_summary, + &snapshot.agent_coverage_sla, + ); + let agent_quality_snapshot = investigation_agent_quality_snapshot(snapshot, candidate); + let business_risk_snapshot = related_business_risk + .map(|item| serde_json::to_value(item).unwrap_or_else(|_| json!({}))) + .unwrap_or_else(|| { + json!({ + "available": false, + "department": candidate.department, + "risk_level": candidate.risk_level, + "reasons": reasons, + }) + }); + let mut pack = InvestigationPack { + candidate_id: candidate.id.clone(), + department: candidate.department.clone(), + owner: candidate.owner.clone(), + hostname: candidate.hostname.clone(), + risk_level: candidate.risk_level.clone(), + reasons, + evidence: candidate.evidence.clone(), + first_seen_utc: candidate.first_seen_utc.clone(), + last_seen_utc: candidate.last_seen_utc.clone(), + current_review_status: review.status.clone(), + review_comment: review.comment.clone(), + review_audit_history: candidate.incident_review_audit.clone(), + trust_kpi_snapshot, + agent_quality_snapshot, + business_risk_snapshot, + markdown: String::new(), + }; + pack.markdown = render_investigation_pack_markdown(&pack); + Ok(pack) +} + +fn investigation_reasons( + candidate: &RiskIncidentCandidate, + business_risk: Option<&BusinessRiskItem>, +) -> Vec { + let mut reasons = Vec::new(); + if let Some(reason) = candidate + .reason + .as_deref() + .filter(|value| !value.is_empty()) + { + reasons.push(reason.to_string()); + } + if let Some(risk) = business_risk { + reasons.extend(risk.reasons.iter().cloned()); + } + if reasons.is_empty() { + reasons.push("кандидат требует ручной проверки".to_string()); + } + reasons.sort(); + reasons.dedup(); + reasons +} + +fn investigation_trust_kpi_snapshot( + candidate: &RiskIncidentCandidate, + business_risk: Option<&BusinessRiskItem>, + agent_quality: &AgentQuality, + agent_quality_nodes_summary: &AgentQualityNodesSummary, + agent_coverage_sla: &AgentCoverageSla, +) -> Value { + json!({ + "department": candidate.department, + "hostname": candidate.hostname, + "trust_score": business_risk.map(|item| item.trust_score), + "activity_score": business_risk.map(|item| item.activity_score), + "business_risk_level": business_risk.map(|item| item.risk_level.clone()), + "agent_quality_status": agent_quality.quality_status, + "agent_quality_source": agent_quality.collector_source, + "accepted_kpi_nodes_pct": agent_quality_nodes_summary.accepted_kpi_nodes_pct, + "coverage_sla_status": agent_coverage_sla.sla_status, + "coverage_pct": agent_coverage_sla.coverage_pct, + "freshness_pct": agent_coverage_sla.freshness_pct, + }) +} + +fn investigation_agent_quality_snapshot( + snapshot: &Snapshot, + candidate: &RiskIncidentCandidate, +) -> Value { + let node = candidate.hostname.as_deref().and_then(|hostname| { + snapshot + .agent_quality_nodes + .iter() + .find(|item| item.hostname == hostname) + }); + json!({ + "global": snapshot.agent_quality, + "global_explain": agent_quality_explain(&snapshot.agent_quality), + "node": node, + "nodes_summary": snapshot.agent_quality_nodes_summary, + "coverage_sla": snapshot.agent_coverage_sla, + }) +} + +fn render_investigation_pack_markdown(pack: &InvestigationPack) -> String { + let mut text = String::new(); + text.push_str("# Пакет расследования кандидата\n\n"); + text.push_str("## Краткое резюме\n\n"); + text.push_str(&format!("- Кандидат: {}\n", pack.candidate_id)); + text.push_str(&format!( + "- Подразделение: {}\n", + pack.department.as_deref().unwrap_or("-") + )); + text.push_str(&format!( + "- Ответственный: {}\n", + pack.owner.as_deref().unwrap_or("-") + )); + text.push_str(&format!( + "- Узел: {}\n", + pack.hostname.as_deref().unwrap_or("-") + )); + text.push_str(&format!( + "- Риск: {}\n", + pack.risk_level.as_deref().unwrap_or("UNKNOWN") + )); + text.push_str(&format!( + "- Период наблюдения: {} - {}\n", + pack.first_seen_utc.as_deref().unwrap_or("-"), + pack.last_seen_utc.as_deref().unwrap_or("-") + )); + text.push_str(&format!( + "- Текущий статус проверки: {}\n", + pack.current_review_status + )); + text.push_str(&format!( + "- Комментарий проверки: {}\n", + pack.review_comment.as_deref().unwrap_or("-") + )); + text.push_str("\n## Причины риска\n\n"); + for reason in &pack.reasons { + text.push_str(&format!("- {reason}\n")); + } + text.push_str("\n## Доказательства\n\n"); + if pack.evidence.is_empty() { + text.push_str("- Evidence отсутствует, требуется ручная проверка первичных источников.\n"); + } else { + for item in &pack.evidence { + text.push_str(&format!("- {item}\n")); + } + } + text.push_str("\n## История проверки\n\n"); + if pack.review_audit_history.is_empty() { + text.push_str("- История изменений отсутствует.\n"); + } else { + for entry in &pack.review_audit_history { + text.push_str(&format!( + "- {}: {} -> {}, проверяющий={}, комментарий={}\n", + entry.changed_at_utc, + entry.old_status, + entry.new_status, + entry.reviewer.as_deref().unwrap_or("-"), + entry.comment.as_deref().unwrap_or("-") + )); + } + } + text.push_str("\n## Снимок доверия к KPI\n\n"); + append_json_markdown(&mut text, &pack.trust_kpi_snapshot); + text.push_str("\n## Качество данных агента\n\n"); + append_json_markdown(&mut text, &pack.agent_quality_snapshot); + text.push_str("\n## Бизнес-риск\n\n"); + append_json_markdown(&mut text, &pack.business_risk_snapshot); + text.push_str("\n## Вывод\n\n"); + text.push_str("- Пакет является экспортом кандидата для ручной проверки.\n"); + text.push_str("- Автоматическое создание или подтверждение инцидента не выполнялось.\n"); + text.push_str( + "- Решение принимает ответственный сотрудник после проверки первичных источников.\n", + ); + text +} + +fn append_json_markdown(text: &mut String, value: &Value) { + text.push_str("```json\n"); + text.push_str(&serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string())); + text.push_str("\n```\n"); +} + fn apply_incident_reviews_to_candidates( candidates: &mut [RiskIncidentCandidate], reviews: &IncidentReviewFile, @@ -5278,6 +5543,54 @@ fn handle_incident_review(mut request: Request, args: &Cli) -> Result<()> { } } +fn handle_investigation_pack( + request: Request, + args: &Cli, + snapshot_cache: &SnapshotCache, + url: &str, + candidate_id: &str, +) -> Result<()> { + let snapshot = cached_snapshot(args, snapshot_cache); + let incident_reviews = load_incident_review_best_effort(args); + let incident_review_audit = load_incident_review_audit_best_effort(args); + let pack = build_investigation_pack( + &snapshot, + candidate_id, + &incident_reviews, + &incident_review_audit, + ); + match pack { + Ok(pack) => { + let format = query_param(url, "format") + .unwrap_or_else(|| "json".to_string()) + .to_ascii_lowercase(); + if matches!(format.as_str(), "md" | "markdown") { + let filename = format!( + "investigation-pack-{}.md", + safe_download_stem(&pack.candidate_id) + ); + respond_text_download( + request, + StatusCode(200), + &pack.markdown, + "text/markdown; charset=utf-8", + &filename, + ) + } else { + respond_json(request, &pack) + } + } + Err(err) => respond_json_status( + request, + StatusCode(404), + &json!({ + "ok": false, + "error": err.to_string() + }), + ), + } +} + fn handle_telemetry_ingest(mut request: Request, args: &Cli) -> Result<()> { if !telemetry_authorized(&request, args) { return respond_json_status( @@ -6728,6 +7041,27 @@ fn respond_text( request.respond(response).map_err(|err| anyhow!("{err}")) } +fn respond_text_download( + request: Request, + status: StatusCode, + body: &str, + content_type: &str, + download_name: &str, +) -> Result<()> { + let response = Response::from_string(body.to_string()) + .with_status_code(status) + .with_header(header("Content-Type", content_type)?) + .with_header(header("Cache-Control", "no-store")?) + .with_header(header( + "Content-Disposition", + &format!( + "attachment; filename=\"{}\"", + download_name.replace('"', "") + ), + )?); + request.respond(response).map_err(|err| anyhow!("{err}")) +} + fn respond_file( request: Request, path: &Path, @@ -6748,6 +7082,25 @@ fn respond_file( request.respond(response).map_err(|err| anyhow!("{err}")) } +fn safe_download_stem(value: &str) -> String { + let stem = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '_' + } + }) + .take(96) + .collect::(); + if stem.is_empty() { + "candidate".to_string() + } else { + stem + } +} + fn header(name: &str, value: &str) -> Result
{ Header::from_bytes(name.as_bytes(), value.as_bytes()) .map_err(|_| anyhow!("invalid header {name}: {value}")) @@ -6769,6 +7122,15 @@ mod tests { assert!(query_flag("/api/reports?anonymize=1", "anonymize")); assert!(query_flag("/api/reports?anonymize=true", "anonymize")); assert!(!query_flag("/api/reports?anonymize=0", "anonymize")); + assert_eq!( + query_param("/api/investigation-pack/c1?format=markdown", "format").as_deref(), + Some("markdown") + ); + assert_eq!( + parse_investigation_pack_path("/api/investigation-pack/risk-candidate-123").as_deref(), + Some("risk-candidate-123") + ); + assert_eq!(safe_download_stem("risk:candidate/1"), "risk_candidate_1"); } #[test] @@ -8024,6 +8386,29 @@ mod tests { report["risk_incident_candidates"][0]["incident_review"]["status"], "NEW" ); + let candidate_id = report["risk_incident_candidates"][0]["id"] + .as_str() + .unwrap(); + let pack = + build_investigation_pack(&snapshot, candidate_id, &IncidentReviewFile::default(), &[]) + .unwrap(); + assert_eq!(pack.candidate_id, candidate_id); + assert!(pack.markdown.contains("# Пакет расследования кандидата")); + assert!(pack.markdown.contains("## Причины риска")); + assert!(pack.markdown.contains("## Доказательства")); + assert!(pack.markdown.contains("## История проверки")); + assert!(pack.trust_kpi_snapshot.is_object()); + assert!(pack.agent_quality_snapshot.is_object()); + assert!(pack.business_risk_snapshot.is_object()); + assert!( + build_investigation_pack( + &snapshot, + "risk-candidate-absent", + &IncidentReviewFile::default(), + &[] + ) + .is_err() + ); assert_eq!(report["incident_review_audit_summary"]["total_changes"], 0); assert!( report["executive_points"] diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index deea633..7e23590 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -910,6 +910,13 @@ h1 { gap: 6px; } +.investigation-pack-button { + margin-top: 8px; + width: 100%; + justify-content: center; + text-align: center; +} + .small-button { min-height: 32px; padding: 6px 8px; diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 3503fc6..9d123af 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -1811,9 +1811,13 @@ function renderCandidateReviewActions(item) { ["FALSE_POSITIVE", "Ложный"], ["POSTPONED", "Отложить"], ]; - return `
${actions.map(([status, label]) => ` - - `).join("")}
`; + const packUrl = `/portal/api/investigation-pack/${encodeURIComponent(id)}?format=markdown`; + return ` +
${actions.map(([status, label]) => ` + + `).join("")}
+ Скачать пакет расследования + `; } function reviewStatusText(status) { diff --git a/docs/BUSINESS_RISK_RU.md b/docs/BUSINESS_RISK_RU.md index f9654be..9d2eca4 100644 --- a/docs/BUSINESS_RISK_RU.md +++ b/docs/BUSINESS_RISK_RU.md @@ -117,6 +117,48 @@ POST /api/incident-review отображается как отсутствующая. Audit JSONL является append-only журналом: история изменений не удаляется и не перезаписывается при смене статуса. +## Investigation Pack Export + +Для каждого кандидата можно выгрузить доказательный пакет расследования: + +```http +GET /api/investigation-pack/{candidate_id} +GET /api/investigation-pack/{candidate_id}?format=markdown +``` + +Пакет не создает инцидент автоматически и не меняет review-статус. Это только +экспорт текущего кандидата для руководителя, ИБ или внутренней проверки. + +JSON/Markdown содержит: + +- `candidate_id`; +- подразделение, ответственного и рабочее место; +- уровень риска; +- причины риска; +- evidence-признаки; +- первое и последнее наблюдение; +- текущий review-статус и комментарий; +- `review_audit_history`; +- `trust_kpi_snapshot`; +- `agent_quality_snapshot`; +- `business_risk_snapshot`. + +Markdown-версия содержит разделы: + +```text +Краткое резюме +Причины риска +Доказательства +История проверки +Снимок доверия к KPI +Качество данных агента +Бизнес-риск +Вывод +``` + +Если audit-файл отсутствует, пакет всё равно формируется, а раздел истории +проверки показывает, что изменения не зафиксированы. + ## Логика риска На риск влияют: @@ -177,6 +219,9 @@ POST /api/incident-review кандидат -> проверка -> решение -> кто/когда/почему ``` +Кнопка `Скачать пакет расследования` выгружает Markdown-пакет по выбранному +кандидату без создания инцидента. + ## Markdown Оперативный отчет содержит раздел: