feat(detmir): add DLP evidence portal viewer
This commit is contained in:
Generated
+3
@@ -580,8 +580,11 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"tempfile",
|
||||||
"tiny_http",
|
"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.
|
non-incident operational links are intentionally not shown in this card.
|
||||||
Local Playwright smoke verified DLP incident rendering, dashboard links,
|
Local Playwright smoke verified DLP incident rendering, dashboard links,
|
||||||
no Worktime/1C text in the card, and zero JS errors.
|
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
|
- during this deploy, `detmir-grafana-check` was corrected so empty
|
||||||
detail-only panels for employees/applications are WARN, not FAIL. The
|
detail-only panels for employees/applications are WARN, not FAIL. The
|
||||||
mandatory freshness/summary panels still fail the check when stale or
|
mandatory freshness/summary panels still fail the check when stale or
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ anyhow.workspace = true
|
|||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
rusqlite.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
tiny_http.workspace = true
|
tiny_http.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs::{self, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -11,8 +11,10 @@ use chrono::{SecondsFormat, Utc};
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use reqwest::header::{CONNECTION, HeaderValue};
|
use reqwest::header::{CONNECTION, HeaderValue};
|
||||||
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||||
|
|
||||||
const INDEX_HTML: &str = include_str!("static/index.html");
|
const INDEX_HTML: &str = include_str!("static/index.html");
|
||||||
@@ -70,8 +72,35 @@ struct Cli {
|
|||||||
)]
|
)]
|
||||||
state_dir: PathBuf,
|
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)]
|
#[arg(long)]
|
||||||
json_smoke: bool,
|
json_smoke: bool,
|
||||||
|
|
||||||
|
#[arg(long, env = "DETMIR_PORTAL_EVIDENCE_ONLY")]
|
||||||
|
evidence_only: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -177,6 +206,101 @@ struct IncidentAuditEntry {
|
|||||||
comment: Option<String>,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
struct PortalLinks {
|
struct PortalLinks {
|
||||||
portal: String,
|
portal: String,
|
||||||
@@ -221,6 +345,7 @@ fn run() -> Result<i32> {
|
|||||||
"health": build_health(&snapshot),
|
"health": build_health(&snapshot),
|
||||||
"summary": build_summary(&snapshot),
|
"summary": build_summary(&snapshot),
|
||||||
"incidents": build_incidents(&snapshot, &incident_state),
|
"incidents": build_incidents(&snapshot, &incident_state),
|
||||||
|
"dlp_evidence": build_dlp_evidence_response(&args),
|
||||||
});
|
});
|
||||||
println!("{}", serde_json::to_string_pretty(&smoke)?);
|
println!("{}", serde_json::to_string_pretty(&smoke)?);
|
||||||
return Ok(if build_health(&snapshot).ok { 0 } else { 2 });
|
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))?;
|
let server = Server::http(&args.bind).map_err(|err| anyhow!("bind {}: {err}", args.bind))?;
|
||||||
eprintln!("detmir-portal listening on http://{}", args.bind);
|
eprintln!("detmir-portal listening on http://{}", args.bind);
|
||||||
for request in server.incoming_requests() {
|
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:#}");
|
eprintln!("detmir-portal request failed: {err:#}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,6 +375,12 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> {
|
|||||||
if method != Method::Get {
|
if method != Method::Get {
|
||||||
return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain");
|
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() {
|
match path.as_str() {
|
||||||
"/" | "/operator" | "/manager" | "/owner" | "/incidents" => respond_text(
|
"/" | "/operator" | "/manager" | "/owner" | "/incidents" => respond_text(
|
||||||
request,
|
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 {
|
fn normalize_path(url: &str) -> String {
|
||||||
let path = url.split('?').next().unwrap_or("/");
|
let path = url.split('?').next().unwrap_or("/");
|
||||||
let path = path.strip_prefix("/portal").unwrap_or(path);
|
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")
|
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 {
|
fn request_actor(request: &Request) -> String {
|
||||||
for name in ["X-Remote-User", "X-Gateway-User", "Remote-User"] {
|
for name in ["X-Remote-User", "X-Gateway-User", "Remote-User"] {
|
||||||
if let Some(value) = request
|
if let Some(value) = request
|
||||||
@@ -1238,6 +1881,26 @@ fn respond_text(
|
|||||||
request.respond(response).map_err(|err| anyhow!("{err}"))
|
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> {
|
fn header(name: &str, value: &str) -> Result<Header> {
|
||||||
Header::from_bytes(name.as_bytes(), value.as_bytes())
|
Header::from_bytes(name.as_bytes(), value.as_bytes())
|
||||||
.map_err(|_| anyhow!("invalid header {name}: {value}"))
|
.map_err(|_| anyhow!("invalid header {name}: {value}"))
|
||||||
@@ -1318,4 +1981,67 @@ mod tests {
|
|||||||
assert_eq!(item.actor.as_deref(), Some("detmir"));
|
assert_eq!(item.actor.as_deref(), Some("detmir"));
|
||||||
assert_eq!(item.assigned_to.as_deref(), Some("operator"));
|
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;
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -176,6 +184,12 @@ h1 {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.small-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.small-button:disabled {
|
.small-button:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
@@ -215,5 +229,6 @@ pre {
|
|||||||
h1 { font-size: 24px; }
|
h1 { font-size: 24px; }
|
||||||
.row { grid-template-columns: 1fr; }
|
.row { grid-template-columns: 1fr; }
|
||||||
.incident-row { grid-template-columns: 1fr; }
|
.incident-row { grid-template-columns: 1fr; }
|
||||||
|
.evidence-row { grid-template-columns: 1fr; }
|
||||||
.actions { justify-content: flex-start; }
|
.actions { justify-content: flex-start; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,20 +202,50 @@ function renderDlpIncidentsList(items) {
|
|||||||
return renderIncidentsList(dlpItems);
|
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) {
|
function renderIncidents(data) {
|
||||||
const links = state.links || {};
|
const links = state.links || {};
|
||||||
|
const incidents = Array.isArray(data) ? data : data.incidents;
|
||||||
|
const evidence = Array.isArray(data) ? null : data.evidence;
|
||||||
return `
|
return `
|
||||||
<h2 class="section-title">Инциденты ИБ</h2>
|
<h2 class="section-title">Инциденты ИБ</h2>
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>DLP-инциденты</h3>
|
<h3>DLP-инциденты</h3>
|
||||||
${renderDlpIncidentsList(data)}
|
${renderDlpIncidentsList(incidents)}
|
||||||
</section>
|
</section>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Графики и дашборды</h3>
|
<h3>Графики и дашборды</h3>
|
||||||
${renderDlpLinks(links)}
|
${renderDlpLinks(links)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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 === "operator") content.innerHTML = renderOperator(data);
|
||||||
if (state.tab === "manager") content.innerHTML = renderManager(data);
|
if (state.tab === "manager") content.innerHTML = renderManager(data);
|
||||||
if (state.tab === "owner") content.innerHTML = renderOwner(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) {
|
function setTab(tab) {
|
||||||
|
|||||||
@@ -46,6 +46,22 @@
|
|||||||
DETMIR_PORTAL_ONE_C_URL=http://10.10.10.2:8710
|
DETMIR_PORTAL_ONE_C_URL=http://10.10.10.2:8710
|
||||||
DETMIR_PORTAL_TIMEOUT_SECONDS=10
|
DETMIR_PORTAL_TIMEOUT_SECONDS=10
|
||||||
DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal
|
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
|
- name: Install detmir-portal systemd service
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
@@ -92,3 +108,107 @@
|
|||||||
- detmir_portal_health.status != 200
|
- detmir_portal_health.status != 200
|
||||||
- "'sources' not in detmir_portal_health.content"
|
- "'sources' not in detmir_portal_health.content"
|
||||||
changed_when: false
|
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/;
|
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/ {
|
location /portal/ {
|
||||||
proxy_set_header Authorization "";
|
proxy_set_header Authorization "";
|
||||||
proxy_set_header X-Remote-User $remote_user;
|
proxy_set_header X-Remote-User $remote_user;
|
||||||
|
|||||||
Reference in New Issue
Block a user