diff --git a/adk-rust/Cargo.lock b/adk-rust/Cargo.lock index 5d49292..819a4e4 100644 --- a/adk-rust/Cargo.lock +++ b/adk-rust/Cargo.lock @@ -577,6 +577,7 @@ name = "detmir-portal" version = "0.1.0" dependencies = [ "anyhow", + "base64", "chrono", "clap", "reqwest", diff --git a/adk-rust/Cargo.toml b/adk-rust/Cargo.toml index ee0feee..1c193f6 100644 --- a/adk-rust/Cargo.toml +++ b/adk-rust/Cargo.toml @@ -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" } diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md index 6d4d59b..6d843a3 100644 --- a/adk-rust/RUNBOOK.md +++ b/adk-rust/RUNBOOK.md @@ -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/.(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 diff --git a/adk-rust/crates/detmir-portal/Cargo.toml b/adk-rust/crates/detmir-portal/Cargo.toml index 10b634a..2b2080c 100644 --- a/adk-rust/crates/detmir-portal/Cargo.toml +++ b/adk-rust/crates/detmir-portal/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true [dependencies] anyhow.workspace = true +base64.workspace = true chrono.workspace = true clap.workspace = true reqwest.workspace = true diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index b836f92..84ac478 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -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, } #[derive(Debug, Serialize)] @@ -218,6 +223,33 @@ struct EvidenceAuditEntry { source_file: Option, } +#[derive(Debug, Deserialize)] +struct EvidenceUploadRequest { + sha256: String, + #[serde(default)] + content_base64: String, + #[serde(default)] + content_type: Option, + #[serde(default)] + source_file: Option, + #[serde(default)] + source_path: Option, + #[serde(default)] + hostname: Option, + #[serde(default)] + username: Option, +} + +#[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 { + 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 { 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 { + 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 { + 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")); + } } diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index e71a3a0..4a1734c 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -181,6 +181,7 @@ - export-evtx-for-hayabusa.ps1 - export-upload-hayabusa-to-aw-server.ps1 - export-upload-file-1c-telemetry.ps1 + - sync-dlp-evidence-artifacts.ps1 - migrate-awatch-rus-paths.ps1 - deploy-domain-users.ps1 - deploy-ensemble.ps1 diff --git a/ansible/deploy_detmir_portal.yml b/ansible/deploy_detmir_portal.yml index eb5ba67..f7abf01 100644 --- a/ansible/deploy_detmir_portal.yml +++ b/ansible/deploy_detmir_portal.yml @@ -119,6 +119,7 @@ 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" + detmir_evidence_upload_token_path: "/var/lib/activitywatch/dlp-evidence/upload-token" tasks: - name: Check local detmir-portal binary for evidence service @@ -152,6 +153,39 @@ - /var/lib/activitywatch/dlp-evidence - /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 ansible.builtin.copy: dest: "{{ detmir_evidence_env_path }}" @@ -166,6 +200,8 @@ DETMIR_PORTAL_EVIDENCE_ROOT=/var/lib/activitywatch/dlp-evidence DETMIR_PORTAL_EVIDENCE_LIMIT=30 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 ansible.builtin.copy: diff --git a/ansible/deploy_dlp_evidence_sync.yml b/ansible/deploy_dlp_evidence_sync.yml new file mode 100644 index 0000000..947d63f --- /dev/null +++ b/ansible/deploy_dlp_evidence_sync.yml @@ -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 diff --git a/windows/sync-dlp-evidence-artifacts.ps1 b/windows/sync-dlp-evidence-artifacts.ps1 new file mode 100644 index 0000000..ac903b6 --- /dev/null +++ b/windows/sync-dlp-evidence-artifacts.ps1 @@ -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 }