feat(portal): export investigation packs

This commit is contained in:
igor04091968
2026-06-04 14:22:50 +03:00
parent cd95792196
commit fca5b57455
4 changed files with 444 additions and 3 deletions
+385
View File
@@ -355,6 +355,33 @@ struct RiskIncidentCandidate {
incident_review_audit: Vec<IncidentReviewAuditEntry>,
}
#[derive(Clone, Debug, Serialize)]
struct InvestigationPack {
candidate_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
department: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
hostname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
risk_level: Option<String>,
reasons: Vec<String>,
evidence: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
first_seen_utc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_seen_utc: Option<String>,
current_review_status: String,
#[serde(skip_serializing_if = "Option::is_none")]
review_comment: Option<String>,
review_audit_history: Vec<IncidentReviewAuditEntry>,
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<String> {
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<String> {
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<RiskIncidentCandidate>, Vec<BusinessRiskItem>) {
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<InvestigationPack> {
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<String> {
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::<String>();
if stem.is_empty() {
"candidate".to_string()
} else {
stem
}
}
fn header(name: &str, value: &str) -> Result<Header> {
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"]
@@ -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;
@@ -1811,9 +1811,13 @@ function renderCandidateReviewActions(item) {
["FALSE_POSITIVE", "Ложный"],
["POSTPONED", "Отложить"],
];
return `<div class="button-row compact-actions">${actions.map(([status, label]) => `
<button class="small-button" data-review-status="${escapeHtml(status)}" data-candidate-id="${escapeHtml(id)}">${ui(label)}</button>
`).join("")}</div>`;
const packUrl = `/portal/api/investigation-pack/${encodeURIComponent(id)}?format=markdown`;
return `
<div class="button-row compact-actions">${actions.map(([status, label]) => `
<button class="small-button" data-review-status="${escapeHtml(status)}" data-candidate-id="${escapeHtml(id)}">${ui(label)}</button>
`).join("")}</div>
<a class="small-button investigation-pack-button" href="${escapeHtml(packUrl)}" download>Скачать пакет расследования</a>
`;
}
function reviewStatusText(status) {
+45
View File
@@ -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
Оперативный отчет содержит раздел: