Files
AWatch-rus/adk-rust/crates/detmir-portal/src/main.rs
T

3369 lines
114 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use chrono::{SecondsFormat, Utc};
use clap::Parser;
use reqwest::blocking::Client;
use reqwest::header::{CONNECTION, HeaderValue};
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
const INDEX_HTML: &str = include_str!("static/index.html");
const APP_CSS: &str = include_str!("static/app.css");
const APP_JS: &str = include_str!("static/app.js");
#[derive(Debug, Parser)]
#[command(about = "Read-only DetMir operator/manager/owner web portal")]
struct Cli {
#[arg(long, default_value = "127.0.0.1:8720", env = "DETMIR_PORTAL_BIND")]
bind: String,
#[arg(
long,
default_value = "detmir-status --json",
env = "DETMIR_PORTAL_STATUS_CMD"
)]
status_cmd: String,
#[arg(
long,
default_value = "detmir-check --json",
env = "DETMIR_PORTAL_CHECK_CMD"
)]
check_cmd: String,
#[arg(
long,
default_value = "systemctl --failed --no-pager",
env = "DETMIR_PORTAL_FAILED_UNITS_CMD"
)]
failed_units_cmd: String,
#[arg(
long,
default_value = "http://192.0.2.13:5610",
env = "DETMIR_PORTAL_WORKTIME_URL"
)]
worktime_url: String,
#[arg(
long,
default_value = "http://192.0.2.2:8710",
env = "DETMIR_PORTAL_ONE_C_URL"
)]
one_c_url: String,
#[arg(
long,
default_value = "/etc/detmir-portal-workforce-policy.json",
env = "DETMIR_PORTAL_WORKFORCE_POLICY_PATH"
)]
workforce_policy_path: PathBuf,
#[arg(long, default_value_t = 10, env = "DETMIR_PORTAL_TIMEOUT_SECONDS")]
timeout_seconds: u64,
#[arg(
long,
default_value = "/var/lib/detmir-portal",
env = "DETMIR_PORTAL_STATE_DIR"
)]
state_dir: PathBuf,
#[arg(
long,
default_value = "/var/lib/activitywatch/dlp_warehouse.sqlite",
env = "DETMIR_PORTAL_DLP_DB_PATH"
)]
dlp_db_path: PathBuf,
#[arg(
long,
default_value = "/var/lib/detmir-portal/evidence",
env = "DETMIR_PORTAL_EVIDENCE_ROOT"
)]
evidence_root: PathBuf,
#[arg(long, default_value_t = 30, env = "DETMIR_PORTAL_EVIDENCE_LIMIT")]
evidence_limit: u32,
#[arg(
long,
default_value_t = 8 * 1024 * 1024,
env = "DETMIR_PORTAL_EVIDENCE_MAX_BYTES"
)]
evidence_max_bytes: u64,
#[arg(long)]
json_smoke: bool,
#[arg(long, env = "DETMIR_PORTAL_EVIDENCE_ONLY")]
evidence_only: bool,
#[arg(long, env = "DETMIR_PORTAL_EVIDENCE_UPLOAD_TOKEN")]
evidence_upload_token: Option<String>,
}
#[derive(Debug, Serialize)]
struct SourceStatus {
ok: bool,
status: String,
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
payload: Option<Value>,
}
#[derive(Debug, Serialize)]
struct HealthResponse {
ok: bool,
generated_at_utc: String,
version: String,
sources: BTreeMap<String, bool>,
}
#[derive(Debug, Serialize)]
struct SummaryBlock {
status: String,
text: String,
}
#[derive(Debug, Serialize)]
struct SummaryResponse {
severity: String,
operator_ok: bool,
headline: String,
generated_at_utc: String,
blocks: BTreeMap<String, SummaryBlock>,
}
#[derive(Debug, Serialize)]
struct IncidentItem {
id: String,
status: String,
kind: String,
source: String,
summary: String,
generated_at_utc: String,
link: String,
acknowledged: bool,
#[serde(skip_serializing_if = "Option::is_none")]
acknowledged_at_utc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
actor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
assigned_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
comment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at_utc: Option<String>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct IncidentStateFile {
incidents: BTreeMap<String, IncidentActionState>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct IncidentActionState {
state: String,
actor: String,
updated_at_utc: String,
#[serde(skip_serializing_if = "Option::is_none")]
acknowledged_at_utc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
assigned_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
comment: Option<String>,
}
#[derive(Debug, Deserialize)]
struct IncidentActionRequest {
id: String,
action: String,
#[serde(default)]
assigned_to: Option<String>,
#[serde(default)]
comment: Option<String>,
}
#[derive(Debug, Serialize)]
struct IncidentActionResponse {
ok: bool,
id: String,
state: IncidentActionState,
}
#[derive(Debug, Serialize)]
struct IncidentAuditEntry {
generated_at_utc: String,
actor: String,
id: String,
action: String,
#[serde(skip_serializing_if = "Option::is_none")]
assigned_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
comment: Option<String>,
}
#[derive(Debug, Serialize)]
struct EvidenceAuditEntry {
generated_at_utc: String,
actor: String,
action: String,
evidence_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
sha256: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
source_file: Option<String>,
}
#[derive(Debug, Deserialize)]
struct EvidenceUploadRequest {
sha256: String,
#[serde(default)]
content_base64: String,
#[serde(default)]
content_type: Option<String>,
#[serde(default)]
source_file: Option<String>,
#[serde(default)]
source_path: Option<String>,
#[serde(default)]
hostname: Option<String>,
#[serde(default)]
username: Option<String>,
}
#[derive(Debug, Serialize)]
struct EvidenceUploadResponse {
ok: bool,
sha256: String,
content_type: String,
bytes: u64,
stored: bool,
path: String,
}
#[derive(Debug, Serialize)]
struct DlpEvidenceResponse {
ok: bool,
generated_at_utc: String,
db_available: bool,
screenshot_root_available: bool,
limit: u32,
items: Vec<DlpEvidenceItem>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct DlpEvidenceItem {
id: String,
event_ts: String,
bucket_id: String,
event_id: String,
stream_type: String,
hostname: String,
#[serde(skip_serializing_if = "Option::is_none")]
username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
severity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
signal_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rule_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
action: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
file_path: Option<String>,
has_screenshot_metadata: bool,
screenshot_available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
source_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
screenshot_sha256: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
screenshot_width: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
screenshot_height: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
preview_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
download_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
blocked_reason: Option<String>,
}
#[derive(Debug)]
struct DlpEvidenceRow {
row_id: i64,
bucket_id: String,
event_id: String,
stream_type: String,
hostname: String,
username: Option<String>,
event_ts: String,
operation: Option<String>,
file_path: Option<String>,
rule_id: Option<String>,
action: Option<String>,
severity: Option<String>,
signal_type: Option<String>,
message: Option<String>,
source: Option<String>,
screenshot_path: Option<String>,
raw_json: String,
}
#[derive(Debug, Clone)]
struct ScreenshotFile {
path: PathBuf,
content_type: &'static str,
source_file: Option<String>,
sha256: Option<String>,
}
#[derive(Debug, Serialize)]
struct PortalLinks {
portal: String,
grafana_dashboards: String,
detmir_activitywatch: String,
dlp_security_dashboard: String,
dlp_management_dashboard: String,
dlp_overview_dashboard: String,
aw_ui: String,
worktime_report: String,
file1c_brief: String,
file1c_actions: String,
}
#[derive(Debug)]
struct Snapshot {
generated_at_utc: String,
detmir_status: SourceStatus,
detmir_check: SourceStatus,
failed_units: SourceStatus,
worktime: SourceStatus,
worktime_management: SourceStatus,
one_c: SourceStatus,
}
#[derive(Debug)]
struct ReportMetrics {
users_count: usize,
active_seconds: i64,
apps_count: usize,
dlp_ok: u64,
dlp_warn: u64,
dlp_fail: u64,
evidence_total: usize,
evidence_screenshots: usize,
open_incidents: usize,
acknowledged_incidents: usize,
workforce_index: Option<u8>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct WorkforcePolicy {
#[serde(default = "default_workforce_role")]
default_role: String,
#[serde(default)]
roles: BTreeMap<String, WorkforceRolePolicy>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct WorkforceRolePolicy {
#[serde(default)]
label: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
planned_hours_per_day: Option<f64>,
#[serde(default)]
default_weight: Option<f64>,
#[serde(default)]
application_weights: BTreeMap<String, f64>,
}
#[derive(Debug)]
struct WeightedActivity {
role: String,
role_label: String,
index: Option<u8>,
planned_seconds: i64,
app_seconds: i64,
weighted_seconds: i64,
matched_applications: usize,
explanation: String,
app_details: Vec<AppWeightDetail>,
}
#[derive(Debug)]
struct AppWeightDetail {
application: String,
seconds: i64,
weight: f64,
weighted_seconds: i64,
matched_rule: String,
}
#[derive(Debug)]
struct ReportWorkforceSummary {
departments_count: usize,
owners_count: usize,
insights_count: usize,
trend_status: String,
}
fn main() {
let code = match run() {
Ok(code) => code,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
fn run() -> Result<i32> {
let args = Cli::parse();
if args.json_smoke {
let snapshot = build_snapshot(&args);
let incident_state = load_incident_state_best_effort(&args);
let smoke = json!({
"health": build_health(&snapshot),
"summary": build_summary(&snapshot),
"reports": build_reports(&snapshot, &incident_state, &build_dlp_evidence_response(&args), &args.workforce_policy_path),
"incidents": build_incidents(&snapshot, &incident_state),
"dlp_evidence": build_dlp_evidence_response(&args),
});
println!("{}", serde_json::to_string_pretty(&smoke)?);
return Ok(if build_health(&snapshot).ok { 0 } else { 2 });
}
let server = Server::http(&args.bind).map_err(|err| anyhow!("bind {}: {err}", args.bind))?;
eprintln!("detmir-portal listening on http://{}", args.bind);
for request in server.incoming_requests() {
let result = if args.evidence_only {
handle_evidence_only_request(request, &args)
} else {
handle_request(request, &args)
};
if let Err(err) = result {
eprintln!("detmir-portal request failed: {err:#}");
}
}
Ok(0)
}
fn handle_request(request: Request, args: &Cli) -> Result<()> {
let method = request.method().clone();
let path = normalize_path(request.url());
if method == Method::Post && path == "/api/incidents/action" {
return handle_incident_action(request, args);
}
if method != Method::Get {
return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain");
}
if path == "/api/dlp/evidence" {
return respond_json(request, &build_dlp_evidence_response(args));
}
if let Some((evidence_id, download)) = parse_evidence_screenshot_path(&path) {
return handle_evidence_screenshot(request, args, &evidence_id, download);
}
match path.as_str() {
"/" | "/operator" | "/manager" | "/owner" | "/incidents" | "/reports" => respond_text(
request,
StatusCode(200),
INDEX_HTML,
"text/html; charset=utf-8",
),
"/app.css" => respond_text(request, StatusCode(200), APP_CSS, "text/css; charset=utf-8"),
"/app.js" => respond_text(
request,
StatusCode(200),
APP_JS,
"application/javascript; charset=utf-8",
),
"/favicon.ico" => respond_text(request, StatusCode(204), "", "image/x-icon"),
"/api/health" => respond_json(request, &build_health(&build_snapshot(args))),
"/api/summary" => respond_json(request, &build_summary(&build_snapshot(args))),
"/api/operator" => {
let snapshot = build_snapshot(args);
let incident_state = load_incident_state_best_effort(args);
respond_json(request, &build_operator(&snapshot, &incident_state))
}
"/api/manager" => {
let snapshot = build_snapshot(args);
respond_json(request, &build_manager(&snapshot))
}
"/api/owner" => {
let snapshot = build_snapshot(args);
respond_json(request, &build_owner(&snapshot))
}
"/api/reports" => {
let snapshot = build_snapshot(args);
let incident_state = load_incident_state_best_effort(args);
let evidence = build_dlp_evidence_response(args);
respond_json(
request,
&build_reports(
&snapshot,
&incident_state,
&evidence,
&args.workforce_policy_path,
),
)
}
"/api/incidents" => {
let snapshot = build_snapshot(args);
let incident_state = load_incident_state_best_effort(args);
respond_json(request, &build_incidents(&snapshot, &incident_state))
}
"/api/links" => respond_json(request, &links()),
_ => respond_text(
request,
StatusCode(404),
"Not Found",
"text/plain; charset=utf-8",
),
}
}
fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> {
let method = request.method().clone();
let path = normalize_path(request.url());
if method == Method::Post && path == "/api/dlp/evidence/upload" {
return handle_evidence_upload(request, args);
}
if method != Method::Get {
return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain");
}
if path == "/api/health" {
return respond_json(
request,
&json!({
"ok": args.dlp_db_path.exists(),
"generated_at_utc": now(),
"mode": "evidence-only",
"db_available": args.dlp_db_path.exists(),
"screenshot_root_available": args.evidence_root.exists(),
"upload_enabled": upload_enabled(args),
}),
);
}
if path == "/api/dlp/evidence" {
return respond_json(request, &build_dlp_evidence_response(args));
}
if let Some((evidence_id, download)) = parse_evidence_screenshot_path(&path) {
return handle_evidence_screenshot(request, args, &evidence_id, download);
}
respond_text(
request,
StatusCode(404),
"Not Found",
"text/plain; charset=utf-8",
)
}
fn normalize_path(url: &str) -> String {
let path = url.split('?').next().unwrap_or("/");
let path = path.strip_prefix("/portal").unwrap_or(path);
if path.is_empty() {
"/".to_string()
} else {
path.to_string()
}
}
fn build_snapshot(args: &Cli) -> Snapshot {
let timeout = Duration::from_secs(args.timeout_seconds);
Snapshot {
generated_at_utc: now(),
detmir_status: command_json_source("detmir_status", &args.status_cmd, timeout),
detmir_check: command_json_source("detmir_check", &args.check_cmd, timeout),
failed_units: command_text_source("failed_units", &args.failed_units_cmd, timeout),
worktime: http_json_source(
"worktime_api",
&format!(
"{}/reports/worktime/today",
args.worktime_url.trim_end_matches('/')
),
timeout,
),
worktime_management: http_json_source(
"worktime_management",
&format!(
"{}/reports/worktime/management?format=json&allow_stale=1",
args.worktime_url.trim_end_matches('/')
),
timeout,
),
one_c: http_json_source(
"one_c",
&format!("{}/api/health", args.one_c_url.trim_end_matches('/')),
timeout,
),
}
}
fn command_json_source(name: &str, command: &str, timeout: Duration) -> SourceStatus {
match run_shell(command, timeout) {
Ok((stdout, stderr, success)) => {
if !success {
return SourceStatus {
ok: false,
status: "FAIL".to_string(),
summary: format!("{name} command returned non-zero status"),
error: Some(stderr.trim().to_string()),
payload: None,
};
}
match serde_json::from_str::<Value>(&stdout) {
Ok(payload) => SourceStatus {
ok: payload_bool(&payload, "/ok").unwrap_or(true),
status: status_from_payload(&payload),
summary: source_summary(name, &payload),
error: None,
payload: Some(payload),
},
Err(err) => SourceStatus {
ok: false,
status: "FAIL".to_string(),
summary: format!("{name} returned invalid JSON"),
error: Some(err.to_string()),
payload: None,
},
}
}
Err(err) => SourceStatus {
ok: false,
status: "FAIL".to_string(),
summary: format!("{name} command failed"),
error: Some(err.to_string()),
payload: None,
},
}
}
fn command_text_source(name: &str, command: &str, timeout: Duration) -> SourceStatus {
match run_shell(command, timeout) {
Ok((stdout, stderr, success)) => {
let text = stdout.trim();
let no_failed_units =
text.contains("0 loaded units listed") || text.contains("UNIT LOAD ACTIVE SUB");
SourceStatus {
ok: success || no_failed_units,
status: if success || no_failed_units {
"OK"
} else {
"WARN"
}
.to_string(),
summary: if success || no_failed_units {
"failed units not reported".to_string()
} else {
"failed units command returned non-zero".to_string()
},
error: if stderr.trim().is_empty() {
None
} else {
Some(stderr.trim().to_string())
},
payload: Some(json!({ "stdout": text })),
}
}
Err(err) => SourceStatus {
ok: false,
status: "FAIL".to_string(),
summary: format!("{name} command failed"),
error: Some(err.to_string()),
payload: None,
},
}
}
fn http_json_source(name: &str, url: &str, timeout: Duration) -> SourceStatus {
let client = match Client::builder().timeout(timeout).no_proxy().build() {
Ok(client) => client,
Err(err) => {
return SourceStatus {
ok: false,
status: "FAIL".to_string(),
summary: format!("{name} HTTP client failed"),
error: Some(err.to_string()),
payload: None,
};
}
};
match client
.get(url)
.header(CONNECTION, HeaderValue::from_static("close"))
.send()
{
Ok(response) => match response.error_for_status() {
Ok(response) => match response.json::<Value>() {
Ok(payload) => SourceStatus {
ok: true,
status: status_from_payload(&payload),
summary: source_summary(name, &payload),
error: None,
payload: Some(payload),
},
Err(err) => SourceStatus {
ok: false,
status: "FAIL".to_string(),
summary: format!("{name} returned invalid JSON"),
error: Some(err.to_string()),
payload: None,
},
},
Err(err) => SourceStatus {
ok: false,
status: "FAIL".to_string(),
summary: format!("{name} HTTP status failed"),
error: Some(err.to_string()),
payload: None,
},
},
Err(err) => SourceStatus {
ok: false,
status: "FAIL".to_string(),
summary: format!("{name} request failed"),
error: Some(err.to_string()),
payload: None,
},
}
}
fn run_shell(command: &str, timeout: Duration) -> Result<(String, String, bool)> {
let mut child = Command::new("/bin/sh")
.arg("-lc")
.arg(command)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("spawn {command}"))?;
let started = Instant::now();
loop {
if let Some(status) = child.try_wait()? {
let mut stdout = String::new();
let mut stderr = String::new();
if let Some(mut pipe) = child.stdout.take() {
pipe.read_to_string(&mut stdout)?;
}
if let Some(mut pipe) = child.stderr.take() {
pipe.read_to_string(&mut stderr)?;
}
return Ok((stdout, stderr, status.success()));
}
if started.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return Err(anyhow!(
"command timed out after {}s: {command}",
timeout.as_secs()
));
}
thread::sleep(Duration::from_millis(100));
}
}
fn build_health(snapshot: &Snapshot) -> HealthResponse {
let mut sources = BTreeMap::new();
sources.insert("detmir_status".to_string(), snapshot.detmir_status.ok);
sources.insert("detmir_check".to_string(), snapshot.detmir_check.ok);
sources.insert("grafana_check".to_string(), grafana_data_ok(snapshot));
sources.insert("worktime_api".to_string(), snapshot.worktime.ok);
sources.insert(
"worktime_management".to_string(),
snapshot.worktime_management.ok,
);
sources.insert("dlp_health".to_string(), dlp_ok(snapshot));
sources.insert("one_c".to_string(), snapshot.one_c.ok);
HealthResponse {
ok: sources.values().all(|value| *value),
generated_at_utc: snapshot.generated_at_utc.clone(),
version: env!("CARGO_PKG_VERSION").to_string(),
sources,
}
}
fn build_summary(snapshot: &Snapshot) -> SummaryResponse {
let severity = snapshot
.detmir_status
.payload
.as_ref()
.and_then(|value| value.get("severity"))
.and_then(Value::as_str)
.unwrap_or(if snapshot.detmir_status.ok {
"OK"
} else {
"FAIL"
})
.to_string();
let operator_ok = snapshot
.detmir_status
.payload
.as_ref()
.and_then(|value| value.get("ok_for_operator"))
.and_then(Value::as_bool)
.unwrap_or(false);
let mut blocks = BTreeMap::new();
blocks.insert(
"collection".to_string(),
collection_block(snapshot.detmir_check.payload.as_ref()),
);
blocks.insert("grafana".to_string(), grafana_block(snapshot));
blocks.insert("dlp".to_string(), dlp_block(snapshot));
blocks.insert("worktime".to_string(), worktime_block(snapshot));
blocks.insert("one_c".to_string(), one_c_block(snapshot));
SummaryResponse {
severity: severity.clone(),
operator_ok,
headline: if operator_ok && severity == "OK" {
"Контур работает штатно".to_string()
} else {
"Контур требует внимания".to_string()
},
generated_at_utc: snapshot.generated_at_utc.clone(),
blocks,
}
}
fn build_operator(snapshot: &Snapshot, incident_state: &IncidentStateFile) -> Value {
json!({
"generated_at_utc": snapshot.generated_at_utc,
"summary": build_summary(snapshot),
"detmir_status": snapshot.detmir_status,
"detmir_check": snapshot.detmir_check,
"failed_units": snapshot.failed_units,
"grafana_data": grafana_service(snapshot),
"worktime_management": snapshot.worktime_management,
"links": links(),
"incidents": build_incidents(snapshot, incident_state),
})
}
fn build_manager(snapshot: &Snapshot) -> Value {
let worktime = snapshot
.worktime
.payload
.clone()
.unwrap_or_else(|| json!({}));
let rows = worktime
.get("rows")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let apps = worktime
.get("true_active_apps")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let total_active_seconds: i64 = rows
.iter()
.filter_map(|row| row.get("active_seconds").and_then(Value::as_i64))
.sum();
json!({
"generated_at_utc": snapshot.generated_at_utc,
"status": worktime_block(snapshot),
"report_date": worktime.get("report_date"),
"users_count": rows.len(),
"total_active_seconds": total_active_seconds,
"total_active_hours": (total_active_seconds as f64 / 3600.0),
"users": rows,
"applications": apps,
"links": links(),
"source": snapshot.worktime,
})
}
fn build_owner(snapshot: &Snapshot) -> Value {
let summary = build_summary(snapshot);
let recommendations = owner_recommendations(snapshot, &summary);
json!({
"generated_at_utc": snapshot.generated_at_utc,
"summary": summary,
"cards": {
"work": worktime_block(snapshot),
"security": dlp_block(snapshot),
"one_c": one_c_block(snapshot),
"collection": collection_block(snapshot.detmir_check.payload.as_ref()),
"grafana": grafana_block(snapshot)
},
"recommendations": recommendations,
"links": links(),
})
}
fn build_reports(
snapshot: &Snapshot,
incident_state: &IncidentStateFile,
evidence: &DlpEvidenceResponse,
workforce_policy_path: &Path,
) -> Value {
let summary = build_summary(snapshot);
let incidents = build_incidents(snapshot, incident_state);
let (users_count, active_seconds, apps_count) = worktime_totals(snapshot);
let dlp = dlp_counts(snapshot);
let metrics = ReportMetrics {
users_count,
active_seconds,
apps_count,
dlp_ok: dlp.0,
dlp_warn: dlp.1,
dlp_fail: dlp.2,
evidence_total: evidence.items.len(),
evidence_screenshots: evidence
.items
.iter()
.filter(|item| item.screenshot_available)
.count(),
open_incidents: incidents.iter().filter(|item| !item.acknowledged).count(),
acknowledged_incidents: incidents.iter().filter(|item| item.acknowledged).count(),
workforce_index: workforce_index(users_count, active_seconds),
};
let grafana = grafana_block(snapshot);
let collection = collection_block(snapshot.detmir_check.payload.as_ref());
let worktime = worktime_block(snapshot);
let one_c = one_c_block(snapshot);
let dlp_block_value = dlp_block(snapshot);
let department_items = workforce_rollup_items(snapshot, "department_rollups");
let owner_items = workforce_rollup_items(snapshot, "owner_rollups");
let trend = workforce_trend_json(snapshot);
let insight_items = workforce_insight_items(snapshot);
let workforce_policy = load_workforce_policy(workforce_policy_path).ok().flatten();
let weighted = workforce_policy
.as_ref()
.and_then(|policy| weighted_activity(snapshot, policy, metrics.users_count));
let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0
{
"Контур DetMir работает штатно, критичных действий не требуется"
} else if metrics.open_incidents > 0 {
"Контур DetMir работает, есть открытые вопросы для оператора"
} else {
"Контур DetMir требует технической проверки"
};
let executive_points = vec![
format!("Сбор данных: {}. {}", collection.status, collection.text),
format!(
"Работа сегодня: сотрудников={}, активное время={}",
metrics.users_count,
human_duration(metrics.active_seconds)
),
format!(
"DLP/ИБ: ok={}, warn={}, fail={}, evidence={}, screenshots={}",
metrics.dlp_ok,
metrics.dlp_warn,
metrics.dlp_fail,
metrics.evidence_total,
metrics.evidence_screenshots
),
format!(
"Открытые вопросы: {}, в работе: {}",
metrics.open_incidents, metrics.acknowledged_incidents
),
];
let recommendations = owner_recommendations(snapshot, &summary);
let workforce_summary = ReportWorkforceSummary {
departments_count: department_items.len(),
owners_count: owner_items.len(),
insights_count: insight_items.len(),
trend_status: trend_status(&trend),
};
let markdown = render_report_markdown(
snapshot,
headline,
&summary,
&metrics,
&recommendations,
&workforce_summary,
);
json!({
"generated_at_utc": snapshot.generated_at_utc,
"period": "оперативный срез за сегодня и текущий runtime",
"severity": summary.severity,
"operator_ok": summary.operator_ok,
"headline": headline,
"executive_points": executive_points,
"kpis": [
report_kpi("Индекс активности", workforce_index_text(metrics.workforce_index), workforce_index_status(metrics.workforce_index), "proxy: активное время / плановое рабочее время"),
weighted_activity_kpi(weighted.as_ref()),
report_kpi("Сотрудники", metrics.users_count.to_string(), worktime.status.clone(), "строки worktime за сегодня"),
report_kpi("Активное время", human_duration(metrics.active_seconds), worktime.status.clone(), "сумма active_seconds"),
report_kpi("Приложения", metrics.apps_count.to_string(), worktime.status.clone(), "true active applications"),
report_kpi("Подразделения", department_items.len().to_string(), snapshot.worktime_management.status.clone(), "сравнение групп за текущий день"),
report_kpi("DLP WARN/FAIL", format!("{}/{}", metrics.dlp_warn, metrics.dlp_fail), dlp_block_value.status.clone(), "технические сигналы DLP"),
report_kpi("Evidence", format!("{}/{}", metrics.evidence_screenshots, metrics.evidence_total), evidence_status(evidence), "скриншоты / все evidence items"),
report_kpi("Открытые вопросы", metrics.open_incidents.to_string(), incident_status(metrics.open_incidents), "не взятые в работу items")
],
"sections": [
{
"title": "Надежность контура",
"items": [
report_item("DetMir status", snapshot.detmir_status.status.clone(), snapshot.detmir_status.summary.clone()),
report_item("Сбор данных", collection.status.clone(), collection.text.clone()),
report_item("Grafana", grafana.status.clone(), grafana.text.clone()),
report_item("1C analytics", one_c.status.clone(), one_c.text.clone())
]
},
{
"title": "Работа и управляемость",
"items": [
report_item("Индекс активности", workforce_index_status(metrics.workforce_index), workforce_index_text(metrics.workforce_index)),
weighted_activity_item(weighted.as_ref(), workforce_policy_path),
report_item("Worktime", worktime.status.clone(), worktime.text.clone()),
report_item("Management report", snapshot.worktime_management.status.clone(), snapshot.worktime_management.summary.clone()),
report_item("Активное время", worktime.status.clone(), human_duration(metrics.active_seconds)),
report_item("Приложения", worktime.status.clone(), metrics.apps_count.to_string()),
report_item("Отчет", "OK", "готов к передаче руководителю")
]
},
{
"title": "Выводы Workforce",
"items": insight_items.clone()
},
{
"title": "Подразделения сегодня",
"items": department_items.clone()
},
{
"title": "Ответственные сегодня",
"items": owner_items.clone()
},
{
"title": "ИБ и evidence",
"items": [
report_item("DLP", dlp_block_value.status.clone(), dlp_block_value.text.clone()),
report_item("Evidence metadata", evidence_status(evidence), format!("items={}", metrics.evidence_total)),
report_item("Скриншоты", evidence_status(evidence), format!("available={}", metrics.evidence_screenshots)),
report_item("Формулировка", "OK", "derived detections/cases, не сертифицированная СЗИ")
]
},
{
"title": "Действия",
"items": recommendations.iter().map(|item| report_item("Рекомендация", "INFO", item)).collect::<Vec<_>>()
}
],
"workforce_policy": workforce_policy_json(workforce_policy.as_ref(), weighted.as_ref(), workforce_policy_path),
"workforce": {
"department_comparison": department_items,
"owner_comparison": owner_items,
"trend": trend,
"insights": insight_items,
"trend_status": trend_status(&trend),
"history_note": "Месячный тренд требует накопленной daily history; текущий слой показывает validated daily management snapshot."
},
"markdown": markdown,
"links": links()
})
}
fn workforce_rollup_items(snapshot: &Snapshot, key: &str) -> Vec<Value> {
snapshot
.worktime_management
.payload
.as_ref()
.and_then(|payload| payload.get(key))
.and_then(Value::as_array)
.map(|items| {
items
.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 coverage = item
.get("portfolio_coverage_pct")
.and_then(Value::as_f64)
.unwrap_or(0.0);
let users = item.get("users_count").and_then(Value::as_i64).unwrap_or(0);
let active = item
.get("active_users")
.and_then(Value::as_i64)
.unwrap_or(0);
let hhmm = item
.get("workday_total_active_hhmm")
.and_then(Value::as_str)
.unwrap_or("00:00");
report_item(
name,
coverage_status(coverage),
format!("{coverage:.0}% · active {active}/{users} · {hhmm}"),
)
})
.collect()
})
.unwrap_or_default()
}
fn workforce_trend_json(snapshot: &Snapshot) -> Value {
snapshot
.worktime_management
.payload
.as_ref()
.and_then(|payload| payload.get("trend"))
.cloned()
.unwrap_or_else(|| json!([]))
}
fn workforce_insight_items(snapshot: &Snapshot) -> Vec<Value> {
snapshot
.worktime_management
.payload
.as_ref()
.and_then(|payload| payload.get("trend_insights"))
.and_then(Value::as_array)
.map(|items| {
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 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("");
report_item(
&format!("{title}: {subject}"),
severity,
format!("{evidence} {recommendation}").trim().to_string(),
)
})
.collect()
})
.unwrap_or_else(|| {
vec![report_item(
"История еще накапливается",
"INFO",
"Worktime API пока не вернул trend_insights.",
)]
})
}
fn trend_status(trend: &Value) -> String {
let points = trend.as_array().map(Vec::len).unwrap_or(0);
if points >= 20 {
"monthly_ready".to_string()
} else if points >= 7 {
"weekly_ready".to_string()
} else {
"daily_only".to_string()
}
}
fn coverage_status(coverage: f64) -> &'static str {
if coverage >= 75.0 {
"OK"
} else if coverage >= 35.0 {
"WARN"
} else {
"FAIL"
}
}
fn load_workforce_policy(path: &Path) -> Result<Option<WorkforcePolicy>> {
if !path.exists() {
return Ok(None);
}
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let policy = serde_json::from_str::<WorkforcePolicy>(&data)
.with_context(|| format!("parse {}", path.display()))?;
Ok(Some(policy))
}
fn weighted_activity(
snapshot: &Snapshot,
policy: &WorkforcePolicy,
users_count: usize,
) -> Option<WeightedActivity> {
if users_count == 0 {
return None;
}
let role = policy.default_role.trim();
let role_policy = policy.roles.get(role)?;
let planned_hours = role_policy
.planned_hours_per_day
.unwrap_or(8.0)
.clamp(1.0, 24.0);
let planned_seconds = (users_count as f64 * planned_hours * 3600.0).round() as i64;
if planned_seconds <= 0 {
return None;
}
let default_weight = role_policy.default_weight.unwrap_or(0.0).clamp(0.0, 1.0);
let apps = snapshot
.worktime
.payload
.as_ref()
.and_then(|payload| payload.get("true_active_apps"))
.and_then(Value::as_array)?;
let mut app_seconds = 0_i64;
let mut weighted_seconds = 0_f64;
let mut matched_applications = 0_usize;
let mut app_details = Vec::new();
for app in apps {
let name = app.get("application").and_then(Value::as_str).unwrap_or("");
let seconds = app
.get("proved_work_seconds")
.and_then(Value::as_i64)
.unwrap_or(0)
.max(0);
if seconds == 0 {
continue;
}
app_seconds += seconds;
let (weight, matched_rule) = application_weight_match(role_policy, name, default_weight);
if weight > 0.0 {
matched_applications += 1;
}
weighted_seconds += seconds as f64 * weight;
app_details.push(AppWeightDetail {
application: name.to_string(),
seconds,
weight,
weighted_seconds: (seconds as f64 * weight).round() as i64,
matched_rule,
});
}
app_details.sort_by_key(|item| -item.weighted_seconds);
app_details.truncate(12);
let weighted_seconds_i64 = weighted_seconds.round() as i64;
let role_label = role_policy
.label
.clone()
.unwrap_or_else(|| role.to_string());
let explanation = role_policy.description.clone().unwrap_or_else(|| {
format!(
"Роль {}: индекс = взвешенное время приложений / плановое время роли.",
role_label
)
});
Some(WeightedActivity {
role: role.to_string(),
role_label,
index: Some(
((weighted_seconds / planned_seconds as f64) * 100.0)
.round()
.clamp(0.0, 100.0) as u8,
),
planned_seconds,
app_seconds,
weighted_seconds: weighted_seconds_i64,
matched_applications,
explanation,
app_details,
})
}
fn application_weight_match(
role_policy: &WorkforceRolePolicy,
application: &str,
default_weight: f64,
) -> (f64, String) {
let app = application.to_lowercase();
role_policy
.application_weights
.iter()
.find_map(|(pattern, weight)| {
let pattern = pattern.to_lowercase();
(!pattern.is_empty() && app.contains(&pattern))
.then_some((weight.clamp(0.0, 1.0), pattern))
})
.unwrap_or((default_weight, "default_weight".to_string()))
}
fn weighted_activity_kpi(weighted: Option<&WeightedActivity>) -> Value {
match weighted {
Some(weighted) => report_kpi(
"Взвешенная активность",
workforce_index_text(weighted.index),
workforce_index_status(weighted.index),
&format!("role={} по весам приложений", weighted.role_label),
),
None => report_kpi(
"Взвешенная активность",
"не настроена".to_string(),
"UNKNOWN".to_string(),
"нужен workforce policy с весами приложений",
),
}
}
fn weighted_activity_item(weighted: Option<&WeightedActivity>, policy_path: &Path) -> Value {
match weighted {
Some(weighted) => report_item(
"Взвешенная активность",
workforce_index_status(weighted.index),
format!(
"{}; роль {}; weighted {}; apps {}",
workforce_index_text(weighted.index),
weighted.role_label,
human_duration(weighted.weighted_seconds),
weighted.matched_applications
),
),
None => report_item(
"Взвешенная активность",
"UNKNOWN",
format!("policy не настроена: {}", policy_path.display()),
),
}
}
fn workforce_policy_json(
policy: Option<&WorkforcePolicy>,
weighted: Option<&WeightedActivity>,
policy_path: &Path,
) -> Value {
match (policy, weighted) {
(Some(policy), Some(weighted)) => json!({
"configured": true,
"path": policy_path.display().to_string(),
"default_role": policy.default_role,
"roles_count": policy.roles.len(),
"available_roles": workforce_role_catalog(policy),
"role": weighted.role,
"role_label": weighted.role_label,
"explanation": weighted.explanation,
"index": weighted.index,
"planned_seconds": weighted.planned_seconds,
"app_seconds": weighted.app_seconds,
"weighted_seconds": weighted.weighted_seconds,
"matched_applications": weighted.matched_applications,
"app_details": weighted.app_details.iter().map(|item| json!({
"application": item.application,
"seconds": item.seconds,
"weight": item.weight,
"weighted_seconds": item.weighted_seconds,
"matched_rule": item.matched_rule,
})).collect::<Vec<_>>(),
}),
(Some(policy), None) => json!({
"configured": true,
"path": policy_path.display().to_string(),
"default_role": policy.default_role,
"roles_count": policy.roles.len(),
"available_roles": workforce_role_catalog(policy),
"note": "role/application policy exists, but weighted activity could not be calculated",
}),
(None, _) => json!({
"configured": false,
"path": policy_path.display().to_string(),
"note": "weighted activity requires role/application policy",
}),
}
}
fn workforce_role_catalog(policy: &WorkforcePolicy) -> Vec<Value> {
policy
.roles
.iter()
.map(|(role, item)| {
json!({
"role": role,
"label": item.label.clone().unwrap_or_else(|| role.to_string()),
"description": item.description.clone().unwrap_or_default(),
"planned_hours_per_day": item.planned_hours_per_day.unwrap_or(8.0),
"default_weight": item.default_weight.unwrap_or(0.0),
"application_rules": item.application_weights.len(),
})
})
.collect()
}
fn default_workforce_role() -> String {
"default".to_string()
}
fn report_kpi(label: &str, value: String, status: String, context: &str) -> Value {
json!({
"label": label,
"value": value,
"status": status,
"context": context,
})
}
fn report_item(label: &str, status: impl Into<String>, value: impl Into<String>) -> Value {
json!({
"label": label,
"status": status.into(),
"value": value.into(),
})
}
fn render_report_markdown(
snapshot: &Snapshot,
headline: &str,
summary: &SummaryResponse,
metrics: &ReportMetrics,
recommendations: &[String],
workforce: &ReportWorkforceSummary,
) -> String {
let mut text = String::new();
text.push_str("# DetMir оперативный отчет\n\n");
text.push_str(&format!("Дата снимка: {}\n\n", snapshot.generated_at_utc));
text.push_str(&format!("Итог: {headline}\n\n"));
text.push_str("## KPI\n\n");
text.push_str(&format!("- Общий статус: {}\n", summary.severity));
text.push_str(&format!(
"- Готовность для оператора: {}\n",
if summary.operator_ok {
"да"
} else {
"нет"
}
));
text.push_str(&format!(
"- Индекс активности: {}\n",
workforce_index_text(metrics.workforce_index)
));
text.push_str(&format!(
"- Сотрудники за сегодня: {}\n",
metrics.users_count
));
text.push_str(&format!(
"- Активное время: {}\n",
human_duration(metrics.active_seconds)
));
text.push_str(&format!("- Активные приложения: {}\n", metrics.apps_count));
text.push_str(&format!(
"- Подразделения/ответственные: {}/{}\n",
workforce.departments_count, workforce.owners_count
));
text.push_str(&format!(
"- Автоматические выводы Workforce: {}\n",
workforce.insights_count
));
text.push_str(&format!("- Статус тренда: {}\n", workforce.trend_status));
text.push_str(&format!(
"- DLP технические сигналы: ok={}, warn={}, fail={}\n",
metrics.dlp_ok, metrics.dlp_warn, metrics.dlp_fail
));
text.push_str(&format!(
"- Evidence: items={}, screenshots={}\n",
metrics.evidence_total, metrics.evidence_screenshots
));
text.push_str(&format!(
"- Открытые вопросы: {}, в работе: {}\n\n",
metrics.open_incidents, metrics.acknowledged_incidents
));
text.push_str("## Рекомендации\n\n");
for item in recommendations {
text.push_str(&format!("- {item}\n"));
}
text.push_str("\nПримечание: DLP/case показатели являются derived detections/cases и требуют регламентной валидации перед подачей как подтвержденные инциденты.\n");
text
}
fn worktime_totals(snapshot: &Snapshot) -> (usize, i64, usize) {
let Some(payload) = snapshot.worktime.payload.as_ref() else {
return (0, 0, 0);
};
let rows = payload
.get("rows")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let apps = payload
.get("true_active_apps")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
let active_seconds = rows
.iter()
.filter_map(|row| row.get("active_seconds").and_then(Value::as_i64))
.sum();
(rows.len(), active_seconds, apps)
}
fn dlp_counts(snapshot: &Snapshot) -> (u64, u64, u64) {
let counts = snapshot
.detmir_status
.payload
.as_ref()
.and_then(|status| status.get("dlp_counts"))
.unwrap_or(&Value::Null);
(
counts.get("ok").and_then(Value::as_u64).unwrap_or(0),
counts.get("warn").and_then(Value::as_u64).unwrap_or(0),
counts.get("fail").and_then(Value::as_u64).unwrap_or(0),
)
}
fn evidence_status(evidence: &DlpEvidenceResponse) -> String {
if evidence.ok {
"OK".to_string()
} else {
"WARN".to_string()
}
}
fn incident_status(open_incidents: usize) -> String {
if open_incidents == 0 {
"OK".to_string()
} else {
"WARN".to_string()
}
}
fn human_duration(seconds: i64) -> String {
let seconds = seconds.max(0);
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
format!("{hours:02}:{minutes:02}")
}
fn workforce_index(users_count: usize, active_seconds: i64) -> Option<u8> {
if users_count == 0 || active_seconds <= 0 {
return None;
}
let planned_seconds = users_count as f64 * 8.0 * 3600.0;
let value = ((active_seconds as f64 / planned_seconds) * 100.0).round();
Some(value.clamp(0.0, 100.0) as u8)
}
fn workforce_index_text(value: Option<u8>) -> String {
value
.map(|value| format!("{value}%"))
.unwrap_or_else(|| "нет данных".to_string())
}
fn workforce_index_status(value: Option<u8>) -> String {
match value {
Some(value) if value >= 80 => "OK".to_string(),
Some(value) if value >= 60 => "WARN".to_string(),
Some(_) => "FAIL".to_string(),
None => "UNKNOWN".to_string(),
}
}
fn build_incidents(snapshot: &Snapshot, state: &IncidentStateFile) -> Vec<IncidentItem> {
let mut incidents = Vec::new();
for source in [
("detmir_status", &snapshot.detmir_status),
("detmir_check", &snapshot.detmir_check),
("failed_units", &snapshot.failed_units),
("worktime", &snapshot.worktime),
("one_c", &snapshot.one_c),
] {
if !source.1.ok {
incidents.push(incident_item(
&source.1.status,
"health",
source.0,
&source.1.summary,
&snapshot.generated_at_utc,
"/portal/operator",
state,
));
}
}
if let Some(check) = snapshot.detmir_check.payload.as_ref() {
if let Some(services) = check.get("services").and_then(Value::as_array) {
for service in services {
if service.get("ok").and_then(Value::as_bool) == Some(false) {
let status = if service.get("required").and_then(Value::as_bool) == Some(true) {
"FAIL"
} else {
"WARN"
};
let source = service
.get("name")
.and_then(Value::as_str)
.unwrap_or("service");
let summary = service
.get("error")
.and_then(Value::as_str)
.unwrap_or("service check failed");
incidents.push(incident_item(
status,
"service",
source,
summary,
&snapshot.generated_at_utc,
"/portal/operator",
state,
));
}
}
}
if let Some(buckets) = check.get("buckets").and_then(Value::as_array) {
for bucket in buckets {
if bucket.get("ok").and_then(Value::as_bool) == Some(false) {
let status = bucket
.get("status")
.and_then(Value::as_str)
.unwrap_or("WARN");
let source = bucket
.get("bucket")
.and_then(Value::as_str)
.unwrap_or("bucket");
incidents.push(incident_item(
status,
"collector",
source,
&format!(
"{} is {}",
bucket
.get("label")
.and_then(Value::as_str)
.unwrap_or("bucket"),
bucket
.get("status")
.and_then(Value::as_str)
.unwrap_or("not OK")
),
&snapshot.generated_at_utc,
"/portal/operator",
state,
));
}
}
}
}
if let Some(status) = snapshot.detmir_status.payload.as_ref() {
let counts = status.get("dlp_counts").unwrap_or(&Value::Null);
let warn = counts.get("warn").and_then(Value::as_u64).unwrap_or(0);
let fail = counts.get("fail").and_then(Value::as_u64).unwrap_or(0);
if warn > 0 || fail > 0 {
incidents.push(incident_item(
if fail > 0 { "FAIL" } else { "WARN" },
"dlp",
"dlp_counts",
&format!("DLP requires review: warn={warn}, fail={fail}"),
&snapshot.generated_at_utc,
"/portal/incidents",
state,
));
}
}
incidents
}
fn incident_item(
status: &str,
kind: &str,
source: &str,
summary: &str,
generated_at_utc: &str,
link: &str,
state: &IncidentStateFile,
) -> IncidentItem {
let id = incident_id(kind, source, summary);
let saved = state.incidents.get(&id);
IncidentItem {
id,
status: status.to_string(),
kind: kind.to_string(),
source: source.to_string(),
summary: summary.to_string(),
generated_at_utc: generated_at_utc.to_string(),
link: link.to_string(),
acknowledged: saved
.map(|item| item.state == "acknowledged")
.unwrap_or(false),
acknowledged_at_utc: saved.and_then(|item| item.acknowledged_at_utc.clone()),
actor: saved.map(|item| item.actor.clone()),
assigned_to: saved.and_then(|item| item.assigned_to.clone()),
comment: saved.and_then(|item| item.comment.clone()),
updated_at_utc: saved.map(|item| item.updated_at_utc.clone()),
}
}
fn incident_id(kind: &str, source: &str, summary: &str) -> String {
let mut hash = 0xcbf29ce484222325u64;
for byte in format!("{kind}\n{source}\n{summary}").as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{kind}-{hash:016x}")
}
fn handle_incident_action(mut request: Request, args: &Cli) -> Result<()> {
let actor = request_actor(&request);
let mut body = String::new();
request
.as_reader()
.take(32 * 1024)
.read_to_string(&mut body)?;
let response = apply_incident_action(args, &actor, &body);
match response {
Ok(response) => respond_json(request, &response),
Err(err) => respond_text(
request,
StatusCode(400),
&serde_json::to_string_pretty(&json!({
"ok": false,
"error": err.to_string()
}))?,
"application/json; charset=utf-8",
),
}
}
fn apply_incident_action(args: &Cli, actor: &str, body: &str) -> Result<IncidentActionResponse> {
let action: IncidentActionRequest =
serde_json::from_str(body).map_err(|err| anyhow!("invalid incident action JSON: {err}"))?;
let id = validate_short_token(&action.id, "id", 128)?;
let action_name = validate_action(&action.action)?;
let assigned_to = sanitize_optional_text(action.assigned_to, 80);
let comment = sanitize_optional_text(action.comment, 500);
let now = now();
let mut state_file = load_incident_state(args)?;
let previous = state_file.incidents.get(&id).cloned();
let acknowledged_at_utc = if action_name == "ack" {
previous
.as_ref()
.and_then(|item| item.acknowledged_at_utc.clone())
.or_else(|| Some(now.clone()))
} else if action_name == "clear" {
None
} else {
previous
.as_ref()
.and_then(|item| item.acknowledged_at_utc.clone())
};
let state_name = if action_name == "clear" {
"open"
} else if acknowledged_at_utc.is_some() {
"acknowledged"
} else {
"assigned"
};
let next = IncidentActionState {
state: state_name.to_string(),
actor: actor.to_string(),
updated_at_utc: now.clone(),
acknowledged_at_utc,
assigned_to: assigned_to
.clone()
.or_else(|| previous.and_then(|item| item.assigned_to)),
comment: comment.clone(),
};
state_file.incidents.insert(id.clone(), next.clone());
save_incident_state(args, &state_file)?;
append_incident_audit(
args,
&IncidentAuditEntry {
generated_at_utc: now,
actor: actor.to_string(),
id: id.clone(),
action: action_name.to_string(),
assigned_to,
comment,
},
)?;
Ok(IncidentActionResponse {
ok: true,
id,
state: next,
})
}
fn load_incident_state_best_effort(args: &Cli) -> IncidentStateFile {
match load_incident_state(args) {
Ok(state) => state,
Err(err) => {
eprintln!("detmir-portal incident state read failed: {err:#}");
IncidentStateFile::default()
}
}
}
fn load_incident_state(args: &Cli) -> Result<IncidentStateFile> {
let path = incident_state_path(args);
if !path.exists() {
return Ok(IncidentStateFile::default());
}
let data = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&data).with_context(|| format!("parse {}", path.display()))
}
fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> {
fs::create_dir_all(&args.state_dir)
.with_context(|| format!("create {}", args.state_dir.display()))?;
let path = incident_state_path(args);
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, serde_json::to_vec_pretty(state)?)
.with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, &path).with_context(|| format!("rename {}", path.display()))?;
Ok(())
}
fn append_incident_audit(args: &Cli, entry: &IncidentAuditEntry) -> Result<()> {
fs::create_dir_all(&args.state_dir)
.with_context(|| format!("create {}", args.state_dir.display()))?;
let path = args.state_dir.join("audit.jsonl");
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")
}
fn build_dlp_evidence_response(args: &Cli) -> DlpEvidenceResponse {
let generated_at_utc = now();
let db_available = args.dlp_db_path.exists();
let screenshot_root_available = args.evidence_root.exists();
if !db_available {
return DlpEvidenceResponse {
ok: true,
generated_at_utc,
db_available,
screenshot_root_available,
limit: args.evidence_limit,
items: Vec::new(),
error: Some(format!(
"DLP warehouse is absent: {}",
args.dlp_db_path.display()
)),
};
}
match load_dlp_evidence_items(args) {
Ok(items) => DlpEvidenceResponse {
ok: true,
generated_at_utc,
db_available,
screenshot_root_available,
limit: args.evidence_limit,
items,
error: None,
},
Err(err) => DlpEvidenceResponse {
ok: false,
generated_at_utc,
db_available,
screenshot_root_available,
limit: args.evidence_limit,
items: Vec::new(),
error: Some(err.to_string()),
},
}
}
fn load_dlp_evidence_items(args: &Cli) -> Result<Vec<DlpEvidenceItem>> {
let connection = Connection::open_with_flags(
&args.dlp_db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.with_context(|| format!("open DLP warehouse {}", args.dlp_db_path.display()))?;
let mut statement = connection.prepare(
r#"
select
id,
bucket_id,
event_id,
stream_type,
hostname,
username,
event_ts,
operation,
file_path,
rule_id,
action,
severity,
signal_type,
message,
source,
screenshot_path,
raw_json
from dlp_events
where stream_type = 'dlp_incident'
or screenshot_path is not null
order by event_ts desc, id desc
limit ?
"#,
)?;
let rows = statement
.query_map(params![i64::from(args.evidence_limit)], |row| {
Ok(DlpEvidenceRow {
row_id: row.get(0)?,
bucket_id: row.get(1)?,
event_id: row.get(2)?,
stream_type: row.get(3)?,
hostname: row.get(4)?,
username: row.get(5)?,
event_ts: row.get(6)?,
operation: row.get(7)?,
file_path: row.get(8)?,
rule_id: row.get(9)?,
action: row.get(10)?,
severity: row.get(11)?,
signal_type: row.get(12)?,
message: row.get(13)?,
source: row.get(14)?,
screenshot_path: row.get(15)?,
raw_json: row.get(16)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
rows.into_iter()
.map(|row| evidence_item_from_row(args, row))
.collect()
}
fn evidence_item_from_row(args: &Cli, row: DlpEvidenceRow) -> Result<DlpEvidenceItem> {
let raw = serde_json::from_str::<Value>(&row.raw_json).unwrap_or(Value::Null);
let sha256 = json_string(&raw, &["screenshotSha256", "sha256", "captureSha256"])
.map(|value| value.to_ascii_lowercase())
.filter(|value| is_sha256_hex(value));
let source_file = row
.screenshot_path
.as_deref()
.and_then(screenshot_basename)
.or_else(|| json_string(&raw, &["screenshotFile", "artifactFile"]));
let id = evidence_id(row.row_id, &row.event_id, row.screenshot_path.as_deref());
let screenshot = resolve_screenshot_file(args, &source_file, &sha256)?;
let has_screenshot_metadata = row.screenshot_path.is_some() || sha256.is_some();
let blocked_reason = if screenshot.is_some() {
None
} else if has_screenshot_metadata && sha256.is_none() {
Some("screenshot sha256 is absent; original is not served".to_string())
} else if has_screenshot_metadata {
Some("screenshot is not synced into the server evidence root yet".to_string())
} else {
None
};
Ok(DlpEvidenceItem {
id: id.clone(),
event_ts: row.event_ts,
bucket_id: row.bucket_id,
event_id: row.event_id,
stream_type: row.stream_type,
hostname: row.hostname,
username: row.username,
severity: row.severity,
signal_type: row.signal_type,
rule_id: row.rule_id,
action: row.action.or(row.operation),
source: row.source,
message: row.message,
file_path: row.file_path,
has_screenshot_metadata,
screenshot_available: screenshot.is_some(),
source_file,
screenshot_sha256: sha256,
screenshot_width: json_i64(&raw, &["screenshotWidth", "captureWidth"]),
screenshot_height: json_i64(&raw, &["screenshotHeight", "captureHeight"]),
preview_url: screenshot
.as_ref()
.map(|_| format!("/portal/api/dlp/evidence/{id}/screenshot")),
download_url: screenshot
.as_ref()
.map(|_| format!("/portal/api/dlp/evidence/{id}/download")),
blocked_reason,
})
}
fn parse_evidence_screenshot_path(path: &str) -> Option<(String, bool)> {
let rest = path.strip_prefix("/api/dlp/evidence/")?;
let (evidence_id, suffix) = rest.rsplit_once('/')?;
match suffix {
"screenshot" => Some((evidence_id.to_string(), false)),
"download" => Some((evidence_id.to_string(), true)),
_ => None,
}
}
fn handle_evidence_upload(mut request: Request, args: &Cli) -> Result<()> {
if !upload_authorized(&request, args) {
return respond_text(
request,
StatusCode(403),
"Forbidden",
"text/plain; charset=utf-8",
);
}
let body_limit = args
.evidence_max_bytes
.saturating_mul(2)
.saturating_add(64 * 1024)
.min(32 * 1024 * 1024);
let mut body = String::new();
request
.as_reader()
.take(body_limit)
.read_to_string(&mut body)?;
match apply_evidence_upload(args, &request_actor(&request), &body) {
Ok(response) => respond_json(request, &response),
Err(err) => respond_text(
request,
StatusCode(400),
&serde_json::to_string_pretty(&json!({
"ok": false,
"error": err.to_string()
}))?,
"application/json; charset=utf-8",
),
}
}
fn apply_evidence_upload(args: &Cli, actor: &str, body: &str) -> Result<EvidenceUploadResponse> {
let upload: EvidenceUploadRequest =
serde_json::from_str(body).map_err(|err| anyhow!("invalid evidence upload JSON: {err}"))?;
let expected_sha256 = upload.sha256.trim().to_ascii_lowercase();
if !is_sha256_hex(&expected_sha256) {
return Err(anyhow!("sha256 is invalid"));
}
if upload.content_base64.is_empty() {
return Err(anyhow!("content_base64 is empty"));
}
let bytes = BASE64_STANDARD
.decode(upload.content_base64.as_bytes())
.map_err(|err| anyhow!("content_base64 decode failed: {err}"))?;
if bytes.is_empty() {
return Err(anyhow!("content is empty"));
}
if bytes.len() as u64 > args.evidence_max_bytes {
return Err(anyhow!("content is too large"));
}
let actual_sha256 = sha256_bytes(&bytes);
if actual_sha256 != expected_sha256 {
return Err(anyhow!("sha256 mismatch"));
}
let (content_type, extension) = evidence_image_type(&bytes, upload.content_type.as_deref())?;
let root = ensure_evidence_root(args)?;
let screenshots = root.join("screenshots");
fs::create_dir_all(&screenshots)
.with_context(|| format!("create {}", screenshots.display()))?;
let path = screenshots.join(format!("{expected_sha256}.{extension}"));
let mut stored = false;
if path.exists() {
let existing = sha256_file(&path)?;
if existing != expected_sha256 {
return Err(anyhow!("existing evidence file hash mismatch"));
}
} else {
let tmp = screenshots.join(format!(
"{expected_sha256}.{extension}.tmp-{}",
std::process::id()
));
fs::write(&tmp, &bytes).with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, &path).with_context(|| format!("rename {}", path.display()))?;
stored = true;
}
append_evidence_audit(
args,
&EvidenceAuditEntry {
generated_at_utc: now(),
actor: actor.to_string(),
action: "upload".to_string(),
evidence_id: format!("sha256:{expected_sha256}"),
sha256: Some(expected_sha256.clone()),
source_file: upload
.source_file
.as_deref()
.and_then(screenshot_basename)
.or_else(|| upload.source_path.as_deref().and_then(screenshot_basename)),
},
)?;
let meta = json!({
"generated_at_utc": now(),
"hostname": upload.hostname.as_deref().map(|value| sanitize_text(value, 80)),
"username": upload.username.as_deref().map(|value| sanitize_text(value, 80)),
"source_file": upload.source_file.as_deref().and_then(screenshot_basename),
"source_path_basename": upload.source_path.as_deref().and_then(screenshot_basename),
"sha256": expected_sha256,
"content_type": content_type,
"bytes": bytes.len(),
"path": path.file_name().and_then(|name| name.to_str()).unwrap_or(""),
});
let _ = fs::write(
screenshots.join(format!("{}.json", meta["sha256"].as_str().unwrap_or(""))),
serde_json::to_vec_pretty(&meta)?,
);
Ok(EvidenceUploadResponse {
ok: true,
sha256: meta["sha256"].as_str().unwrap_or("").to_string(),
content_type: content_type.to_string(),
bytes: bytes.len() as u64,
stored,
path: path.display().to_string(),
})
}
fn handle_evidence_screenshot(
request: Request,
args: &Cli,
evidence_id: &str,
download: bool,
) -> Result<()> {
let actor = request_actor(&request);
let id = match validate_short_token(evidence_id, "evidence_id", 128) {
Ok(id) => id,
Err(err) => {
return respond_text(
request,
StatusCode(400),
&format!("Bad evidence id: {err}"),
"text/plain; charset=utf-8",
);
}
};
let screenshot = match load_screenshot_for_evidence(args, &id) {
Ok(Some(screenshot)) => screenshot,
Ok(None) => {
return respond_text(
request,
StatusCode(404),
"Evidence screenshot is not available",
"text/plain; charset=utf-8",
);
}
Err(err) => {
return respond_text(
request,
StatusCode(400),
&format!("Evidence screenshot rejected: {err}"),
"text/plain; charset=utf-8",
);
}
};
append_evidence_audit(
args,
&EvidenceAuditEntry {
generated_at_utc: now(),
actor,
action: if download { "download" } else { "view" }.to_string(),
evidence_id: id,
sha256: screenshot.sha256.clone(),
source_file: screenshot.source_file.clone(),
},
)?;
respond_file(
request,
&screenshot.path,
screenshot.content_type,
download.then_some(
screenshot
.source_file
.as_deref()
.unwrap_or("dlp-evidence.png"),
),
)
}
fn load_screenshot_for_evidence(
args: &Cli,
evidence_id_value: &str,
) -> Result<Option<ScreenshotFile>> {
let row_id = evidence_row_id(evidence_id_value)?;
if !args.dlp_db_path.exists() {
return Ok(None);
}
let connection = Connection::open_with_flags(
&args.dlp_db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.with_context(|| format!("open DLP warehouse {}", args.dlp_db_path.display()))?;
let row = connection
.query_row(
r#"
select
id,
bucket_id,
event_id,
stream_type,
hostname,
username,
event_ts,
operation,
file_path,
rule_id,
action,
severity,
signal_type,
message,
source,
screenshot_path,
raw_json
from dlp_events
where id = ?
"#,
params![row_id],
|row| {
Ok(DlpEvidenceRow {
row_id: row.get(0)?,
bucket_id: row.get(1)?,
event_id: row.get(2)?,
stream_type: row.get(3)?,
hostname: row.get(4)?,
username: row.get(5)?,
event_ts: row.get(6)?,
operation: row.get(7)?,
file_path: row.get(8)?,
rule_id: row.get(9)?,
action: row.get(10)?,
severity: row.get(11)?,
signal_type: row.get(12)?,
message: row.get(13)?,
source: row.get(14)?,
screenshot_path: row.get(15)?,
raw_json: row.get(16)?,
})
},
)
.optional()?;
let Some(row) = row else {
return Ok(None);
};
let expected_id = evidence_id(row.row_id, &row.event_id, row.screenshot_path.as_deref());
if expected_id != evidence_id_value {
return Err(anyhow!("evidence id checksum mismatch"));
}
let raw = serde_json::from_str::<Value>(&row.raw_json).unwrap_or(Value::Null);
let sha256 = json_string(&raw, &["screenshotSha256", "sha256", "captureSha256"])
.map(|value| value.to_ascii_lowercase())
.filter(|value| is_sha256_hex(value));
let source_file = row
.screenshot_path
.as_deref()
.and_then(screenshot_basename)
.or_else(|| json_string(&raw, &["screenshotFile", "artifactFile"]));
resolve_screenshot_file(args, &source_file, &sha256)
}
fn resolve_screenshot_file(
args: &Cli,
source_file: &Option<String>,
sha256: &Option<String>,
) -> Result<Option<ScreenshotFile>> {
let Some(expected_sha256) = sha256.as_deref() else {
return Ok(None);
};
if !args.evidence_root.exists() {
return Ok(None);
}
let root = args
.evidence_root
.canonicalize()
.with_context(|| format!("canonicalize {}", args.evidence_root.display()))?;
let mut candidates = Vec::new();
candidates.push(
root.join("screenshots")
.join(format!("{expected_sha256}.png")),
);
candidates.push(
root.join("screenshots")
.join(format!("{expected_sha256}.jpg")),
);
candidates.push(
root.join("screenshots")
.join(format!("{expected_sha256}.jpeg")),
);
candidates.push(root.join(format!("{expected_sha256}.png")));
candidates.push(root.join(format!("{expected_sha256}.jpg")));
candidates.push(root.join(format!("{expected_sha256}.jpeg")));
candidates.push(root.join(expected_sha256));
if let Some(file_name) = source_file
.as_deref()
.and_then(screenshot_basename)
.filter(|name| is_safe_file_name(name))
{
candidates.push(root.join("screenshots").join(&file_name));
candidates.push(root.join(&file_name));
}
for candidate in candidates {
if !candidate.exists() {
continue;
}
let canonical = candidate
.canonicalize()
.with_context(|| format!("canonicalize {}", candidate.display()))?;
if !canonical.starts_with(&root) {
continue;
}
let metadata = fs::metadata(&canonical)?;
if !metadata.is_file() || metadata.len() > args.evidence_max_bytes {
continue;
}
let Some(content_type) = screenshot_content_type(&canonical) else {
continue;
};
let actual_sha256 = sha256_file(&canonical)?;
if actual_sha256 != expected_sha256 {
continue;
}
return Ok(Some(ScreenshotFile {
path: canonical,
content_type,
source_file: source_file.clone(),
sha256: Some(expected_sha256.to_string()),
}));
}
Ok(None)
}
fn append_evidence_audit(args: &Cli, entry: &EvidenceAuditEntry) -> Result<()> {
fs::create_dir_all(&args.state_dir)
.with_context(|| format!("create {}", args.state_dir.display()))?;
let path = args.state_dir.join("evidence-audit.jsonl");
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 evidence_id(row_id: i64, event_id: &str, screenshot_path: Option<&str>) -> String {
let mut hash = 0xcbf29ce484222325u64;
for byte in format!("{row_id}\n{event_id}\n{}", screenshot_path.unwrap_or("")).as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("ev-{row_id}-{hash:016x}")
}
fn evidence_row_id(evidence_id_value: &str) -> Result<i64> {
let rest = evidence_id_value
.strip_prefix("ev-")
.ok_or_else(|| anyhow!("unsupported evidence id prefix"))?;
let (row_id, checksum) = rest
.split_once('-')
.ok_or_else(|| anyhow!("malformed evidence id"))?;
if checksum.len() != 16 || !checksum.chars().all(|ch| ch.is_ascii_hexdigit()) {
return Err(anyhow!("malformed evidence id checksum"));
}
row_id
.parse::<i64>()
.map_err(|err| anyhow!("malformed evidence row id: {err}"))
}
fn json_string(value: &Value, names: &[&str]) -> Option<String> {
for name in names {
if let Some(text) = value.get(*name).and_then(Value::as_str) {
let text = sanitize_text(text, 512);
if !text.is_empty() {
return Some(text);
}
}
}
None
}
fn json_i64(value: &Value, names: &[&str]) -> Option<i64> {
names
.iter()
.find_map(|name| value.get(*name).and_then(Value::as_i64))
}
fn screenshot_basename(path: &str) -> Option<String> {
if path.split(['/', '\\']).any(|part| part == "..") {
return None;
}
let name = path
.rsplit(['/', '\\'])
.next()
.map(|value| sanitize_text(value, 255))
.filter(|value| !value.is_empty())?;
is_safe_file_name(&name).then_some(name)
}
fn is_safe_file_name(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 255
&& !value.contains("..")
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | '@'))
}
fn is_sha256_hex(value: &str) -> bool {
value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn screenshot_content_type(path: &Path) -> Option<&'static str> {
match path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
_ => None,
}
}
fn sha256_file(path: &Path) -> Result<String> {
let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let n = file.read(&mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn sha256_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn evidence_image_type(
bytes: &[u8],
claimed_content_type: Option<&str>,
) -> Result<(&'static str, &'static str)> {
let detected = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some(("image/png", "png"))
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
Some(("image/jpeg", "jpg"))
} else {
None
};
let Some((content_type, extension)) = detected else {
return Err(anyhow!("unsupported evidence image type"));
};
if let Some(claimed) = claimed_content_type.map(|value| value.trim().to_ascii_lowercase()) {
let allowed = match content_type {
"image/png" => claimed == "image/png" || claimed == "application/octet-stream",
"image/jpeg" => {
claimed == "image/jpeg"
|| claimed == "image/jpg"
|| claimed == "application/octet-stream"
}
_ => false,
};
if !allowed {
return Err(anyhow!("claimed content_type does not match image bytes"));
}
}
Ok((content_type, extension))
}
fn ensure_evidence_root(args: &Cli) -> Result<PathBuf> {
fs::create_dir_all(&args.evidence_root)
.with_context(|| format!("create {}", args.evidence_root.display()))?;
args.evidence_root
.canonicalize()
.with_context(|| format!("canonicalize {}", args.evidence_root.display()))
}
fn upload_enabled(args: &Cli) -> bool {
args.evidence_upload_token
.as_deref()
.map(|token| !token.trim().is_empty())
.unwrap_or(false)
}
fn upload_authorized(request: &Request, args: &Cli) -> bool {
let Some(expected) = args
.evidence_upload_token
.as_deref()
.map(str::trim)
.filter(|token| !token.is_empty())
else {
return false;
};
let Some(actual) = bearer_token(request) else {
return false;
};
constant_time_eq(actual.as_bytes(), expected.as_bytes())
}
fn bearer_token(request: &Request) -> Option<String> {
request
.headers()
.iter()
.find(|header| header.field.equiv("Authorization"))
.map(|header| header.value.as_str().trim())
.and_then(|value| value.strip_prefix("Bearer "))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
let mut diff = 0_u8;
for (a, b) in left.iter().zip(right.iter()) {
diff |= a ^ b;
}
diff == 0
}
fn request_actor(request: &Request) -> String {
for name in ["X-Remote-User", "X-Gateway-User", "Remote-User"] {
if let Some(value) = request
.headers()
.iter()
.find(|header| header.field.equiv(name))
.map(|header| header.value.as_str().trim())
{
if !value.is_empty() {
return sanitize_text(value, 80);
}
}
}
"local".to_string()
}
fn validate_short_token(value: &str, name: &str, max_len: usize) -> Result<String> {
let value = value.trim();
if value.is_empty() || value.len() > max_len {
return Err(anyhow!("{name} length is invalid"));
}
if !value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':' | '.'))
{
return Err(anyhow!("{name} contains unsupported characters"));
}
Ok(value.to_string())
}
fn validate_action(value: &str) -> Result<&'static str> {
match value.trim() {
"ack" | "acknowledge" => Ok("ack"),
"assign" => Ok("assign"),
"clear" | "reopen" => Ok("clear"),
_ => Err(anyhow!("unsupported incident action")),
}
}
fn sanitize_optional_text(value: Option<String>, max_len: usize) -> Option<String> {
value
.map(|text| sanitize_text(&text, max_len))
.filter(|text| !text.is_empty())
}
fn sanitize_text(value: &str, max_len: usize) -> String {
value
.chars()
.filter(|ch| !ch.is_control())
.take(max_len)
.collect::<String>()
.trim()
.to_string()
}
fn links() -> PortalLinks {
PortalLinks {
portal: "/portal/".to_string(),
grafana_dashboards: "/dashboards".to_string(),
detmir_activitywatch:
"/d/detmir-aw-main/detmir-activitywatch?orgId=1&from=now-48h&to=now&timezone=browser&var-host=HOST-EXAMPLE&refresh=5m"
.to_string(),
dlp_security_dashboard:
"/d/detmir-dlp-security?orgId=1&from=now-30d&to=now&timezone=browser".to_string(),
dlp_management_dashboard:
"/d/detmir-dlp-management?orgId=1&from=now-30d&to=now&timezone=browser".to_string(),
dlp_overview_dashboard:
"/d/awatch-dlp-overview?orgId=1&from=now-30d&to=now&timezone=browser".to_string(),
aw_ui: "/r/aw/".to_string(),
worktime_report: "/reports/worktime/management?format=html&host=HOST-EXAMPLE".to_string(),
file1c_brief: "/r/file1c/brief".to_string(),
file1c_actions: "/r/file1c/actions".to_string(),
}
}
fn collection_block(check: Option<&Value>) -> SummaryBlock {
let Some(check) = check else {
return block("UNKNOWN", "Нет данных detmir-check");
};
let summary = check.get("summary").unwrap_or(&Value::Null);
let stale = summary
.get("bucket_stale")
.and_then(Value::as_u64)
.unwrap_or(0);
let dead = summary
.get("bucket_dead")
.and_then(Value::as_u64)
.unwrap_or(0);
let failures = summary
.get("service_failures")
.and_then(Value::as_u64)
.unwrap_or(0);
if stale == 0 && dead == 0 && failures == 0 {
block("OK", "Сбор данных свежий")
} else {
block(
"FAIL",
&format!("Проблемы сбора: stale={stale}, dead={dead}, service_fail={failures}"),
)
}
}
fn grafana_block(snapshot: &Snapshot) -> SummaryBlock {
let Some(service) = grafana_service(snapshot) else {
return block("WARN", "Grafana check не найден");
};
let ok = service.get("ok").and_then(Value::as_bool).unwrap_or(false);
let age = service
.pointer("/payload/age_seconds")
.and_then(Value::as_i64)
.unwrap_or(-1);
let fail_count = service
.pointer("/payload/fail_count")
.and_then(Value::as_i64)
.unwrap_or(-1);
if ok {
block(
"OK",
&format!("Grafana данные актуальны, возраст {} сек", age),
)
} else {
block(
"FAIL",
&format!("Grafana check fail_count={fail_count}, age={age}"),
)
}
}
fn dlp_block(snapshot: &Snapshot) -> SummaryBlock {
let Some(status) = snapshot.detmir_status.payload.as_ref() else {
return block("UNKNOWN", "Нет DLP данных");
};
let counts = status.get("dlp_counts").unwrap_or(&Value::Null);
let ok = counts.get("ok").and_then(Value::as_u64).unwrap_or(0);
let warn = counts.get("warn").and_then(Value::as_u64).unwrap_or(0);
let fail = counts.get("fail").and_then(Value::as_u64).unwrap_or(0);
if warn == 0 && fail == 0 {
block("OK", &format!("DLP проверки OK: {ok}"))
} else {
block("WARN", &format!("DLP: ok={ok}, warn={warn}, fail={fail}"))
}
}
fn worktime_block(snapshot: &Snapshot) -> SummaryBlock {
if !snapshot.worktime.ok {
return block("FAIL", "Worktime API не отвечает");
}
let Some(payload) = snapshot.worktime.payload.as_ref() else {
return block("UNKNOWN", "Нет Worktime JSON");
};
let rows = payload
.get("rows")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
let apps = payload
.get("true_active_apps")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
if rows > 0 {
block(
"OK",
&format!("Есть данные за сегодня: сотрудников={rows}, приложений={apps}"),
)
} else {
block("WARN", "Worktime API ответил, но строк сотрудников нет")
}
}
fn one_c_block(snapshot: &Snapshot) -> SummaryBlock {
if !snapshot.one_c.ok {
return block("FAIL", "1C analytics API не отвечает");
}
let companies = snapshot
.one_c
.payload
.as_ref()
.and_then(|value| value.get("companies_total"))
.and_then(Value::as_u64)
.unwrap_or(0);
block(
"OK",
&format!("1C analytics отвечает, компаний={companies}"),
)
}
fn owner_recommendations(snapshot: &Snapshot, summary: &SummaryResponse) -> Vec<String> {
let mut out = Vec::new();
if !summary.operator_ok {
out.push("Проверить технический контур: общий статус не готов для оператора".to_string());
}
if !grafana_data_ok(snapshot) {
out.push(
"Проверить Grafana data pipeline: dashboard freshness или panel query не OK"
.to_string(),
);
}
if !snapshot.worktime.ok {
out.push(
"Проверить Worktime API и RDP collectors: нет надежного отчета за сегодня".to_string(),
);
}
if !dlp_ok(snapshot) {
out.push("Открыть DLP обзор: есть предупреждения или ошибки DLP".to_string());
}
if !snapshot.one_c.ok {
out.push("Проверить 1C analytics API: управленческий блок может быть неполным".to_string());
}
if out.is_empty() {
out.push("Критичных действий сейчас не требуется".to_string());
}
out
}
fn grafana_data_ok(snapshot: &Snapshot) -> bool {
grafana_service(snapshot)
.and_then(|service| service.get("ok").and_then(Value::as_bool))
.unwrap_or(false)
}
fn grafana_service(snapshot: &Snapshot) -> Option<Value> {
snapshot
.detmir_check
.payload
.as_ref()
.and_then(|check| check.get("services"))
.and_then(Value::as_array)
.and_then(|services| {
services
.iter()
.find(|service| service.get("name").and_then(Value::as_str) == Some("grafana-data"))
})
.cloned()
}
fn dlp_ok(snapshot: &Snapshot) -> bool {
snapshot
.detmir_status
.payload
.as_ref()
.and_then(|status| status.get("dlp_ok"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn block(status: &str, text: &str) -> SummaryBlock {
SummaryBlock {
status: status.to_string(),
text: text.to_string(),
}
}
fn payload_bool(payload: &Value, pointer: &str) -> Option<bool> {
payload.pointer(pointer).and_then(Value::as_bool)
}
fn status_from_payload(payload: &Value) -> String {
payload
.get("severity")
.or_else(|| payload.get("status"))
.and_then(Value::as_str)
.unwrap_or_else(|| {
if payload_bool(payload, "/ok") == Some(false) {
"FAIL"
} else {
"OK"
}
})
.to_string()
}
fn source_summary(name: &str, payload: &Value) -> String {
match name {
"detmir_status" => format!(
"severity={}, operator_ok={}",
payload
.get("severity")
.and_then(Value::as_str)
.unwrap_or("UNKNOWN"),
payload
.get("ok_for_operator")
.and_then(Value::as_bool)
.unwrap_or(false)
),
"detmir_check" => {
let summary = payload.get("summary").unwrap_or(&Value::Null);
format!(
"bucket_ok={}, stale={}, dead={}, service_fail={}",
summary
.get("bucket_ok")
.and_then(Value::as_u64)
.unwrap_or(0),
summary
.get("bucket_stale")
.and_then(Value::as_u64)
.unwrap_or(0),
summary
.get("bucket_dead")
.and_then(Value::as_u64)
.unwrap_or(0),
summary
.get("service_failures")
.and_then(Value::as_u64)
.unwrap_or(0),
)
}
"worktime_api" => format!(
"rows={}, apps={}",
payload
.get("rows")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0),
payload
.get("true_active_apps")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0)
),
"worktime_management" => format!(
"coverage={:.0}%, departments={}, owners={}",
payload
.pointer("/summary/portfolio_coverage_pct")
.and_then(Value::as_f64)
.unwrap_or(0.0),
payload
.get("department_rollups")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0),
payload
.get("owner_rollups")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0)
),
"one_c" => format!(
"status={}, companies={}",
payload
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown"),
payload
.get("companies_total")
.and_then(Value::as_u64)
.unwrap_or(0)
),
_ => "source loaded".to_string(),
}
}
fn respond_json<T: Serialize>(request: Request, value: &T) -> Result<()> {
let body = serde_json::to_string_pretty(value)?;
respond_text(
request,
StatusCode(200),
&body,
"application/json; charset=utf-8",
)
}
fn respond_text(
request: Request,
status: StatusCode,
body: &str,
content_type: &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")?);
request.respond(response).map_err(|err| anyhow!("{err}"))
}
fn respond_file(
request: Request,
path: &Path,
content_type: &str,
download_name: Option<&str>,
) -> Result<()> {
let data = fs::read(path).with_context(|| format!("read {}", path.display()))?;
let mut response = Response::from_data(data)
.with_status_code(StatusCode(200))
.with_header(header("Content-Type", content_type)?)
.with_header(header("Cache-Control", "no-store")?);
if let Some(name) = download_name.and_then(screenshot_basename) {
response = response.with_header(header(
"Content-Disposition",
&format!("attachment; filename=\"{}\"", name.replace('"', "")),
)?);
}
request.respond(response).map_err(|err| anyhow!("{err}"))
}
fn header(name: &str, value: &str) -> Result<Header> {
Header::from_bytes(name.as_bytes(), value.as_bytes())
.map_err(|_| anyhow!("invalid header {name}: {value}"))
}
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_gateway_prefix() {
assert_eq!(normalize_path("/portal/api/health?x=1"), "/api/health");
assert_eq!(normalize_path("/portal/"), "/");
assert_eq!(normalize_path("/api/health"), "/api/health");
}
#[test]
fn links_are_gateway_relative() {
let links = links();
assert!(links.detmir_activitywatch.starts_with("/d/"));
assert!(
links
.worktime_report
.starts_with("/reports/worktime/management?")
);
assert!(links.worktime_report.contains("format=html"));
}
#[test]
fn collection_block_detects_green_summary() {
let value = json!({"summary":{"bucket_stale":0,"bucket_dead":0,"service_failures":0}});
let block = collection_block(Some(&value));
assert_eq!(block.status, "OK");
}
#[test]
fn incident_id_is_stable() {
assert_eq!(
incident_id("service", "grafana-data", "stale"),
incident_id("service", "grafana-data", "stale")
);
assert_ne!(
incident_id("service", "grafana-data", "stale"),
incident_id("service", "grafana-data", "fresh")
);
}
#[test]
fn incident_item_applies_ack_state() {
let id = incident_id("service", "grafana-data", "stale");
let mut state = IncidentStateFile::default();
state.incidents.insert(
id.clone(),
IncidentActionState {
state: "acknowledged".to_string(),
actor: "detmir".to_string(),
updated_at_utc: "2026-06-02T18:00:00Z".to_string(),
acknowledged_at_utc: Some("2026-06-02T18:00:00Z".to_string()),
assigned_to: Some("operator".to_string()),
comment: Some("checking".to_string()),
},
);
let item = incident_item(
"FAIL",
"service",
"grafana-data",
"stale",
"2026-06-02T18:01:00Z",
"/portal/operator",
&state,
);
assert_eq!(item.id, id);
assert!(item.acknowledged);
assert_eq!(item.actor.as_deref(), Some("detmir"));
assert_eq!(item.assigned_to.as_deref(), Some("operator"));
}
#[test]
fn evidence_id_is_stable_and_parseable() {
let id = evidence_id(42, "event-1", Some(r"C:\tmp\shot.png"));
assert_eq!(id, evidence_id(42, "event-1", Some(r"C:\tmp\shot.png")));
assert_ne!(id, evidence_id(42, "event-2", Some(r"C:\tmp\shot.png")));
assert_eq!(evidence_row_id(&id).unwrap(), 42);
}
#[test]
fn screenshot_basename_rejects_traversal() {
assert_eq!(
screenshot_basename(r"C:\Users\operator\shot-1.png").as_deref(),
Some("shot-1.png")
);
assert_eq!(screenshot_basename("../secret.png"), None);
assert_eq!(screenshot_basename("..\\secret.png"), None);
}
#[test]
fn screenshot_resolution_requires_matching_hash_inside_root() {
let dir = tempfile::tempdir().unwrap();
let screenshots = dir.path().join("screenshots");
fs::create_dir_all(&screenshots).unwrap();
let data = b"not a real png, but content type is extension-bound";
let digest = {
let mut hasher = Sha256::new();
hasher.update(data);
format!("{:x}", hasher.finalize())
};
fs::write(screenshots.join(format!("{digest}.png")), data).unwrap();
let args = Cli {
bind: "127.0.0.1:0".to_string(),
status_cmd: "true".to_string(),
check_cmd: "true".to_string(),
failed_units_cmd: "true".to_string(),
worktime_url: "http://127.0.0.1".to_string(),
one_c_url: "http://127.0.0.1".to_string(),
workforce_policy_path: dir.path().join("workforce-policy.json"),
timeout_seconds: 1,
state_dir: dir.path().join("state"),
dlp_db_path: dir.path().join("dlp.sqlite"),
evidence_root: dir.path().to_path_buf(),
evidence_limit: 10,
evidence_max_bytes: 1024,
json_smoke: false,
evidence_only: false,
evidence_upload_token: None,
};
let found = resolve_screenshot_file(&args, &None, &Some(digest.clone()))
.unwrap()
.unwrap();
assert_eq!(found.content_type, "image/png");
assert_eq!(found.sha256.as_deref(), Some(digest.as_str()));
assert!(
resolve_screenshot_file(&args, &None, &Some("0".repeat(64)))
.unwrap()
.is_none()
);
assert!(
resolve_screenshot_file(&args, &None, &None)
.unwrap()
.is_none()
);
}
#[test]
fn evidence_image_type_checks_magic_and_claim() {
let png = b"\x89PNG\r\n\x1a\nrest";
assert_eq!(
evidence_image_type(png, Some("image/png")).unwrap(),
("image/png", "png")
);
assert!(evidence_image_type(png, Some("image/jpeg")).is_err());
let jpg = &[0xff, 0xd8, 0xff, 0xe0, 0x00];
assert_eq!(
evidence_image_type(jpg, Some("application/octet-stream")).unwrap(),
("image/jpeg", "jpg")
);
assert!(evidence_image_type(b"plain text", None).is_err());
}
#[test]
fn constant_time_eq_requires_same_bytes() {
assert!(constant_time_eq(b"secret", b"secret"));
assert!(!constant_time_eq(b"secret", b"other"));
assert!(!constant_time_eq(b"secret", b"secret2"));
}
#[test]
fn human_duration_formats_hhmm() {
assert_eq!(human_duration(0), "00:00");
assert_eq!(human_duration(3660), "01:01");
assert_eq!(human_duration(-10), "00:00");
}
#[test]
fn workforce_index_uses_eight_hour_proxy() {
assert_eq!(workforce_index(1, 8 * 3600), Some(100));
assert_eq!(workforce_index(2, 8 * 3600), Some(50));
assert_eq!(workforce_index(0, 8 * 3600), None);
assert_eq!(workforce_index(1, 0), None);
assert_eq!(workforce_index_text(Some(84)), "84%");
assert_eq!(workforce_index_status(Some(84)), "OK");
assert_eq!(workforce_index_status(Some(70)), "WARN");
assert_eq!(workforce_index_status(Some(30)), "FAIL");
}
#[test]
fn reports_include_commercial_kpis_and_disclaimer() {
let snapshot = Snapshot {
generated_at_utc: "2026-06-03T10:00:00Z".to_string(),
detmir_status: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "severity=OK, operator_ok=true".to_string(),
error: None,
payload: Some(json!({
"severity": "OK",
"ok_for_operator": true,
"dlp_ok": true,
"dlp_counts": {"ok": 22, "warn": 0, "fail": 0}
})),
},
detmir_check: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "bucket_ok=8, stale=0, dead=0, service_fail=0".to_string(),
error: None,
payload: Some(json!({
"summary": {
"bucket_ok": 8,
"bucket_stale": 0,
"bucket_dead": 0,
"service_failures": 0
},
"services": [
{"name": "grafana-data", "ok": true, "payload": {"age_seconds": 60, "fail_count": 0}}
]
})),
},
failed_units: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "failed units not reported".to_string(),
error: None,
payload: Some(json!({"stdout": "0 loaded units listed"})),
},
worktime: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "rows=2, apps=1".to_string(),
error: None,
payload: Some(json!({
"rows": [
{"user": "USER-1", "active_seconds": 3600},
{"user": "USER-2", "active_seconds": 1800}
],
"true_active_apps": [
{"application": "ERP", "proved_work_human": "00:30", "proved_work_seconds": 3600},
{"application": "Browser", "proved_work_human": "00:30", "proved_work_seconds": 3600}
]
})),
},
worktime_management: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "coverage=50%, departments=1, owners=1".to_string(),
error: None,
payload: Some(json!({
"summary": {
"portfolio_coverage_pct": 50.0
},
"department_rollups": [
{
"name": "Бухгалтерия",
"users_count": 2,
"active_users": 1,
"portfolio_coverage_pct": 50.0,
"workday_total_active_hhmm": "04:00"
}
],
"owner_rollups": [
{
"name": "Ответственный",
"users_count": 1,
"active_users": 1,
"portfolio_coverage_pct": 80.0,
"workday_total_active_hhmm": "06:24"
}
],
"trend": [
{
"report_date": "2026-06-03",
"portfolio_coverage_pct": 50.0
}
],
"trend_insights": [
{
"code": "history_insufficient",
"severity": "INFO",
"scope": "portfolio",
"subject": "Workforce",
"title": "История еще накапливается",
"evidence": "Накоплено 1 daily point.",
"recommendation": "Использовать текущий дневной срез."
}
]
})),
},
one_c: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "status=ok, companies=47".to_string(),
error: None,
payload: Some(json!({"status": "ok", "companies_total": 47})),
},
};
let evidence = DlpEvidenceResponse {
ok: true,
generated_at_utc: "2026-06-03T10:00:00Z".to_string(),
db_available: true,
screenshot_root_available: true,
limit: 10,
items: vec![DlpEvidenceItem {
id: "ev-1-0000000000000000".to_string(),
event_ts: "2026-06-03T10:00:00Z".to_string(),
bucket_id: "aw-dlp-incidents_HOST-EXAMPLE".to_string(),
event_id: "event-1".to_string(),
stream_type: "dlp_incident".to_string(),
hostname: "HOST-EXAMPLE".to_string(),
username: None,
severity: Some("medium".to_string()),
signal_type: Some("clipboard".to_string()),
rule_id: None,
action: None,
source: None,
message: None,
file_path: None,
has_screenshot_metadata: true,
screenshot_available: true,
source_file: Some("shot.png".to_string()),
screenshot_sha256: Some("0".repeat(64)),
screenshot_width: None,
screenshot_height: None,
preview_url: None,
download_url: None,
blocked_reason: None,
}],
error: None,
};
let missing_policy = Path::new("/tmp/detmir-missing-workforce-policy.json");
let report = build_reports(
&snapshot,
&IncidentStateFile::default(),
&evidence,
missing_policy,
);
assert_eq!(report["operator_ok"], true);
assert_eq!(report["severity"], "OK");
assert!(
report["markdown"]
.as_str()
.unwrap()
.contains("derived detections/cases")
);
assert!(report["kpis"].as_array().unwrap().len() >= 6);
assert_eq!(report["workforce_policy"]["configured"], false);
assert_eq!(report["workforce"]["trend_status"], "daily_only");
assert_eq!(report["workforce"]["insights"].as_array().unwrap().len(), 1);
assert_eq!(
report["workforce"]["department_comparison"]
.as_array()
.unwrap()
.len(),
1
);
}
#[test]
fn weighted_activity_uses_role_application_policy() {
let snapshot = Snapshot {
generated_at_utc: "2026-06-03T10:00:00Z".to_string(),
detmir_status: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: Some(json!({})),
},
detmir_check: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: Some(json!({})),
},
failed_units: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: None,
},
worktime: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: Some(json!({
"true_active_apps": [
{"application": "1С", "proved_work_seconds": 3600},
{"application": "YouTube", "proved_work_seconds": 3600}
]
})),
},
worktime_management: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: Some(json!({})),
},
one_c: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: None,
},
};
let policy = WorkforcePolicy {
default_role: "accountant".to_string(),
roles: BTreeMap::from([(
"accountant".to_string(),
WorkforceRolePolicy {
label: Some("Бухгалтер".to_string()),
description: Some(
"Бухгалтер: высокий вес 1С и офисных документов; развлекательные сайты не учитываются."
.to_string(),
),
planned_hours_per_day: Some(8.0),
default_weight: Some(0.2),
application_weights: BTreeMap::from([
("1с".to_string(), 1.0),
("youtube".to_string(), 0.0),
]),
},
)]),
};
let weighted = weighted_activity(&snapshot, &policy, 1).unwrap();
assert_eq!(weighted.role, "accountant");
assert_eq!(weighted.weighted_seconds, 3600);
assert_eq!(weighted.app_seconds, 7200);
assert_eq!(weighted.index, Some(13));
}
}