feat(portal): audit incident review changes

This commit is contained in:
igor04091968
2026-06-04 14:11:34 +03:00
parent 20fb47b457
commit cd95792196
4 changed files with 286 additions and 11 deletions
+218 -10
View File
@@ -351,6 +351,8 @@ struct RiskIncidentCandidate {
#[serde(skip_serializing_if = "Option::is_none")]
recommendation: Option<String>,
incident_review: IncidentReviewState,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
incident_review_audit: Vec<IncidentReviewAuditEntry>,
}
#[derive(Debug, Serialize)]
@@ -477,6 +479,28 @@ struct IncidentReviewResponse {
review: IncidentReviewState,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct IncidentReviewAuditEntry {
candidate_id: String,
old_status: String,
new_status: String,
#[serde(skip_serializing_if = "Option::is_none")]
reviewer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
comment: Option<String>,
changed_at_utc: String,
}
#[derive(Clone, Debug, Default, Serialize)]
struct IncidentReviewAuditSummary {
total_changes: usize,
confirmed_count: usize,
false_positive_count: usize,
postponed_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
last_change_utc: Option<String>,
}
#[derive(Debug, Serialize)]
struct IncidentAuditEntry {
generated_at_utc: String,
@@ -811,11 +835,13 @@ struct ReportMarkdownContext<'a> {
business_risk_history: &'a [BusinessRiskHistoryItem],
business_risk_history_summary: &'a BusinessRiskHistorySummary,
risk_incident_candidates: &'a [RiskIncidentCandidate],
incident_review_audit_summary: &'a IncidentReviewAuditSummary,
}
struct ReportRuntimeInputs<'a> {
incident_state: &'a IncidentStateFile,
incident_reviews: &'a IncidentReviewFile,
incident_review_audit: &'a [IncidentReviewAuditEntry],
evidence: &'a DlpEvidenceResponse,
}
@@ -835,17 +861,21 @@ fn run() -> Result<i32> {
if args.json_smoke {
let snapshot = build_snapshot(&args);
let incident_state = load_incident_state_best_effort(&args);
let incident_reviews = load_incident_review_best_effort(&args);
let incident_review_audit = load_incident_review_audit_best_effort(&args);
let evidence = build_dlp_evidence_response(&args);
let ueba_baseline_path = ueba_baseline_state_path(&args);
let smoke = json!({
"health": build_health(&snapshot),
"summary": build_summary(&snapshot),
"reports": build_reports(&snapshot, ReportRuntimeInputs {
incident_state: &incident_state,
incident_reviews: &load_incident_review_best_effort(&args),
evidence: &build_dlp_evidence_response(&args),
incident_reviews: &incident_reviews,
incident_review_audit: &incident_review_audit,
evidence: &evidence,
}, &args.workforce_policy_path, &args.ueba_policy_path, &ueba_baseline_path, false),
"incidents": build_incidents(&snapshot, &incident_state),
"dlp_evidence": build_dlp_evidence_response(&args),
"dlp_evidence": evidence,
});
println!("{}", serde_json::to_string_pretty(&smoke)?);
return Ok(if build_health(&snapshot).ok { 0 } else { 2 });
@@ -944,6 +974,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
let snapshot = cached_snapshot(args, snapshot_cache);
let incident_state = load_incident_state_best_effort(args);
let incident_reviews = load_incident_review_best_effort(args);
let incident_review_audit = load_incident_review_audit_best_effort(args);
let evidence = build_dlp_evidence_response(args);
let ueba_baseline_path = ueba_baseline_state_path(args);
respond_json(
@@ -953,6 +984,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
ReportRuntimeInputs {
incident_state: &incident_state,
incident_reviews: &incident_reviews,
incident_review_audit: &incident_review_audit,
evidence: &evidence,
},
&args.workforce_policy_path,
@@ -2046,7 +2078,13 @@ fn build_reports(
let business_risk_history_summary = summarize_business_risk_history(&business_risk_history);
let mut risk_incident_candidates =
build_risk_incident_candidates(snapshot, &business_risk, &business_risk_history);
apply_incident_reviews_to_candidates(&mut risk_incident_candidates, inputs.incident_reviews);
apply_incident_reviews_to_candidates(
&mut risk_incident_candidates,
inputs.incident_reviews,
inputs.incident_review_audit,
);
let incident_review_audit_summary =
summarize_incident_review_audit(inputs.incident_review_audit);
let trend = workforce_trend_json(snapshot);
let insight_items = workforce_insight_items(snapshot);
let workforce_policy_explain =
@@ -2158,6 +2196,7 @@ fn build_reports(
business_risk_history: &business_risk_history,
business_risk_history_summary: &business_risk_history_summary,
risk_incident_candidates: &risk_incident_candidates,
incident_review_audit_summary: &incident_review_audit_summary,
},
);
json!({
@@ -2249,6 +2288,7 @@ fn build_reports(
"business_risk_history": business_risk_history,
"business_risk_history_summary": business_risk_history_summary,
"risk_incident_candidates": risk_incident_candidates,
"incident_review_audit_summary": incident_review_audit_summary,
"workforce_policy": workforce_policy_explain,
"workforce": {
"department_comparison": department_items,
@@ -2664,6 +2704,7 @@ fn build_risk_incident_candidates(
fn apply_incident_reviews_to_candidates(
candidates: &mut [RiskIncidentCandidate],
reviews: &IncidentReviewFile,
audit_entries: &[IncidentReviewAuditEntry],
) {
for candidate in candidates {
candidate.incident_review =
@@ -2678,9 +2719,39 @@ fn apply_incident_reviews_to_candidates(
comment: None,
updated_at: String::new(),
});
candidate.incident_review_audit = audit_entries
.iter()
.filter(|entry| entry.candidate_id == candidate.id)
.cloned()
.collect();
}
}
fn summarize_incident_review_audit(
entries: &[IncidentReviewAuditEntry],
) -> IncidentReviewAuditSummary {
let mut summary = IncidentReviewAuditSummary {
total_changes: entries.len(),
..IncidentReviewAuditSummary::default()
};
for entry in entries {
match entry.new_status.as_str() {
"CONFIRMED" => summary.confirmed_count += 1,
"FALSE_POSITIVE" => summary.false_positive_count += 1,
"POSTPONED" => summary.postponed_count += 1,
_ => {}
}
if summary
.last_change_utc
.as_ref()
.is_none_or(|current| entry.changed_at_utc > *current)
{
summary.last_change_utc = Some(entry.changed_at_utc.clone());
}
}
summary
}
fn stable_high_risk_candidates(history: &[BusinessRiskHistoryItem]) -> Vec<RiskIncidentCandidate> {
let mut by_department = BTreeMap::<String, Vec<&BusinessRiskHistoryItem>>::new();
for item in history {
@@ -2720,6 +2791,7 @@ fn stable_high_risk_candidates(history: &[BusinessRiskHistoryItem]) -> Vec<RiskI
.to_string(),
),
incident_review: IncidentReviewState::default(),
incident_review_audit: Vec::new(),
})
})
.collect()
@@ -2748,6 +2820,7 @@ fn low_trust_risk_candidates(
last_seen_utc: Some(snapshot.generated_at_utc.clone()),
recommendation: Some(item.recommendation.clone()),
incident_review: IncidentReviewState::default(),
incident_review_audit: Vec::new(),
})
.collect()
}
@@ -2777,6 +2850,7 @@ fn agent_quality_candidates(
last_seen_utc: Some(node.last_seen_utc.clone()),
recommendation: Some(node.recommendation.clone()),
incident_review: IncidentReviewState::default(),
incident_review_audit: Vec::new(),
});
}
if let Some(error) = &node.collector_error {
@@ -2798,6 +2872,7 @@ fn agent_quality_candidates(
"Проверить журнал агента и восстановить основной сбор.".to_string(),
),
incident_review: IncidentReviewState::default(),
incident_review_audit: Vec::new(),
});
}
}
@@ -2830,6 +2905,7 @@ fn agent_coverage_candidates(snapshot: &Snapshot) -> Vec<RiskIncidentCandidate>
last_seen_utc: Some(snapshot.generated_at_utc.clone()),
recommendation: Some(node.recommendation.clone()),
incident_review: IncidentReviewState::default(),
incident_review_audit: Vec::new(),
})
.collect()
}
@@ -4385,6 +4461,11 @@ fn render_report_markdown(
);
append_risk_incident_candidates_markdown(&mut text, context.risk_incident_candidates);
append_incident_review_markdown(&mut text, context.risk_incident_candidates);
append_incident_review_audit_markdown(
&mut text,
context.risk_incident_candidates,
context.incident_review_audit_summary,
);
append_ueba_risk_markdown(&mut text, context.ueba_risk);
append_workforce_policy_markdown(&mut text, context.workforce_policy);
text.push_str("\nПримечание: DLP/case показатели являются derived detections/cases и требуют регламентной валидации перед подачей как подтвержденные инциденты.\n");
@@ -4666,6 +4747,48 @@ fn append_incident_review_markdown(text: &mut String, candidates: &[RiskIncident
}
}
fn append_incident_review_audit_markdown(
text: &mut String,
candidates: &[RiskIncidentCandidate],
summary: &IncidentReviewAuditSummary,
) {
text.push_str("\n## Аудит проверки инцидентов\n\n");
text.push_str(&format!("- Всего изменений: {}\n", summary.total_changes));
text.push_str(&format!("- Подтверждено: {}\n", summary.confirmed_count));
text.push_str(&format!(
"- Ложных срабатываний: {}\n",
summary.false_positive_count
));
text.push_str(&format!("- Отложено: {}\n", summary.postponed_count));
text.push_str(&format!(
"- Последнее изменение: {}\n",
summary.last_change_utc.as_deref().unwrap_or("-")
));
let mut shown = 0usize;
for entry in candidates
.iter()
.flat_map(|candidate| candidate.incident_review_audit.iter())
.rev()
{
text.push_str(&format!(
"- {}: {} -> {}, reviewer={}, changed_at={}, comment={}\n",
entry.candidate_id,
entry.old_status,
entry.new_status,
entry.reviewer.as_deref().unwrap_or("-"),
entry.changed_at_utc,
entry.comment.as_deref().unwrap_or("-")
));
shown += 1;
if shown >= 20 {
break;
}
}
if shown == 0 {
text.push_str("- История изменений отсутствует.\n");
}
}
fn append_ueba_risk_markdown(text: &mut String, risk: &Value) {
text.push_str("\n## UEBA риск\n\n");
text.push_str(&format!(
@@ -5337,16 +5460,33 @@ fn apply_incident_review(args: &Cli, actor: &str, body: &str) -> Result<Incident
.or_else(|| Some(sanitize_text(actor, 80)))
.filter(|value| !value.is_empty());
let comment = sanitize_optional_text(request.comment, 500);
let mut state = load_incident_review(args)?;
let old_status = state
.reviews
.get(&candidate_id)
.map(|review| review.status.clone())
.unwrap_or_else(|| "NEW".to_string());
let changed_at_utc = now();
let review = IncidentReviewState {
candidate_id: candidate_id.clone(),
status: status.to_string(),
reviewer,
comment,
updated_at: now(),
reviewer: reviewer.clone(),
comment: comment.clone(),
updated_at: changed_at_utc.clone(),
};
let mut state = load_incident_review(args)?;
state.reviews.insert(candidate_id, review.clone());
state.reviews.insert(candidate_id.clone(), review.clone());
save_incident_review(args, &state)?;
append_incident_review_audit(
args,
&IncidentReviewAuditEntry {
candidate_id,
old_status,
new_status: status.to_string(),
reviewer,
comment,
changed_at_utc,
},
)?;
Ok(IncidentReviewResponse { ok: true, review })
}
@@ -5370,6 +5510,16 @@ fn load_incident_review_best_effort(args: &Cli) -> IncidentReviewFile {
}
}
fn load_incident_review_audit_best_effort(args: &Cli) -> Vec<IncidentReviewAuditEntry> {
match load_incident_review_audit(args) {
Ok(entries) => entries,
Err(err) => {
eprintln!("detmir-portal incident review audit read failed: {err:#}");
Vec::new()
}
}
}
fn load_incident_state(args: &Cli) -> Result<IncidentStateFile> {
let path = incident_state_path(args);
if !path.exists() {
@@ -5388,6 +5538,25 @@ fn load_incident_review(args: &Cli) -> Result<IncidentReviewFile> {
serde_json::from_str(&data).with_context(|| format!("parse {}", path.display()))
}
fn load_incident_review_audit(args: &Cli) -> Result<Vec<IncidentReviewAuditEntry>> {
let path = incident_review_audit_path(args);
if !path.exists() {
return Ok(Vec::new());
}
let data = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let mut entries = Vec::new();
for (index, line) in data.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let entry = serde_json::from_str(line)
.with_context(|| format!("parse {} line {}", path.display(), index + 1))?;
entries.push(entry);
}
Ok(entries)
}
fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> {
fs::create_dir_all(&args.state_dir)
.with_context(|| format!("create {}", args.state_dir.display()))?;
@@ -5425,6 +5594,21 @@ fn append_incident_audit(args: &Cli, entry: &IncidentAuditEntry) -> Result<()> {
Ok(())
}
fn append_incident_review_audit(args: &Cli, entry: &IncidentReviewAuditEntry) -> Result<()> {
let path = incident_review_audit_path(args);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("open {}", path.display()))?;
serde_json::to_writer(&mut file, entry)?;
file.write_all(b"\n")?;
Ok(())
}
fn incident_state_path(args: &Cli) -> PathBuf {
args.state_dir.join("incidents-state.json")
}
@@ -5433,6 +5617,12 @@ fn incident_review_path(args: &Cli) -> PathBuf {
args.state_dir.join("data").join("incident_reviews.json")
}
fn incident_review_audit_path(args: &Cli) -> PathBuf {
args.state_dir
.join("data")
.join("incident_review_audit.jsonl")
}
fn build_dlp_evidence_response(args: &Cli) -> DlpEvidenceResponse {
let generated_at_utc = now();
let db_available = args.dlp_db_path.exists();
@@ -7316,6 +7506,14 @@ mod tests {
stored.reviews["risk-candidate-123"].comment.as_deref(),
Some("confirmed by manual check")
);
assert!(incident_review_audit_path(&args).ends_with("data/incident_review_audit.jsonl"));
let audit = load_incident_review_audit(&args).unwrap();
assert_eq!(audit.len(), 1);
assert_eq!(audit[0].candidate_id, "risk-candidate-123");
assert_eq!(audit[0].old_status, "NEW");
assert_eq!(audit[0].new_status, "CONFIRMED");
assert_eq!(audit[0].reviewer.as_deref(), Some("operator"));
assert_eq!(summarize_incident_review_audit(&audit).confirmed_count, 1);
let mut candidates = vec![RiskIncidentCandidate {
id: "risk-candidate-123".to_string(),
department: Some("Подразделение".to_string()),
@@ -7328,9 +7526,11 @@ mod tests {
last_seen_utc: None,
recommendation: None,
incident_review: IncidentReviewState::default(),
incident_review_audit: Vec::new(),
}];
apply_incident_reviews_to_candidates(&mut candidates, &stored);
apply_incident_reviews_to_candidates(&mut candidates, &stored, &audit);
assert_eq!(candidates[0].incident_review.status, "CONFIRMED");
assert_eq!(candidates[0].incident_review_audit.len(), 1);
assert_eq!(
validate_incident_review_status("FALSE_POSITIVE").unwrap(),
"FALSE_POSITIVE"
@@ -7713,6 +7913,7 @@ mod tests {
ReportRuntimeInputs {
incident_state: &IncidentStateFile::default(),
incident_reviews: &IncidentReviewFile::default(),
incident_review_audit: &[],
evidence: &evidence,
},
&missing_policy,
@@ -7823,6 +8024,7 @@ mod tests {
report["risk_incident_candidates"][0]["incident_review"]["status"],
"NEW"
);
assert_eq!(report["incident_review_audit_summary"]["total_changes"], 0);
assert!(
report["executive_points"]
.as_array()
@@ -7880,6 +8082,12 @@ mod tests {
.unwrap()
.contains("## Проверка кандидатов в инциденты")
);
assert!(
report["markdown"]
.as_str()
.unwrap()
.contains("## Аудит проверки инцидентов")
);
assert!(report["markdown"].as_str().unwrap().contains("причины:"));
assert!(
report["markdown"]
@@ -604,6 +604,29 @@ h1 {
font-weight: 800;
}
.review-history {
margin-top: 6px;
max-width: 320px;
}
.review-history summary {
cursor: pointer;
color: var(--link);
font-size: 12px;
font-weight: 800;
}
.review-history ul {
display: grid;
gap: 6px;
margin: 6px 0 0;
padding-left: 16px;
}
.review-history li {
overflow-wrap: anywhere;
}
.heatmap-table td {
min-width: 120px;
}
@@ -1779,7 +1779,28 @@ function renderCandidateReview(item) {
const comment = review.comment || "комментария нет";
const reviewer = review.reviewer || "проверяющий не указан";
const updated = review.updated_at || "не обновлялось";
return `<span class="badge ${statusClass(status)}">${ui(reviewStatusText(status))}</span><br><span class="muted small">${ui(reviewer)} · ${ui(updated)}</span><br><span class="muted small">${ui(comment)}</span>`;
const audit = Array.isArray(item?.incident_review_audit) ? item.incident_review_audit.slice(-4).reverse() : [];
const history = audit.length ? `
<details class="review-history">
<summary>История изменений</summary>
<ul>
${audit.map(entry => `
<li>
<span>${ui(reviewStatusText(entry.old_status))} ${ui(reviewStatusText(entry.new_status))}</span><br>
<span class="muted small">${ui(entry.reviewer || "проверяющий не указан")} · ${ui(entry.changed_at_utc || "-")}</span><br>
<span class="muted small">${ui(entry.comment || "комментария нет")}</span>
</li>
`).join("")}
</ul>
</details>
` : `<span class="muted small">История изменений отсутствует</span>`;
return `
<span class="badge ${statusClass(status)}">${ui(reviewStatusText(status))}</span><br>
<span class="muted small">Изменил: ${ui(reviewer)}</span><br>
<span class="muted small">Когда: ${ui(updated)}</span><br>
<span class="muted small">Комментарий: ${ui(comment)}</span>
${history}
`;
}
function renderCandidateReviewActions(item) {
+23
View File
@@ -69,6 +69,7 @@ Business Risk не является автоматическим обвинен
- `last_seen_utc` - последнее наблюдение;
- `recommendation` - рекомендуемое действие.
- `incident_review` - статус проверки кандидата.
- `incident_review_audit` - история изменений статуса проверки кандидата.
`incident_review`:
@@ -78,6 +79,15 @@ Business Risk не является автоматическим обвинен
- `comment` - комментарий проверки;
- `updated_at` - время последнего изменения статуса.
`incident_review_audit`:
- `candidate_id` - идентификатор кандидата;
- `old_status` - предыдущий статус;
- `new_status` - новый статус;
- `reviewer` - проверяющий, если указан;
- `comment` - комментарий к изменению;
- `changed_at_utc` - время изменения в UTC.
Для изменения review-статуса используется:
```http
@@ -99,9 +109,13 @@ POST /api/incident-review
```text
<state_dir>/data/incident_reviews.json
<state_dir>/data/incident_review_audit.jsonl
```
Если файла нет, все кандидаты отображаются как `NEW`.
Если audit-файла нет, портал продолжает работать, а история изменений
отображается как отсутствующая. Audit JSONL является append-only журналом:
история изменений не удаляется и не перезаписывается при смене статуса.
## Логика риска
@@ -155,6 +169,14 @@ POST /api/incident-review
- `Ложный` -> `FALSE_POSITIVE`;
- `Отложить` -> `POSTPONED`.
Для каждого кандидата портал показывает текущий статус, проверяющего, время
последнего изменения, последний комментарий и раскрываемую историю изменений.
Это формирует доказательную цепочку:
```text
кандидат -> проверка -> решение -> кто/когда/почему
```
## Markdown
Оперативный отчет содержит раздел:
@@ -164,6 +186,7 @@ POST /api/incident-review
## Динамика бизнес-рисков
## Кандидаты в инциденты
## Проверка кандидатов в инциденты
## Аудит проверки инцидентов
```
Раздел можно использовать в PDF/Markdown-отчете для руководителя.