feat(detmir): automate DLP evidence screenshot sync
This commit is contained in:
Generated
+1
@@ -577,6 +577,7 @@ name = "detmir-portal"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"reqwest",
|
||||
|
||||
@@ -64,6 +64,7 @@ publish = false
|
||||
[workspace.dependencies]
|
||||
adk-rust = { version = "0.9.1", default-features = false }
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
detmir-aw-client = { path = "crates/detmir-aw-client" }
|
||||
|
||||
@@ -1630,6 +1630,25 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg
|
||||
verified byte-identical screenshot serving and audit logging; local
|
||||
Playwright smoke verified the `Доказательства` block, `СКРИН`, `Открыть`,
|
||||
`Скачать`, and zero JS errors.
|
||||
- DLP evidence screenshot delivery is now automated in production:
|
||||
the AW evidence API accepts authenticated uploads at
|
||||
`POST /api/dlp/evidence/upload` in evidence-only mode. Uploads require a
|
||||
server-generated Bearer token stored outside git, validate base64 body,
|
||||
PNG/JPEG magic, max size, and exact SHA-256 before atomic write to
|
||||
`/var/lib/activitywatch/dlp-evidence/screenshots/<sha256>.(png|jpg)`.
|
||||
Windows RDP host runs `sync-dlp-evidence-artifacts.ps1` through scheduled
|
||||
task `ActivityWatch DLP Evidence Sync` every 5 minutes as SYSTEM. The
|
||||
sync scans `C:\ProgramData\AWatch-rus\incident-artifacts` plus configured
|
||||
artifact roots, uploads new PNG screenshots, and keeps local upload state
|
||||
in `C:\ProgramData\AWatch-rus\dlp-evidence-sync-state.json`. Production
|
||||
controlled test: a visible PNG was created on Windows, sync uploaded it
|
||||
(`uploaded=1`, `failed=0`), a temporary warehouse row made it visible in
|
||||
the portal with `screenshot_available=true`, gateway preview returned
|
||||
`image/png` with matching SHA, external Playwright verified the portal UI
|
||||
and preview, audit recorded upload/view, unauthenticated upload returned
|
||||
403, and the synthetic row/files/state were removed. Final state:
|
||||
evidence count returned to 11, synthetic hit false, AW/Proxmox failed
|
||||
units 0, `detmir-status` OK, sync task Ready with `lastTaskResult=0`.
|
||||
- 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
|
||||
|
||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
reqwest.workspace = true
|
||||
|
||||
@@ -7,6 +7,8 @@ use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use clap::Parser;
|
||||
use reqwest::blocking::Client;
|
||||
@@ -101,6 +103,9 @@ struct Cli {
|
||||
|
||||
#[arg(long, env = "DETMIR_PORTAL_EVIDENCE_ONLY")]
|
||||
evidence_only: bool,
|
||||
|
||||
#[arg(long, env = "DETMIR_PORTAL_EVIDENCE_UPLOAD_TOKEN")]
|
||||
evidence_upload_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -218,6 +223,33 @@ struct EvidenceAuditEntry {
|
||||
source_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EvidenceUploadRequest {
|
||||
sha256: String,
|
||||
#[serde(default)]
|
||||
content_base64: String,
|
||||
#[serde(default)]
|
||||
content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
source_file: Option<String>,
|
||||
#[serde(default)]
|
||||
source_path: Option<String>,
|
||||
#[serde(default)]
|
||||
hostname: Option<String>,
|
||||
#[serde(default)]
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct EvidenceUploadResponse {
|
||||
ok: bool,
|
||||
sha256: String,
|
||||
content_type: String,
|
||||
bytes: u64,
|
||||
stored: bool,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DlpEvidenceResponse {
|
||||
ok: bool,
|
||||
@@ -429,6 +461,9 @@ 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::Post && path == "/api/dlp/evidence/upload" {
|
||||
return handle_evidence_upload(request, args);
|
||||
}
|
||||
if method != Method::Get {
|
||||
return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain");
|
||||
}
|
||||
@@ -441,6 +476,7 @@ fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> {
|
||||
"mode": "evidence-only",
|
||||
"db_available": args.dlp_db_path.exists(),
|
||||
"screenshot_root_available": args.evidence_root.exists(),
|
||||
"upload_enabled": upload_enabled(args),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1212,6 +1248,123 @@ fn parse_evidence_screenshot_path(path: &str) -> Option<(String, bool)> {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_evidence_upload(mut request: Request, args: &Cli) -> Result<()> {
|
||||
if !upload_authorized(&request, args) {
|
||||
return respond_text(
|
||||
request,
|
||||
StatusCode(403),
|
||||
"Forbidden",
|
||||
"text/plain; charset=utf-8",
|
||||
);
|
||||
}
|
||||
let body_limit = args
|
||||
.evidence_max_bytes
|
||||
.saturating_mul(2)
|
||||
.saturating_add(64 * 1024)
|
||||
.min(32 * 1024 * 1024);
|
||||
let mut body = String::new();
|
||||
request
|
||||
.as_reader()
|
||||
.take(body_limit)
|
||||
.read_to_string(&mut body)?;
|
||||
match apply_evidence_upload(args, &request_actor(&request), &body) {
|
||||
Ok(response) => respond_json(request, &response),
|
||||
Err(err) => respond_text(
|
||||
request,
|
||||
StatusCode(400),
|
||||
&serde_json::to_string_pretty(&json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
}))?,
|
||||
"application/json; charset=utf-8",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_evidence_upload(args: &Cli, actor: &str, body: &str) -> Result<EvidenceUploadResponse> {
|
||||
let upload: EvidenceUploadRequest =
|
||||
serde_json::from_str(body).map_err(|err| anyhow!("invalid evidence upload JSON: {err}"))?;
|
||||
let expected_sha256 = upload.sha256.trim().to_ascii_lowercase();
|
||||
if !is_sha256_hex(&expected_sha256) {
|
||||
return Err(anyhow!("sha256 is invalid"));
|
||||
}
|
||||
if upload.content_base64.is_empty() {
|
||||
return Err(anyhow!("content_base64 is empty"));
|
||||
}
|
||||
let bytes = BASE64_STANDARD
|
||||
.decode(upload.content_base64.as_bytes())
|
||||
.map_err(|err| anyhow!("content_base64 decode failed: {err}"))?;
|
||||
if bytes.is_empty() {
|
||||
return Err(anyhow!("content is empty"));
|
||||
}
|
||||
if bytes.len() as u64 > args.evidence_max_bytes {
|
||||
return Err(anyhow!("content is too large"));
|
||||
}
|
||||
let actual_sha256 = sha256_bytes(&bytes);
|
||||
if actual_sha256 != expected_sha256 {
|
||||
return Err(anyhow!("sha256 mismatch"));
|
||||
}
|
||||
let (content_type, extension) = evidence_image_type(&bytes, upload.content_type.as_deref())?;
|
||||
let root = ensure_evidence_root(args)?;
|
||||
let screenshots = root.join("screenshots");
|
||||
fs::create_dir_all(&screenshots)
|
||||
.with_context(|| format!("create {}", screenshots.display()))?;
|
||||
let path = screenshots.join(format!("{expected_sha256}.{extension}"));
|
||||
let mut stored = false;
|
||||
if path.exists() {
|
||||
let existing = sha256_file(&path)?;
|
||||
if existing != expected_sha256 {
|
||||
return Err(anyhow!("existing evidence file hash mismatch"));
|
||||
}
|
||||
} else {
|
||||
let tmp = screenshots.join(format!(
|
||||
"{expected_sha256}.{extension}.tmp-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::write(&tmp, &bytes).with_context(|| format!("write {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &path).with_context(|| format!("rename {}", path.display()))?;
|
||||
stored = true;
|
||||
}
|
||||
append_evidence_audit(
|
||||
args,
|
||||
&EvidenceAuditEntry {
|
||||
generated_at_utc: now(),
|
||||
actor: actor.to_string(),
|
||||
action: "upload".to_string(),
|
||||
evidence_id: format!("sha256:{expected_sha256}"),
|
||||
sha256: Some(expected_sha256.clone()),
|
||||
source_file: upload
|
||||
.source_file
|
||||
.as_deref()
|
||||
.and_then(screenshot_basename)
|
||||
.or_else(|| upload.source_path.as_deref().and_then(screenshot_basename)),
|
||||
},
|
||||
)?;
|
||||
let meta = json!({
|
||||
"generated_at_utc": now(),
|
||||
"hostname": upload.hostname.as_deref().map(|value| sanitize_text(value, 80)),
|
||||
"username": upload.username.as_deref().map(|value| sanitize_text(value, 80)),
|
||||
"source_file": upload.source_file.as_deref().and_then(screenshot_basename),
|
||||
"source_path_basename": upload.source_path.as_deref().and_then(screenshot_basename),
|
||||
"sha256": expected_sha256,
|
||||
"content_type": content_type,
|
||||
"bytes": bytes.len(),
|
||||
"path": path.file_name().and_then(|name| name.to_str()).unwrap_or(""),
|
||||
});
|
||||
let _ = fs::write(
|
||||
screenshots.join(format!("{}.json", meta["sha256"].as_str().unwrap_or(""))),
|
||||
serde_json::to_vec_pretty(&meta)?,
|
||||
);
|
||||
Ok(EvidenceUploadResponse {
|
||||
ok: true,
|
||||
sha256: meta["sha256"].as_str().unwrap_or("").to_string(),
|
||||
content_type: content_type.to_string(),
|
||||
bytes: bytes.len() as u64,
|
||||
stored,
|
||||
path: path.display().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_evidence_screenshot(
|
||||
request: Request,
|
||||
args: &Cli,
|
||||
@@ -1373,7 +1526,17 @@ fn resolve_screenshot_file(
|
||||
root.join("screenshots")
|
||||
.join(format!("{expected_sha256}.png")),
|
||||
);
|
||||
candidates.push(
|
||||
root.join("screenshots")
|
||||
.join(format!("{expected_sha256}.jpg")),
|
||||
);
|
||||
candidates.push(
|
||||
root.join("screenshots")
|
||||
.join(format!("{expected_sha256}.jpeg")),
|
||||
);
|
||||
candidates.push(root.join(format!("{expected_sha256}.png")));
|
||||
candidates.push(root.join(format!("{expected_sha256}.jpg")));
|
||||
candidates.push(root.join(format!("{expected_sha256}.jpeg")));
|
||||
candidates.push(root.join(expected_sha256));
|
||||
if let Some(file_name) = source_file
|
||||
.as_deref()
|
||||
@@ -1523,6 +1686,96 @@ fn sha256_file(path: &Path) -> Result<String> {
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
fn sha256_bytes(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn evidence_image_type(
|
||||
bytes: &[u8],
|
||||
claimed_content_type: Option<&str>,
|
||||
) -> Result<(&'static str, &'static str)> {
|
||||
let detected = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
Some(("image/png", "png"))
|
||||
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
Some(("image/jpeg", "jpg"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some((content_type, extension)) = detected else {
|
||||
return Err(anyhow!("unsupported evidence image type"));
|
||||
};
|
||||
if let Some(claimed) = claimed_content_type.map(|value| value.trim().to_ascii_lowercase()) {
|
||||
let allowed = match content_type {
|
||||
"image/png" => claimed == "image/png" || claimed == "application/octet-stream",
|
||||
"image/jpeg" => {
|
||||
claimed == "image/jpeg"
|
||||
|| claimed == "image/jpg"
|
||||
|| claimed == "application/octet-stream"
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !allowed {
|
||||
return Err(anyhow!("claimed content_type does not match image bytes"));
|
||||
}
|
||||
}
|
||||
Ok((content_type, extension))
|
||||
}
|
||||
|
||||
fn ensure_evidence_root(args: &Cli) -> Result<PathBuf> {
|
||||
fs::create_dir_all(&args.evidence_root)
|
||||
.with_context(|| format!("create {}", args.evidence_root.display()))?;
|
||||
args.evidence_root
|
||||
.canonicalize()
|
||||
.with_context(|| format!("canonicalize {}", args.evidence_root.display()))
|
||||
}
|
||||
|
||||
fn upload_enabled(args: &Cli) -> bool {
|
||||
args.evidence_upload_token
|
||||
.as_deref()
|
||||
.map(|token| !token.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn upload_authorized(request: &Request, args: &Cli) -> bool {
|
||||
let Some(expected) = args
|
||||
.evidence_upload_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.is_empty())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(actual) = bearer_token(request) else {
|
||||
return false;
|
||||
};
|
||||
constant_time_eq(actual.as_bytes(), expected.as_bytes())
|
||||
}
|
||||
|
||||
fn bearer_token(request: &Request) -> Option<String> {
|
||||
request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|header| header.field.equiv("Authorization"))
|
||||
.map(|header| header.value.as_str().trim())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||
if left.len() != right.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0_u8;
|
||||
for (a, b) in left.iter().zip(right.iter()) {
|
||||
diff |= a ^ b;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
fn request_actor(request: &Request) -> String {
|
||||
for name in ["X-Remote-User", "X-Gateway-User", "Remote-User"] {
|
||||
if let Some(value) = request
|
||||
@@ -2027,6 +2280,7 @@ mod tests {
|
||||
evidence_max_bytes: 1024,
|
||||
json_smoke: false,
|
||||
evidence_only: false,
|
||||
evidence_upload_token: None,
|
||||
};
|
||||
let found = resolve_screenshot_file(&args, &None, &Some(digest.clone()))
|
||||
.unwrap()
|
||||
@@ -2044,4 +2298,27 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evidence_image_type_checks_magic_and_claim() {
|
||||
let png = b"\x89PNG\r\n\x1a\nrest";
|
||||
assert_eq!(
|
||||
evidence_image_type(png, Some("image/png")).unwrap(),
|
||||
("image/png", "png")
|
||||
);
|
||||
assert!(evidence_image_type(png, Some("image/jpeg")).is_err());
|
||||
let jpg = &[0xff, 0xd8, 0xff, 0xe0, 0x00];
|
||||
assert_eq!(
|
||||
evidence_image_type(jpg, Some("application/octet-stream")).unwrap(),
|
||||
("image/jpeg", "jpg")
|
||||
);
|
||||
assert!(evidence_image_type(b"plain text", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_time_eq_requires_same_bytes() {
|
||||
assert!(constant_time_eq(b"secret", b"secret"));
|
||||
assert!(!constant_time_eq(b"secret", b"other"));
|
||||
assert!(!constant_time_eq(b"secret", b"secret2"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user