feat(detmir): add DLP evidence portal viewer
This commit is contained in:
Generated
+3
@@ -580,8 +580,11 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"tiny_http",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user