feat(detmir): automate DLP evidence screenshot sync

This commit is contained in:
igor04091968
2026-06-03 01:14:42 +03:00
parent 122c4bbfc0
commit ec0a5fdd55
9 changed files with 710 additions and 0 deletions
+1
View File
@@ -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",
+1
View File
@@ -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" }
+19
View File
@@ -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
+1
View File
@@ -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
+277
View File
@@ -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"));
}
} }
+1
View File
@@ -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
+36
View File
@@ -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:
+146
View File
@@ -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
+228
View File
@@ -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 }