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"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ publish = false
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
adk-rust = { version = "0.9.1", default-features = false }
|
adk-rust = { version = "0.9.1", default-features = false }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
base64 = "0.22"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
detmir-aw-client = { path = "crates/detmir-aw-client" }
|
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
|
verified byte-identical screenshot serving and audit logging; local
|
||||||
Playwright smoke verified the `Доказательства` block, `СКРИН`, `Открыть`,
|
Playwright smoke verified the `Доказательства` block, `СКРИН`, `Открыть`,
|
||||||
`Скачать`, and zero JS errors.
|
`Скачать`, 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
|
- 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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::{SecondsFormat, Utc};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
@@ -101,6 +103,9 @@ struct Cli {
|
|||||||
|
|
||||||
#[arg(long, env = "DETMIR_PORTAL_EVIDENCE_ONLY")]
|
#[arg(long, env = "DETMIR_PORTAL_EVIDENCE_ONLY")]
|
||||||
evidence_only: bool,
|
evidence_only: bool,
|
||||||
|
|
||||||
|
#[arg(long, env = "DETMIR_PORTAL_EVIDENCE_UPLOAD_TOKEN")]
|
||||||
|
evidence_upload_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -218,6 +223,33 @@ struct EvidenceAuditEntry {
|
|||||||
source_file: Option<String>,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
struct DlpEvidenceResponse {
|
struct DlpEvidenceResponse {
|
||||||
ok: bool,
|
ok: bool,
|
||||||
@@ -429,6 +461,9 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> {
|
|||||||
fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> {
|
fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> {
|
||||||
let method = request.method().clone();
|
let method = request.method().clone();
|
||||||
let path = normalize_path(request.url());
|
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 {
|
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");
|
||||||
}
|
}
|
||||||
@@ -441,6 +476,7 @@ fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> {
|
|||||||
"mode": "evidence-only",
|
"mode": "evidence-only",
|
||||||
"db_available": args.dlp_db_path.exists(),
|
"db_available": args.dlp_db_path.exists(),
|
||||||
"screenshot_root_available": args.evidence_root.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(
|
fn handle_evidence_screenshot(
|
||||||
request: Request,
|
request: Request,
|
||||||
args: &Cli,
|
args: &Cli,
|
||||||
@@ -1373,7 +1526,17 @@ fn resolve_screenshot_file(
|
|||||||
root.join("screenshots")
|
root.join("screenshots")
|
||||||
.join(format!("{expected_sha256}.png")),
|
.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}.png")));
|
||||||
|
candidates.push(root.join(format!("{expected_sha256}.jpg")));
|
||||||
|
candidates.push(root.join(format!("{expected_sha256}.jpeg")));
|
||||||
candidates.push(root.join(expected_sha256));
|
candidates.push(root.join(expected_sha256));
|
||||||
if let Some(file_name) = source_file
|
if let Some(file_name) = source_file
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -1523,6 +1686,96 @@ fn sha256_file(path: &Path) -> Result<String> {
|
|||||||
Ok(format!("{:x}", hasher.finalize()))
|
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 {
|
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
|
||||||
@@ -2027,6 +2280,7 @@ mod tests {
|
|||||||
evidence_max_bytes: 1024,
|
evidence_max_bytes: 1024,
|
||||||
json_smoke: false,
|
json_smoke: false,
|
||||||
evidence_only: false,
|
evidence_only: false,
|
||||||
|
evidence_upload_token: None,
|
||||||
};
|
};
|
||||||
let found = resolve_screenshot_file(&args, &None, &Some(digest.clone()))
|
let found = resolve_screenshot_file(&args, &None, &Some(digest.clone()))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -2044,4 +2298,27 @@ mod tests {
|
|||||||
.is_none()
|
.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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,7 @@
|
|||||||
- export-evtx-for-hayabusa.ps1
|
- export-evtx-for-hayabusa.ps1
|
||||||
- export-upload-hayabusa-to-aw-server.ps1
|
- export-upload-hayabusa-to-aw-server.ps1
|
||||||
- export-upload-file-1c-telemetry.ps1
|
- export-upload-file-1c-telemetry.ps1
|
||||||
|
- sync-dlp-evidence-artifacts.ps1
|
||||||
- migrate-awatch-rus-paths.ps1
|
- migrate-awatch-rus-paths.ps1
|
||||||
- deploy-domain-users.ps1
|
- deploy-domain-users.ps1
|
||||||
- deploy-ensemble.ps1
|
- deploy-ensemble.ps1
|
||||||
|
|||||||
@@ -119,6 +119,7 @@
|
|||||||
aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}"
|
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_bind: "{{ detmir_evidence_bind_override | default('10.10.10.13:8721') }}"
|
||||||
detmir_evidence_env_path: "/etc/detmir-portal-evidence.env"
|
detmir_evidence_env_path: "/etc/detmir-portal-evidence.env"
|
||||||
|
detmir_evidence_upload_token_path: "/var/lib/activitywatch/dlp-evidence/upload-token"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- name: Check local detmir-portal binary for evidence service
|
- name: Check local detmir-portal binary for evidence service
|
||||||
@@ -152,6 +153,39 @@
|
|||||||
- /var/lib/activitywatch/dlp-evidence
|
- /var/lib/activitywatch/dlp-evidence
|
||||||
- /var/lib/activitywatch/dlp-evidence/screenshots
|
- /var/lib/activitywatch/dlp-evidence/screenshots
|
||||||
|
|
||||||
|
- name: Check detmir evidence upload token
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ detmir_evidence_upload_token_path }}"
|
||||||
|
register: detmir_evidence_upload_token_stat
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Generate detmir evidence upload token
|
||||||
|
ansible.builtin.shell: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
python3 - <<'PY' > '{{ detmir_evidence_upload_token_path }}'
|
||||||
|
import secrets
|
||||||
|
print(secrets.token_urlsafe(48))
|
||||||
|
PY
|
||||||
|
args:
|
||||||
|
executable: /bin/bash
|
||||||
|
when: not (detmir_evidence_upload_token_stat.stat.exists | default(false))
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Fix detmir evidence upload token permissions
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ detmir_evidence_upload_token_path }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Read detmir evidence upload token
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: "{{ detmir_evidence_upload_token_path }}"
|
||||||
|
register: detmir_evidence_upload_token_slurp
|
||||||
|
no_log: true
|
||||||
|
|
||||||
- name: Install detmir evidence API environment
|
- name: Install detmir evidence API environment
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: "{{ detmir_evidence_env_path }}"
|
dest: "{{ detmir_evidence_env_path }}"
|
||||||
@@ -166,6 +200,8 @@
|
|||||||
DETMIR_PORTAL_EVIDENCE_ROOT=/var/lib/activitywatch/dlp-evidence
|
DETMIR_PORTAL_EVIDENCE_ROOT=/var/lib/activitywatch/dlp-evidence
|
||||||
DETMIR_PORTAL_EVIDENCE_LIMIT=30
|
DETMIR_PORTAL_EVIDENCE_LIMIT=30
|
||||||
DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608
|
DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608
|
||||||
|
DETMIR_PORTAL_EVIDENCE_UPLOAD_TOKEN={{ detmir_evidence_upload_token_slurp.content | b64decode | trim }}
|
||||||
|
no_log: true
|
||||||
|
|
||||||
- name: Install detmir evidence API systemd service
|
- name: Install detmir evidence API systemd service
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
- name: Deploy DLP evidence upload sync on Windows endpoints
|
||||||
|
hosts: aw_windows
|
||||||
|
gather_facts: false
|
||||||
|
|
||||||
|
vars:
|
||||||
|
aw_repo_root: "{{ playbook_dir | dirname }}"
|
||||||
|
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
|
||||||
|
aw_windows_deploy_root: "C:\\Program Files\\AWatch-rus"
|
||||||
|
aw_windows_evidence_sync_task_name: "ActivityWatch DLP Evidence Sync"
|
||||||
|
aw_windows_evidence_sync_interval_minutes: 5
|
||||||
|
aw_windows_evidence_sync_api_url: "http://10.10.10.13:8721/api/dlp/evidence/upload"
|
||||||
|
aw_windows_evidence_sync_script: "{{ aw_windows_state_root }}\\sync-dlp-evidence-artifacts.ps1"
|
||||||
|
aw_windows_evidence_sync_token_path: "{{ aw_windows_state_root }}\\dlp-evidence-upload-token.txt"
|
||||||
|
aw_windows_evidence_sync_state_path: "{{ aw_windows_state_root }}\\dlp-evidence-sync-state.json"
|
||||||
|
aw_windows_evidence_sync_log_path: "{{ aw_windows_state_root }}\\logs\\dlp-evidence-sync.log"
|
||||||
|
detmir_evidence_upload_token_path: "/var/lib/activitywatch/dlp-evidence/upload-token"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Read evidence upload token from AW server
|
||||||
|
ansible.builtin.slurp:
|
||||||
|
src: "{{ detmir_evidence_upload_token_path }}"
|
||||||
|
delegate_to: "{{ (groups['aw_server'] | first) }}"
|
||||||
|
become: true
|
||||||
|
register: detmir_evidence_upload_token_slurp
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Ensure AWatch-rus state and log directories
|
||||||
|
ansible.windows.win_file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
loop:
|
||||||
|
- "{{ aw_windows_state_root }}"
|
||||||
|
- "{{ aw_windows_state_root }}\\logs"
|
||||||
|
|
||||||
|
- name: Install DLP evidence sync script
|
||||||
|
ansible.windows.win_copy:
|
||||||
|
src: "{{ aw_repo_root }}/windows/sync-dlp-evidence-artifacts.ps1"
|
||||||
|
dest: "{{ aw_windows_evidence_sync_script }}"
|
||||||
|
|
||||||
|
- name: Normalize DLP evidence sync script encoding
|
||||||
|
ansible.windows.win_powershell:
|
||||||
|
script: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$path = "{{ aw_windows_evidence_sync_script }}"
|
||||||
|
$text = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
|
||||||
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
[System.IO.File]::WriteAllText($path, $text, $utf8Bom)
|
||||||
|
|
||||||
|
- name: Prepare existing DLP evidence upload token ACL for update
|
||||||
|
ansible.windows.win_powershell:
|
||||||
|
script: |
|
||||||
|
$path = "{{ aw_windows_evidence_sync_token_path }}"
|
||||||
|
if (Test-Path -LiteralPath $path) {
|
||||||
|
icacls.exe $path /grant:r "*S-1-5-18:(F)" "*S-1-5-32-544:(F)" | Out-Null
|
||||||
|
}
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Install DLP evidence upload token
|
||||||
|
ansible.windows.win_copy:
|
||||||
|
dest: "{{ aw_windows_evidence_sync_token_path }}"
|
||||||
|
content: "{{ detmir_evidence_upload_token_slurp.content | b64decode | trim }}"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Lock down DLP evidence upload token ACL
|
||||||
|
ansible.windows.win_powershell:
|
||||||
|
script: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$path = "{{ aw_windows_evidence_sync_token_path }}"
|
||||||
|
icacls.exe $path /inheritance:r | Out-Null
|
||||||
|
icacls.exe $path /grant:r "*S-1-5-18:(F)" "*S-1-5-32-544:(F)" | Out-Null
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Register DLP evidence sync scheduled task
|
||||||
|
ansible.windows.win_powershell:
|
||||||
|
script: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$taskName = "{{ aw_windows_evidence_sync_task_name }}"
|
||||||
|
$ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||||
|
$args = @(
|
||||||
|
'-NoProfile',
|
||||||
|
'-ExecutionPolicy', 'Bypass',
|
||||||
|
'-File', '"{{ aw_windows_evidence_sync_script }}"',
|
||||||
|
'-EvidenceApiUrl', '"{{ aw_windows_evidence_sync_api_url }}"',
|
||||||
|
'-TokenPath', '"{{ aw_windows_evidence_sync_token_path }}"',
|
||||||
|
'-StatePath', '"{{ aw_windows_evidence_sync_state_path }}"',
|
||||||
|
'-LogPath', '"{{ aw_windows_evidence_sync_log_path }}"'
|
||||||
|
) -join ' '
|
||||||
|
$action = New-ScheduledTaskAction -Execute $ps -Argument $args
|
||||||
|
$trigger = New-ScheduledTaskTrigger `
|
||||||
|
-Once `
|
||||||
|
-At ((Get-Date).AddMinutes(1)) `
|
||||||
|
-RepetitionInterval (New-TimeSpan -Minutes {{ aw_windows_evidence_sync_interval_minutes | int }}) `
|
||||||
|
-RepetitionDuration (New-TimeSpan -Days 3650)
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||||
|
$settings = New-ScheduledTaskSettingsSet `
|
||||||
|
-AllowStartIfOnBatteries `
|
||||||
|
-StartWhenAvailable `
|
||||||
|
-MultipleInstances IgnoreNew `
|
||||||
|
-ExecutionTimeLimit (New-TimeSpan -Minutes 10)
|
||||||
|
$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
if ($existing) {
|
||||||
|
Set-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null
|
||||||
|
} else {
|
||||||
|
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null
|
||||||
|
}
|
||||||
|
Enable-ScheduledTask -TaskName $taskName | Out-Null
|
||||||
|
Start-ScheduledTask -TaskName $taskName
|
||||||
|
[pscustomobject]@{ taskName = $taskName; started = $true }
|
||||||
|
|
||||||
|
- name: Smoke-run DLP evidence sync once
|
||||||
|
ansible.windows.win_powershell:
|
||||||
|
script: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$result = & "{{ aw_windows_evidence_sync_script }}" `
|
||||||
|
-EvidenceApiUrl "{{ aw_windows_evidence_sync_api_url }}" `
|
||||||
|
-TokenPath "{{ aw_windows_evidence_sync_token_path }}" `
|
||||||
|
-StatePath "{{ aw_windows_evidence_sync_state_path }}" `
|
||||||
|
-LogPath "{{ aw_windows_evidence_sync_log_path }}"
|
||||||
|
$result
|
||||||
|
register: aw_windows_evidence_sync_smoke
|
||||||
|
|
||||||
|
- name: Verify DLP evidence sync smoke result
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- aw_windows_evidence_sync_smoke.output is defined
|
||||||
|
- aw_windows_evidence_sync_smoke.output | length > 0
|
||||||
|
fail_msg: "DLP evidence sync smoke did not return output."
|
||||||
|
|
||||||
|
- name: Read DLP evidence sync task status
|
||||||
|
ansible.windows.win_powershell:
|
||||||
|
script: |
|
||||||
|
$task = Get-ScheduledTask -TaskName "{{ aw_windows_evidence_sync_task_name }}" -ErrorAction Stop
|
||||||
|
$info = Get-ScheduledTaskInfo -TaskName "{{ aw_windows_evidence_sync_task_name }}" -ErrorAction Stop
|
||||||
|
[pscustomobject]@{
|
||||||
|
taskName = $task.TaskName
|
||||||
|
state = [string]$task.State
|
||||||
|
lastRunTime = $info.LastRunTime
|
||||||
|
lastTaskResult = $info.LastTaskResult
|
||||||
|
nextRunTime = $info.NextRunTime
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
register: aw_windows_evidence_sync_task_status
|
||||||
|
|
||||||
|
- name: Show DLP evidence sync task status
|
||||||
|
ansible.builtin.debug:
|
||||||
|
var: aw_windows_evidence_sync_task_status.output
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
param(
|
||||||
|
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
|
||||||
|
[string]$EvidenceApiUrl = 'http://10.10.10.13:8721/api/dlp/evidence/upload',
|
||||||
|
[string]$TokenPath = 'C:\ProgramData\AWatch-rus\dlp-evidence-upload-token.txt',
|
||||||
|
[string]$StatePath = 'C:\ProgramData\AWatch-rus\dlp-evidence-sync-state.json',
|
||||||
|
[string]$LogPath = 'C:\ProgramData\AWatch-rus\logs\dlp-evidence-sync.log',
|
||||||
|
[int]$MaxFiles = 200,
|
||||||
|
[int]$MaxBytes = 8388608,
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Ensure-Directory {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$Path)
|
||||||
|
$dir = if ([System.IO.Path]::HasExtension($Path)) { Split-Path -Parent $Path } else { $Path }
|
||||||
|
if ($dir -and -not (Test-Path -LiteralPath $dir)) {
|
||||||
|
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-SyncLog {
|
||||||
|
param([string]$Message)
|
||||||
|
Ensure-Directory -Path $LogPath
|
||||||
|
$line = "{0} {1}" -f ([DateTime]::UtcNow.ToString('o')), $Message
|
||||||
|
Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-JsonFile {
|
||||||
|
param([string]$Path, [object]$Default)
|
||||||
|
if (-not (Test-Path -LiteralPath $Path)) { return $Default }
|
||||||
|
try {
|
||||||
|
return Get-Content -Raw -LiteralPath $Path -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-SyncLog ("state parse failed: {0}" -f $_.Exception.Message)
|
||||||
|
return $Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-JsonFile {
|
||||||
|
param([string]$Path, [object]$Value)
|
||||||
|
Ensure-Directory -Path $Path
|
||||||
|
$tmp = "$Path.tmp"
|
||||||
|
$Value | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $tmp -Encoding UTF8
|
||||||
|
Move-Item -LiteralPath $tmp -Destination $Path -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-FileSha256Hex {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$Path)
|
||||||
|
return ((Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant())
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Config {
|
||||||
|
if (-not (Test-Path -LiteralPath $ConfigPath)) { return $null }
|
||||||
|
try {
|
||||||
|
return Get-Content -Raw -LiteralPath $ConfigPath -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-SyncLog ("config parse failed: {0}" -f $_.Exception.Message)
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-RootIfExists {
|
||||||
|
param(
|
||||||
|
[System.Collections.Generic.List[string]]$Roots,
|
||||||
|
[string]$Path
|
||||||
|
)
|
||||||
|
if ($Path -and (Test-Path -LiteralPath $Path)) {
|
||||||
|
$full = [System.IO.Path]::GetFullPath($Path)
|
||||||
|
if (-not $Roots.Contains($full)) {
|
||||||
|
$Roots.Add($full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ArtifactRoots {
|
||||||
|
$roots = [System.Collections.Generic.List[string]]::new()
|
||||||
|
$config = Get-Config
|
||||||
|
if ($config -and $config.PSObject.Properties.Name -contains 'incidentCapture' -and
|
||||||
|
$config.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') {
|
||||||
|
Add-RootIfExists -Roots $roots -Path ([string]$config.incidentCapture.artifactsRoot)
|
||||||
|
}
|
||||||
|
if ($config -and $config.PSObject.Properties.Name -contains 'paths' -and
|
||||||
|
$config.paths.PSObject.Properties.Name -contains 'stateRoot') {
|
||||||
|
Add-RootIfExists -Roots $roots -Path (Join-Path ([string]$config.paths.stateRoot) 'incident-artifacts')
|
||||||
|
}
|
||||||
|
Add-RootIfExists -Roots $roots -Path 'C:\ProgramData\AWatch-rus\incident-artifacts'
|
||||||
|
Get-ChildItem -LiteralPath 'C:\Users' -Directory -ErrorAction SilentlyContinue | ForEach-Object {
|
||||||
|
Add-RootIfExists -Roots $roots -Path (Join-Path $_.FullName 'AppData\Local\AWatch-rus\incident-artifacts')
|
||||||
|
}
|
||||||
|
return @($roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-StateMap {
|
||||||
|
$state = Get-JsonFile -Path $StatePath -Default ([pscustomobject]@{ uploaded = @{} })
|
||||||
|
if (-not ($state.PSObject.Properties.Name -contains 'uploaded') -or -not $state.uploaded) {
|
||||||
|
$state | Add-Member -NotePropertyName uploaded -NotePropertyValue ([pscustomobject]@{}) -Force
|
||||||
|
}
|
||||||
|
return $state
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-AlreadyUploaded {
|
||||||
|
param(
|
||||||
|
[object]$State,
|
||||||
|
[string]$Sha256,
|
||||||
|
[System.IO.FileInfo]$File
|
||||||
|
)
|
||||||
|
if (-not ($State.uploaded.PSObject.Properties.Name -contains $Sha256)) { return $false }
|
||||||
|
$entry = $State.uploaded.$Sha256
|
||||||
|
return (
|
||||||
|
[string]$entry.path -eq $File.FullName -and
|
||||||
|
[int64]$entry.length -eq [int64]$File.Length -and
|
||||||
|
[string]$entry.lastWriteUtc -eq $File.LastWriteTimeUtc.ToString('o')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-UploadedState {
|
||||||
|
param(
|
||||||
|
[object]$State,
|
||||||
|
[string]$Sha256,
|
||||||
|
[System.IO.FileInfo]$File,
|
||||||
|
[object]$Response
|
||||||
|
)
|
||||||
|
$entry = [pscustomobject]@{
|
||||||
|
path = $File.FullName
|
||||||
|
length = [int64]$File.Length
|
||||||
|
lastWriteUtc = $File.LastWriteTimeUtc.ToString('o')
|
||||||
|
uploadedAtUtc = [DateTime]::UtcNow.ToString('o')
|
||||||
|
responseStored = [bool]$Response.stored
|
||||||
|
}
|
||||||
|
$State.uploaded | Add-Member -NotePropertyName $Sha256 -NotePropertyValue $entry -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-EvidenceUpload {
|
||||||
|
param(
|
||||||
|
[System.IO.FileInfo]$File,
|
||||||
|
[string]$Sha256,
|
||||||
|
[string]$Token
|
||||||
|
)
|
||||||
|
$bytes = [System.IO.File]::ReadAllBytes($File.FullName)
|
||||||
|
$payload = [pscustomobject]@{
|
||||||
|
sha256 = $Sha256
|
||||||
|
content_base64 = [Convert]::ToBase64String($bytes)
|
||||||
|
content_type = 'image/png'
|
||||||
|
source_file = $File.Name
|
||||||
|
source_path = $File.FullName
|
||||||
|
hostname = $env:COMPUTERNAME
|
||||||
|
username = $env:USERNAME
|
||||||
|
}
|
||||||
|
if ($DryRun) {
|
||||||
|
return [pscustomobject]@{ ok = $true; stored = $false; dryRun = $true }
|
||||||
|
}
|
||||||
|
return Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri $EvidenceApiUrl `
|
||||||
|
-Headers @{ Authorization = "Bearer $Token" } `
|
||||||
|
-ContentType 'application/json; charset=utf-8' `
|
||||||
|
-Body ($payload | ConvertTo-Json -Depth 5 -Compress) `
|
||||||
|
-TimeoutSec 30
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [ordered]@{
|
||||||
|
ok = $true
|
||||||
|
dryRun = [bool]$DryRun
|
||||||
|
roots = @()
|
||||||
|
scanned = 0
|
||||||
|
uploaded = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
errors = @()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$token = ''
|
||||||
|
if (Test-Path -LiteralPath $TokenPath) {
|
||||||
|
$token = (Get-Content -Raw -LiteralPath $TokenPath -Encoding UTF8).Trim()
|
||||||
|
}
|
||||||
|
if (-not $token) {
|
||||||
|
throw "upload token is missing: $TokenPath"
|
||||||
|
}
|
||||||
|
$roots = @(Get-ArtifactRoots)
|
||||||
|
$result.roots = $roots
|
||||||
|
$state = Get-StateMap
|
||||||
|
$files = @()
|
||||||
|
foreach ($root in $roots) {
|
||||||
|
$files += @(Get-ChildItem -LiteralPath $root -Filter '*.png' -File -Recurse -ErrorAction SilentlyContinue)
|
||||||
|
}
|
||||||
|
$files = @($files | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First $MaxFiles)
|
||||||
|
foreach ($file in $files) {
|
||||||
|
try {
|
||||||
|
$result.scanned++
|
||||||
|
if ($file.Length -le 0 -or $file.Length -gt $MaxBytes) {
|
||||||
|
$result.skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$sha = Get-FileSha256Hex -Path $file.FullName
|
||||||
|
if (Test-AlreadyUploaded -State $state -Sha256 $sha -File $file) {
|
||||||
|
$result.skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$response = Invoke-EvidenceUpload -File $file -Sha256 $sha -Token $token
|
||||||
|
if (-not $response.ok) {
|
||||||
|
throw "upload response is not ok"
|
||||||
|
}
|
||||||
|
Set-UploadedState -State $state -Sha256 $sha -File $file -Response $response
|
||||||
|
$result.uploaded++
|
||||||
|
Write-SyncLog ("uploaded evidence sha={0} file={1}" -f $sha, $file.FullName)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$result.failed++
|
||||||
|
$result.errors += ("{0}: {1}" -f $file.FullName, $_.Exception.Message)
|
||||||
|
Write-SyncLog ("upload failed file={0}: {1}" -f $file.FullName, $_.Exception.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Save-JsonFile -Path $StatePath -Value $state
|
||||||
|
if ($result.failed -gt 0) { $result.ok = $false }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$result.ok = $false
|
||||||
|
$result.failed++
|
||||||
|
$result.errors += $_.Exception.Message
|
||||||
|
Write-SyncLog ("sync failed: {0}" -f $_.Exception.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
$result | ConvertTo-Json -Depth 6
|
||||||
|
if (-not $result.ok) { exit 1 }
|
||||||
Reference in New Issue
Block a user