From 122c4bbfc0733d24919f3b73b4df1fedf04c4076 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Wed, 3 Jun 2026 00:33:31 +0300 Subject: [PATCH] feat(detmir): add DLP evidence portal viewer --- adk-rust/Cargo.lock | 3 + adk-rust/RUNBOOK.md | 19 + adk-rust/crates/detmir-portal/Cargo.toml | 4 + adk-rust/crates/detmir-portal/src/main.rs | 732 +++++++++++++++++- .../crates/detmir-portal/src/static/app.css | 15 + .../crates/detmir-portal/src/static/app.js | 37 +- ansible/deploy_detmir_portal.yml | 120 +++ ansible/templates/proxmox-web-gateway.conf.j2 | 7 + 8 files changed, 932 insertions(+), 5 deletions(-) diff --git a/adk-rust/Cargo.lock b/adk-rust/Cargo.lock index 7861ead..5d49292 100644 --- a/adk-rust/Cargo.lock +++ b/adk-rust/Cargo.lock @@ -580,8 +580,11 @@ dependencies = [ "chrono", "clap", "reqwest", + "rusqlite", "serde", "serde_json", + "sha2", + "tempfile", "tiny_http", ] diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md index 99f6888..6d4d59b 100644 --- a/adk-rust/RUNBOOK.md +++ b/adk-rust/RUNBOOK.md @@ -1611,6 +1611,25 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg non-incident operational links are intentionally not shown in this card. Local Playwright smoke verified DLP incident rendering, dashboard links, no Worktime/1C text in the card, and zero JS errors. + - DLP evidence viewing layer added to the portal: + `detmir-portal` now exposes `/api/dlp/evidence` and safe screenshot + routes by opaque evidence id. The service reads DLP warehouse SQLite + read-only, extracts `screenshotSha256`/dimensions from raw event JSON, + and serves image files only from an allowlisted evidence root after + canonical path, extension, size, and SHA-256 validation. Evidence views + and downloads append to `evidence-audit.jsonl`. Production uses an + AW-server evidence-only service, `/usr/local/bin/detmir-portal-evidence` + with `detmir-portal-evidence.service`, because the DLP warehouse lives on + the AW server. Proxmox nginx gateway routes + `/portal/api/dlp/evidence*` to `10.10.10.13:8721`. Current production + verification: AW evidence API `ok=true`, gateway evidence route + `ok=true`, `db_available=true`, 11 DLP evidence rows returned, + `screenshot_available=0` because current stored rows do not yet contain + screenshot metadata, both portal/evidence services active, failed units + 0, `detmir-status` `OK / ok_for_operator=true`. Local HTTP smoke + verified byte-identical screenshot serving and audit logging; local + Playwright smoke verified the `Доказательства` block, `СКРИН`, `Открыть`, + `Скачать`, and zero JS errors. - during this deploy, `detmir-grafana-check` was corrected so empty detail-only panels for employees/applications are WARN, not FAIL. The mandatory freshness/summary panels still fail the check when stale or diff --git a/adk-rust/crates/detmir-portal/Cargo.toml b/adk-rust/crates/detmir-portal/Cargo.toml index 6b0d9e2..10b634a 100644 --- a/adk-rust/crates/detmir-portal/Cargo.toml +++ b/adk-rust/crates/detmir-portal/Cargo.toml @@ -11,7 +11,11 @@ anyhow.workspace = true chrono.workspace = true clap.workspace = true reqwest.workspace = true +rusqlite.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true tiny_http.workspace = true +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index f4f49e0..b836f92 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; -use std::fs::{self, OpenOptions}; +use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; @@ -11,8 +11,10 @@ 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"); @@ -70,8 +72,35 @@ struct Cli { )] 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, } #[derive(Debug, Serialize)] @@ -177,6 +206,101 @@ struct IncidentAuditEntry { comment: Option, } +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + source_file: Option, +} + +#[derive(Debug, Serialize)] +struct DlpEvidenceResponse { + ok: bool, + generated_at_utc: String, + db_available: bool, + screenshot_root_available: bool, + limit: u32, + items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + severity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + signal_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rule_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + action: Option, + #[serde(skip_serializing_if = "Option::is_none")] + source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + file_path: Option, + has_screenshot_metadata: bool, + screenshot_available: bool, + #[serde(skip_serializing_if = "Option::is_none")] + source_file: Option, + #[serde(skip_serializing_if = "Option::is_none")] + screenshot_sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + screenshot_width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + screenshot_height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + preview_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + download_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + blocked_reason: Option, +} + +#[derive(Debug)] +struct DlpEvidenceRow { + row_id: i64, + bucket_id: String, + event_id: String, + stream_type: String, + hostname: String, + username: Option, + event_ts: String, + operation: Option, + file_path: Option, + rule_id: Option, + action: Option, + severity: Option, + signal_type: Option, + message: Option, + source: Option, + screenshot_path: Option, + raw_json: String, +} + +#[derive(Debug, Clone)] +struct ScreenshotFile { + path: PathBuf, + content_type: &'static str, + source_file: Option, + sha256: Option, +} + #[derive(Debug, Serialize)] struct PortalLinks { portal: String, @@ -221,6 +345,7 @@ fn run() -> Result { "health": build_health(&snapshot), "summary": build_summary(&snapshot), "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 }); @@ -229,7 +354,12 @@ fn run() -> Result { 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() { - if let Err(err) = handle_request(request, &args) { + 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:#}"); } } @@ -245,6 +375,12 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> { 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" => respond_text( request, @@ -290,6 +426,38 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> { } } +fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> { + let method = request.method().clone(); + let path = normalize_path(request.url()); + 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(), + }), + ); + } + 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); @@ -880,6 +1048,481 @@ 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> { + 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::, _>>()?; + rows.into_iter() + .map(|row| evidence_item_from_row(args, row)) + .collect() +} + +fn evidence_item_from_row(args: &Cli, row: DlpEvidenceRow) -> Result { + let raw = serde_json::from_str::(&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_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> { + 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::(&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, + sha256: &Option, +) -> Result> { + 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(format!("{expected_sha256}.png"))); + 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 { + 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::() + .map_err(|err| anyhow!("malformed evidence row id: {err}")) +} + +fn json_string(value: &Value, names: &[&str]) -> Option { + 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 { + names + .iter() + .find_map(|name| value.get(*name).and_then(Value::as_i64)) +} + +fn screenshot_basename(path: &str) -> Option { + 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 { + 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 request_actor(request: &Request) -> String { for name in ["X-Remote-User", "X-Gateway-User", "Remote-User"] { if let Some(value) = request @@ -1238,6 +1881,26 @@ fn respond_text( 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::from_bytes(name.as_bytes(), value.as_bytes()) .map_err(|_| anyhow!("invalid header {name}: {value}")) @@ -1318,4 +1981,67 @@ mod tests { 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(), + 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, + }; + 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() + ); + } } diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index e75bab9..e187d35 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -156,6 +156,14 @@ h1 { grid-template-columns: minmax(150px, 1fr) minmax(220px, 2fr) auto auto; } +.evidence-card { + margin-top: 12px; +} + +.evidence-row { + grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr) auto auto; +} + .actions { display: flex; flex-wrap: wrap; @@ -176,6 +184,12 @@ h1 { cursor: pointer; } +a.small-button { + display: inline-flex; + align-items: center; + text-decoration: none; +} + .small-button:disabled { cursor: default; opacity: 0.55; @@ -215,5 +229,6 @@ pre { h1 { font-size: 24px; } .row { grid-template-columns: 1fr; } .incident-row { grid-template-columns: 1fr; } + .evidence-row { grid-template-columns: 1fr; } .actions { justify-content: flex-start; } } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 7eeb7c8..525b166 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -202,20 +202,50 @@ function renderDlpIncidentsList(items) { return renderIncidentsList(dlpItems); } +function renderDlpEvidence(evidence) { + if (!evidence) return `

Данные evidence загружаются.

`; + if (!evidence.ok) return `

Evidence недоступны: ${escapeHtml(evidence.error || "ошибка чтения")}

`; + const items = evidence.items || []; + if (items.length === 0) return `

DLP evidence пока не найдены.

`; + return `
${items.map(item => ` +
+
+ ${escapeHtml(item.signal_type || item.source || item.stream_type)} +
${escapeHtml(item.event_ts)} · ${escapeHtml(item.hostname)}${item.username ? " · " + escapeHtml(item.username) : ""}
+
+
+ ${escapeHtml(item.message || item.file_path || item.rule_id || item.event_id)} +
${item.source_file ? "Файл: " + escapeHtml(item.source_file) + " · " : ""}${item.screenshot_sha256 ? "SHA-256: " + escapeHtml(item.screenshot_sha256) : escapeHtml(item.blocked_reason || "без скрина")}
+
+ ${item.screenshot_available ? "СКРИН" : "МЕТА"} +
+ ${item.preview_url ? `Открыть` : ""} + ${item.download_url ? `Скачать` : ""} +
+
+ `).join("")}
`; +} + function renderIncidents(data) { const links = state.links || {}; + const incidents = Array.isArray(data) ? data : data.incidents; + const evidence = Array.isArray(data) ? null : data.evidence; return `

Инциденты ИБ

DLP-инциденты

- ${renderDlpIncidentsList(data)} + ${renderDlpIncidentsList(incidents)}

Графики и дашборды

${renderDlpLinks(links)}
+
+

Доказательства

+ ${renderDlpEvidence(evidence)} +
`; } @@ -228,7 +258,10 @@ async function refresh() { if (state.tab === "operator") content.innerHTML = renderOperator(data); if (state.tab === "manager") content.innerHTML = renderManager(data); if (state.tab === "owner") content.innerHTML = renderOwner(data); - if (state.tab === "incidents") content.innerHTML = renderIncidents(data); + if (state.tab === "incidents") { + const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] })); + content.innerHTML = renderIncidents({ incidents: data, evidence }); + } } function setTab(tab) { diff --git a/ansible/deploy_detmir_portal.yml b/ansible/deploy_detmir_portal.yml index 2a15f8f..eb5ba67 100644 --- a/ansible/deploy_detmir_portal.yml +++ b/ansible/deploy_detmir_portal.yml @@ -46,6 +46,22 @@ DETMIR_PORTAL_ONE_C_URL=http://10.10.10.2:8710 DETMIR_PORTAL_TIMEOUT_SECONDS=10 DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal + DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite + DETMIR_PORTAL_EVIDENCE_ROOT=/var/lib/detmir-portal/evidence + DETMIR_PORTAL_EVIDENCE_LIMIT=30 + DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608 + + - name: Ensure detmir-portal state and evidence directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0750" + loop: + - /var/lib/detmir-portal + - /var/lib/detmir-portal/evidence + - /var/lib/detmir-portal/evidence/screenshots - name: Install detmir-portal systemd service ansible.builtin.copy: @@ -92,3 +108,107 @@ - detmir_portal_health.status != 200 - "'sources' not in detmir_portal_health.content" changed_when: false + +- name: Deploy DetMir DLP evidence API on AW server + hosts: aw_server + become: true + gather_facts: false + + vars: + aw_repo_root: "{{ playbook_dir | dirname }}" + aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}" + detmir_evidence_bind: "{{ detmir_evidence_bind_override | default('10.10.10.13:8721') }}" + detmir_evidence_env_path: "/etc/detmir-portal-evidence.env" + + tasks: + - name: Check local detmir-portal binary for evidence service + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/detmir-portal" + delegate_to: localhost + become: false + register: detmir_evidence_binary + + - name: Fail when detmir-portal evidence binary is absent + ansible.builtin.fail: + msg: "Missing {{ aw_rust_release_dir }}/detmir-portal. Build with cargo build --release -p detmir-portal." + when: not (detmir_evidence_binary.stat.exists | default(false)) + + - name: Install detmir-portal evidence binary + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/detmir-portal" + dest: /usr/local/bin/detmir-portal-evidence + owner: root + group: root + mode: "0755" + + - name: Ensure detmir evidence directories on AW server + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0750" + loop: + - /var/lib/activitywatch/dlp-evidence + - /var/lib/activitywatch/dlp-evidence/screenshots + + - name: Install detmir evidence API environment + ansible.builtin.copy: + dest: "{{ detmir_evidence_env_path }}" + owner: root + group: root + mode: "0644" + content: | + DETMIR_PORTAL_BIND={{ detmir_evidence_bind }} + DETMIR_PORTAL_EVIDENCE_ONLY=true + DETMIR_PORTAL_STATE_DIR=/var/lib/activitywatch/dlp-evidence + DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite + DETMIR_PORTAL_EVIDENCE_ROOT=/var/lib/activitywatch/dlp-evidence + DETMIR_PORTAL_EVIDENCE_LIMIT=30 + DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608 + + - name: Install detmir evidence API systemd service + ansible.builtin.copy: + dest: /etc/systemd/system/detmir-portal-evidence.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=DetMir DLP Evidence API + After=network-online.target + Wants=network-online.target + + [Service] + Type=simple + EnvironmentFile=-{{ detmir_evidence_env_path }} + ExecStart=/usr/local/bin/detmir-portal-evidence + Restart=on-failure + RestartSec=5s + + [Install] + WantedBy=multi-user.target + register: detmir_evidence_service_unit + + - name: Reload systemd for evidence API + ansible.builtin.systemd: + daemon_reload: true + when: detmir_evidence_service_unit.changed + + - name: Enable and restart detmir evidence API + ansible.builtin.systemd: + name: detmir-portal-evidence.service + enabled: true + state: restarted + + - name: Verify detmir evidence API health + ansible.builtin.uri: + url: "http://{{ detmir_evidence_bind }}/api/health" + method: GET + status_code: 200 + return_content: true + register: detmir_evidence_health + failed_when: + - detmir_evidence_health.status != 200 + - "'evidence-only' not in detmir_evidence_health.content" + changed_when: false diff --git a/ansible/templates/proxmox-web-gateway.conf.j2 b/ansible/templates/proxmox-web-gateway.conf.j2 index fa3667d..89104b2 100644 --- a/ansible/templates/proxmox-web-gateway.conf.j2 +++ b/ansible/templates/proxmox-web-gateway.conf.j2 @@ -261,6 +261,13 @@ server { return 302 /portal/; } + location ^~ /portal/api/dlp/evidence { + proxy_set_header Authorization ""; + proxy_set_header X-Remote-User $remote_user; + proxy_pass http://10.10.10.13:8721/api/dlp/evidence; + proxy_redirect off; + } + location /portal/ { proxy_set_header Authorization ""; proxy_set_header X-Remote-User $remote_user;