From a672941cc7b6b6db780ee9425acf6cd9a3aff99c Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 4 Jun 2026 19:14:45 +0300 Subject: [PATCH] fix(portal): sanitize corrupt workforce labels --- adk-rust/crates/detmir-portal/src/main.rs | 261 +++++++++++++++------- 1 file changed, 185 insertions(+), 76 deletions(-) diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index f2fe05f..0061c80 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -25,6 +25,7 @@ const APP_CSS: &str = include_str!("static/app.css"); const APP_JS: &str = include_str!("static/app.js"); const UEBA_BASELINE_MIN_SAMPLES: usize = 3; const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(5); +const DEFAULT_DEPARTMENT_LABEL: &str = "Без подразделения"; type SnapshotCache = Arc>>; @@ -1743,6 +1744,109 @@ fn load_expected_nodes(path: &Path) -> Vec { sanitize_expected_nodes(nodes) } +fn display_department_name(value: Option<&str>) -> String { + display_name_opt(value, DEFAULT_DEPARTMENT_LABEL) +} + +fn display_rollup_name(value: Option<&str>, key: &str) -> String { + let fallback = if key == "department_rollups" { + DEFAULT_DEPARTMENT_LABEL + } else { + "Без группы" + }; + display_name_opt(value, fallback) +} + +fn display_name_or(value: &str, fallback: &str) -> String { + display_name_opt(Some(value), fallback) +} + +fn display_name_opt(value: Option<&str>, fallback: &str) -> String { + let Some(value) = value else { + return fallback.to_string(); + }; + let value = value.trim(); + if value.is_empty() || has_broken_display_chars(value) { + fallback.to_string() + } else { + value.to_string() + } +} + +fn display_text_opt(value: Option<&str>, fallback: &str) -> String { + let Some(value) = value else { + return fallback.to_string(); + }; + repair_broken_display_text(value, fallback) +} + +fn repair_broken_display_text(value: &str, fallback: &str) -> String { + let value = value.trim(); + if value.is_empty() { + return fallback.to_string(); + } + if !has_broken_display_chars(value) { + return value.to_string(); + } + let mut repaired = String::new(); + let mut replacement_open = false; + for ch in value.chars() { + if ch == '\u{FFFD}' { + if !replacement_open { + repaired.push_str(fallback); + replacement_open = true; + } + continue; + } + replacement_open = false; + if ch.is_control() && ch != '\t' { + continue; + } + repaired.push(ch); + } + let repaired = repaired.trim(); + if repaired.is_empty() { + fallback.to_string() + } else { + repaired.to_string() + } +} + +fn sanitize_workforce_json(value: Value) -> Value { + match value { + Value::Array(items) => { + Value::Array(items.into_iter().map(sanitize_workforce_json).collect()) + } + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(key, value)| { + let value = match (key.as_str(), value) { + ("name", Value::String(value)) => { + Value::String(display_name_or(&value, "Без группы")) + } + ("user", Value::String(value)) => { + Value::String(display_name_or(&value, "Пользователь не определён")) + } + ("user_id", Value::String(value)) => { + Value::String(display_text_opt(Some(&value), "unknown")) + } + (_, value) => sanitize_workforce_json(value), + }; + (key, value) + }) + .collect(), + ), + Value::String(value) => Value::String(display_text_opt(Some(&value), "Без значения")), + value => value, + } +} + +fn has_broken_display_chars(value: &str) -> bool { + value + .chars() + .any(|ch| ch == '\u{FFFD}' || (ch.is_control() && ch != '\t')) +} + fn sanitize_expected_nodes(nodes: Vec) -> Vec { let mut by_hostname = BTreeMap::new(); for node in nodes { @@ -1754,9 +1858,9 @@ fn sanitize_expected_nodes(nodes: Vec) -> Vec { hostname.to_string(), ExpectedNode { hostname: hostname.to_string(), - department: node.department.trim().to_string(), - owner: node.owner.trim().to_string(), - criticality: node.criticality.trim().to_string(), + department: display_name_or(&node.department, "Не задано"), + owner: display_name_or(&node.owner, "Не назначен"), + criticality: display_name_or(&node.criticality, "normal"), }, ); } @@ -2599,10 +2703,7 @@ fn workforce_rollup_items(snapshot: &Snapshot, key: &str) -> Vec { .iter() .filter(|item| item.get("users_count").and_then(Value::as_i64).unwrap_or(0) > 0) .map(|item| { - let name = item - .get("name") - .and_then(Value::as_str) - .unwrap_or("Без группы"); + let name = display_rollup_name(item.get("name").and_then(Value::as_str), key); let coverage = item .get("portfolio_coverage_pct") .and_then(Value::as_f64) @@ -2617,7 +2718,7 @@ fn workforce_rollup_items(snapshot: &Snapshot, key: &str) -> Vec { .and_then(Value::as_str) .unwrap_or("00:00"); report_item( - name, + &name, coverage_status(coverage), format!("{coverage:.0}% · active {active}/{users} · {hhmm}"), ) @@ -2630,12 +2731,7 @@ fn workforce_rollup_items(snapshot: &Snapshot, key: &str) -> Vec { fn build_business_risk(snapshot: &Snapshot, department_items: &[Value]) -> Vec { let mut departments = BTreeMap::>::new(); for item in department_items { - let department = item - .get("label") - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .unwrap_or("Без подразделения") - .to_string(); + let department = display_department_name(item.get("label").and_then(Value::as_str)); let activity = item .get("value") .and_then(Value::as_str) @@ -2643,12 +2739,8 @@ fn build_business_risk(snapshot: &Snapshot, department_items: &[Value]) -> Vec (usize .problem_nodes .iter() .filter(|node| { - let node_department = if node.department.trim().is_empty() { - "Не задано" - } else { - node.department.as_str() - }; + let node_department = display_name_or(&node.department, "Не задано"); node_department == department }) .collect::>(); @@ -2837,12 +2925,7 @@ fn build_business_risk_history(snapshot: &Snapshot) -> Vec::new(); for item in rollups { - let department = item - .get("name") - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .unwrap_or("Без подразделения") - .to_string(); + let department = display_department_name(item.get("name").and_then(Value::as_str)); let activity_score = item .get("portfolio_coverage_pct") .and_then(Value::as_f64) @@ -3109,10 +3192,7 @@ fn open_case_counts_by_department( .map(|item| { ( item.id.as_str(), - item.department - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("Не задано"), + display_name_opt(item.department.as_deref(), "Не задано"), ) }) .collect::>(); @@ -3124,9 +3204,9 @@ fn open_case_counts_by_department( { let department = candidate_departments .get(item.candidate_id.as_str()) - .copied() - .unwrap_or("Не задано"); - *counts.entry(department.to_string()).or_insert(0) += 1; + .cloned() + .unwrap_or_else(|| "Не задано".to_string()); + *counts.entry(department).or_insert(0) += 1; } counts } @@ -3141,12 +3221,8 @@ fn critical_candidate_counts_by_department( "HIGH" | "CRITICAL" ) }) { - let department = item - .department - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("Не задано"); - *counts.entry(department.to_string()).or_insert(0) += 1; + let department = display_name_opt(item.department.as_deref(), "Не задано"); + *counts.entry(department).or_insert(0) += 1; } counts } @@ -4083,8 +4159,8 @@ fn agent_quality_candidates( if !node.kpi_accepted { items.push(RiskIncidentCandidate { id: risk_candidate_id("kpi-not-accepted", &node.hostname, &node.status), - department: coverage.map(|item| item.department.clone()), - owner: coverage.map(|item| item.owner.clone()), + department: coverage.map(|item| display_name_or(&item.department, "Не задано")), + owner: coverage.map(|item| display_name_or(&item.owner, "Не назначен")), hostname: Some(node.hostname.clone()), risk_level: Some(agent_quality_candidate_level(node)), reason: Some("KPI не принят".to_string()), @@ -4104,8 +4180,8 @@ fn agent_quality_candidates( if let Some(error) = &node.collector_error { items.push(RiskIncidentCandidate { id: risk_candidate_id("collector-error", &node.hostname, error), - department: coverage.map(|item| item.department.clone()), - owner: coverage.map(|item| item.owner.clone()), + department: coverage.map(|item| display_name_or(&item.department, "Не задано")), + owner: coverage.map(|item| display_name_or(&item.owner, "Не назначен")), hostname: Some(node.hostname.clone()), risk_level: Some("HIGH".to_string()), reason: Some("collector_error".to_string()), @@ -4135,8 +4211,8 @@ fn agent_coverage_candidates(snapshot: &Snapshot) -> Vec .filter(|node| matches!(node.status.as_str(), "MISSING" | "STALE")) .map(|node| RiskIncidentCandidate { id: risk_candidate_id("coverage-node", &node.hostname, &node.status), - department: Some(node.department.clone()), - owner: Some(node.owner.clone()), + department: Some(display_name_or(&node.department, "Не задано")), + owner: Some(display_name_or(&node.owner, "Не назначен")), hostname: Some(node.hostname.clone()), risk_level: Some(if node.status == "MISSING" { "HIGH".to_string() @@ -4254,7 +4330,9 @@ fn department_trend_delta(snapshot: &Snapshot, department: &str) -> Option day.get("department_rollups") .and_then(Value::as_array)? .iter() - .find(|item| item.get("name").and_then(Value::as_str) == Some(department)) + .find(|item| { + display_department_name(item.get("name").and_then(Value::as_str)) == department + }) .and_then(|item| item.get("portfolio_coverage_pct").and_then(Value::as_f64)) }) .collect::>(); @@ -4313,6 +4391,7 @@ fn workforce_trend_json(snapshot: &Snapshot) -> Value { .as_ref() .and_then(|payload| payload.get("trend")) .cloned() + .map(sanitize_workforce_json) .unwrap_or_else(|| json!([])) } @@ -4327,23 +4406,20 @@ fn workforce_insight_items(snapshot: &Snapshot) -> Vec { items .iter() .map(|item| { - let title = item - .get("title") - .and_then(Value::as_str) - .unwrap_or("Вывод Workforce"); - let subject = item - .get("subject") - .and_then(Value::as_str) - .unwrap_or("Workforce"); + let title = display_text_opt( + item.get("title").and_then(Value::as_str), + "Вывод Workforce", + ); + let subject = + display_name_opt(item.get("subject").and_then(Value::as_str), "Workforce"); let severity = item .get("severity") .and_then(Value::as_str) .unwrap_or("INFO"); - let evidence = item.get("evidence").and_then(Value::as_str).unwrap_or(""); - let recommendation = item - .get("recommendation") - .and_then(Value::as_str) - .unwrap_or(""); + let evidence = + display_text_opt(item.get("evidence").and_then(Value::as_str), ""); + let recommendation = + display_text_opt(item.get("recommendation").and_then(Value::as_str), ""); report_item( &format!("{title}: {subject}"), severity, @@ -4595,8 +4671,8 @@ fn current_department_baseline_points(snapshot: &Snapshot) -> Vec Vec