feat(detmir): add DLP evidence portal viewer

This commit is contained in:
igor04091968
2026-06-03 00:33:31 +03:00
parent 189885d73c
commit 122c4bbfc0
8 changed files with 932 additions and 5 deletions
+3
View File
@@ -580,8 +580,11 @@ dependencies = [
"chrono",
"clap",
"reqwest",
"rusqlite",
"serde",
"serde_json",
"sha2",
"tempfile",
"tiny_http",
]
+19
View File
@@ -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
+4
View File
@@ -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
+729 -3
View File
@@ -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<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, 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,
@@ -221,6 +345,7 @@ fn run() -> Result<i32> {
"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<i32> {
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<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_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(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<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 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> {
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()
);
}
}
@@ -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; }
}
@@ -202,20 +202,50 @@ function renderDlpIncidentsList(items) {
return renderIncidentsList(dlpItems);
}
function renderDlpEvidence(evidence) {
if (!evidence) return `<p class="muted">Данные evidence загружаются.</p>`;
if (!evidence.ok) return `<p class="muted">Evidence недоступны: ${escapeHtml(evidence.error || "ошибка чтения")}</p>`;
const items = evidence.items || [];
if (items.length === 0) return `<p class="muted">DLP evidence пока не найдены.</p>`;
return `<div class="list evidence-list">${items.map(item => `
<div class="row evidence-row">
<div>
<strong>${escapeHtml(item.signal_type || item.source || item.stream_type)}</strong>
<div class="muted small">${escapeHtml(item.event_ts)} · ${escapeHtml(item.hostname)}${item.username ? " · " + escapeHtml(item.username) : ""}</div>
</div>
<div>
<span class="muted">${escapeHtml(item.message || item.file_path || item.rule_id || item.event_id)}</span>
<div class="muted small">${item.source_file ? "Файл: " + escapeHtml(item.source_file) + " · " : ""}${item.screenshot_sha256 ? "SHA-256: " + escapeHtml(item.screenshot_sha256) : escapeHtml(item.blocked_reason || "без скрина")}</div>
</div>
<span class="badge ${item.screenshot_available ? "status-ok" : "status-warn"}">${item.screenshot_available ? "СКРИН" : "МЕТА"}</span>
<div class="actions">
${item.preview_url ? `<a class="small-button" href="${escapeHtml(item.preview_url)}" target="_blank" rel="noopener noreferrer">Открыть</a>` : ""}
${item.download_url ? `<a class="small-button" href="${escapeHtml(item.download_url)}" target="_blank" rel="noopener noreferrer">Скачать</a>` : ""}
</div>
</div>
`).join("")}</div>`;
}
function renderIncidents(data) {
const links = state.links || {};
const incidents = Array.isArray(data) ? data : data.incidents;
const evidence = Array.isArray(data) ? null : data.evidence;
return `
<h2 class="section-title">Инциденты ИБ</h2>
<div class="grid-2">
<section class="card">
<h3>DLP-инциденты</h3>
${renderDlpIncidentsList(data)}
${renderDlpIncidentsList(incidents)}
</section>
<section class="card">
<h3>Графики и дашборды</h3>
${renderDlpLinks(links)}
</section>
</div>
<section class="card evidence-card">
<h3>Доказательства</h3>
${renderDlpEvidence(evidence)}
</section>
`;
}
@@ -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) {
+120
View File
@@ -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
@@ -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;