From fe87c85a3128c06ae84ca0034deb3606ab787825 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Wed, 1 Jul 2026 00:00:29 +0300 Subject: [PATCH] Harden DetMir DLP production runtime - default DetMir DLP runtime to core_only/disabled with load-guard protection - add fail-closed placeholder validation and runtime-scoped artifact checks - document operator re-enable flow for light profile and guard rollback - update prod docs, env examples, and Ansible DLP defaults --- adk-rust/Cargo.lock | 35 +- adk-rust/crates/aw-rus-healthd/src/main.rs | 86 +- adk-rust/crates/detmir-portal/Cargo.toml | 1 + adk-rust/crates/detmir-portal/src/main.rs | 1191 ++++++++++++++++- .../detmir-portal/src/production/limits.rs | 80 ++ .../detmir-portal/src/production/metrics.rs | 50 + .../detmir-portal/src/production/mod.rs | 3 +- .../detmir-portal/src/snapshot_cache.rs | 74 +- .../src/workforce_kpi_explain.rs | 1 + adk-rust/crates/detmir-readiness/src/main.rs | 53 +- ansible/deploy_aw_server.yml | 352 ++++- ansible/deploy_detmir_portal.yml | 76 ++ ansible/group_vars/all.yml | 42 +- aw-server/aw-server.env.example | 29 +- docs/DETMIR_CURRENT_STATE_RU.md | 98 +- docs/DLP_OPTIONAL_RUNTIME_RU.md | 76 +- docs/DLP_RESOURCE_PROFILES_RU.md | 196 +++ docs/PRODUCTION_READINESS_RU.md | 17 + docs/PROJECT_STATUS_RU.md | 39 +- .../check_detmir_rust_release_artifacts.sh | 46 +- scripts/detmir_dlp_load_guard.sh | 260 ++++ scripts/detmir_dlp_runtime_control.sh | 269 ++++ scripts/detmir_dlp_warehouse_sync.sh | 73 + scripts/diag_and_manual_restart.sh | 66 +- scripts/rdp-worktime-report.sh | 26 +- scripts/run_awatch_contour_check.sh | 34 +- 26 files changed, 3053 insertions(+), 220 deletions(-) create mode 100644 docs/DLP_RESOURCE_PROFILES_RU.md create mode 100644 scripts/detmir_dlp_load_guard.sh create mode 100644 scripts/detmir_dlp_runtime_control.sh create mode 100644 scripts/detmir_dlp_warehouse_sync.sh diff --git a/adk-rust/Cargo.lock b/adk-rust/Cargo.lock index a41e1f9..51c9b7c 100644 --- a/adk-rust/Cargo.lock +++ b/adk-rust/Cargo.lock @@ -122,9 +122,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" @@ -561,6 +561,18 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "containment-engine" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -698,6 +710,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "serde_json", ] [[package]] @@ -739,6 +752,7 @@ dependencies = [ "sha2", "tempfile", "tiny_http", + "url", ] [[package]] @@ -1255,8 +1269,10 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", "tempfile", "urlencoding", + "zip 2.4.2", ] [[package]] @@ -2120,6 +2136,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "security-finding-inbox" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "hayabusa-tools", + "reqwest", + "serde", + "serde_json", + "sha2", + "tempfile", +] + [[package]] name = "semver" version = "1.0.28" diff --git a/adk-rust/crates/aw-rus-healthd/src/main.rs b/adk-rust/crates/aw-rus-healthd/src/main.rs index d12ad63..e8f02f3 100644 --- a/adk-rust/crates/aw-rus-healthd/src/main.rs +++ b/adk-rust/crates/aw-rus-healthd/src/main.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow}; use chrono::{DateTime, Duration as ChronoDuration, SecondsFormat, Utc}; use clap::Parser; use detmir_core::{exit_codes, parse_utc_rfc3339}; @@ -25,10 +25,10 @@ struct Cli { #[arg(long, default_value = "http://127.0.0.1:5610")] worktime_api: String, - #[arg(long, default_value = "198.51.100.18")] + #[arg(long, default_value = "")] rdp_host: String, - #[arg(long, default_value = "HOST-EXAMPLE")] + #[arg(long, default_value = "")] rdp_hostname: String, #[arg(long, default_value = "/var/lib/activitywatch/health")] @@ -61,6 +61,9 @@ struct Cli { #[arg(long, default_value_t = 3.0)] tcp_timeout_seconds: f64, + #[arg(long, default_value_t = true)] + rdp_tcp_required: bool, + #[arg(long)] json: bool, } @@ -129,6 +132,10 @@ impl Cli { self.tcp_timeout_seconds, ); } + if !cli_arg_present("--rdp-tcp-required") { + self.rdp_tcp_required = + env_bool_default("AW_RUS_HEALTH_RDP_TCP_REQUIRED", self.rdp_tcp_required); + } self } } @@ -221,9 +228,42 @@ fn env_f64(name: &str, fallback: f64) -> f64 { } fn env_bool(name: &str) -> bool { + env_bool_default(name, false) +} + +fn env_bool_default(name: &str, fallback: bool) -> bool { env_string(name) - .map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes")) - .unwrap_or(false) + .map(|value| match value.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => true, + "0" | "false" | "no" | "off" => false, + _ => fallback, + }) + .unwrap_or(fallback) +} + +fn validate_cli_config(cli: &Cli) -> Result<()> { + validate_prod_host("rdp_host", &cli.rdp_host)?; + validate_prod_host("rdp_hostname", &cli.rdp_hostname)?; + Ok(()) +} + +fn validate_prod_host(name: &str, value: &str) -> Result<()> { + let value = value.trim(); + if value.is_empty() { + return Err(anyhow!("invalid config {name}: value is empty")); + } + let lowered = value.to_ascii_lowercase(); + if lowered == "host-example" + || lowered.ends_with(".example") + || lowered.starts_with("192.0.2.") + || lowered.starts_with("198.51.100.") + || lowered.starts_with("203.0.113.") + { + return Err(anyhow!( + "invalid config {name}: placeholder/documentation host is not allowed" + )); + } + Ok(()) } fn load_env_file(path: &Path) { @@ -681,6 +721,16 @@ fn normalize_aw_api_base(aw_server: &str) -> String { } } +fn tcp_check_status(ok: bool, required: bool) -> &'static str { + if ok { + "ok" + } else if required { + "fail" + } else { + "warn" + } +} + fn validation_check(report: &mut ReportBuilder, validation_dir: &Path, max_age_seconds: i64) { let Some(path) = latest_validation_report(validation_dir) else { report.add( @@ -812,15 +862,18 @@ fn run(cli: &Cli) -> Result { for (port, label) in [(5985_u16, "winrm"), (3389_u16, "rdp")] { let (ok, message) = tcp_connect(&cli.rdp_host, port, cli.tcp_timeout_seconds); + let status = tcp_check_status(ok, cli.rdp_tcp_required); report.add( format!("tcp:{label}"), - if ok { "ok" } else { "fail" }, + status, if ok { message - } else { + } else if cli.rdp_tcp_required { format!("unreachable: {message}") + } else { + format!("optional unreachable: {message}") }, - json!({"host": cli.rdp_host, "port": port}), + json!({"host": cli.rdp_host, "port": port, "required": cli.rdp_tcp_required}), ); } @@ -949,6 +1002,7 @@ fn run(cli: &Cli) -> Result { fn main() -> Result<()> { let cli = Cli::parse().apply_env(); + validate_cli_config(&cli)?; let report = run(&cli)?; let json_text = serde_json::to_string_pretty(&report)? + "\n"; let text = render_text(&report) + "\n"; @@ -1029,4 +1083,20 @@ mod tests { "http://127.0.0.1:5600/api/0" ); } + + #[test] + fn optional_rdp_tcp_downgrades_unreachable_to_warn() { + assert_eq!(tcp_check_status(false, true), "fail"); + assert_eq!(tcp_check_status(false, false), "warn"); + assert_eq!(tcp_check_status(true, false), "ok"); + } + + #[test] + fn healthd_rejects_placeholder_hosts() { + assert!(validate_prod_host("rdp_host", "192.168.100.19").is_ok()); + assert!(validate_prod_host("rdp_hostname", "SHARKON2025").is_ok()); + assert!(validate_prod_host("rdp_host", "198.51.100.18").is_err()); + assert!(validate_prod_host("rdp_hostname", "HOST-EXAMPLE").is_err()); + assert!(validate_prod_host("rdp_host", "").is_err()); + } } diff --git a/adk-rust/crates/detmir-portal/Cargo.toml b/adk-rust/crates/detmir-portal/Cargo.toml index 44e9710..042d50a 100644 --- a/adk-rust/crates/detmir-portal/Cargo.toml +++ b/adk-rust/crates/detmir-portal/Cargo.toml @@ -18,6 +18,7 @@ serde_json.workspace = true serde_yaml.workspace = true sha2.workspace = true tiny_http.workspace = true +url.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 3dca6bf..b280adf 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -1,8 +1,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Write}; +use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -52,8 +53,9 @@ use path_query::{ use portal_roles::PortalRole; use production::{ build_healthz, build_readyz, build_version, is_limited_api_route, mark_request_started, - record_report_generated, render_prometheus_metrics, validate_api_query_limits, - validate_portal_config, + record_report_cache_hit, record_report_cache_miss, record_report_cache_stale_hit, + record_report_generated, record_report_request, render_prometheus_metrics, + validate_api_query_limits, validate_portal_config, }; use readiness_api::{readiness_bundle, readiness_latest, readiness_verify}; use risk_narrative::{ @@ -61,7 +63,8 @@ use risk_narrative::{ }; use role_access::{portal_role_from_request, respond_forbidden, role_envelope}; use snapshot_cache::{ - SnapshotCache, build_fast_health, cached_snapshot, clone_snapshot_cache, new_snapshot_cache, + SnapshotCache, build_fast_health, cached_snapshot, cached_snapshot_or_refresh, + clone_snapshot_cache, new_snapshot_cache, }; use static_assets::{ API_CONTRACT_OPENAPI, API_CONTRACT_TYPESCRIPT, APP_CSS, APP_JS, ARCHITECTURE_HTML, INDEX_HTML, @@ -84,6 +87,9 @@ const DEFAULT_MAX_REPORT_DATE_RANGE_DAYS: i64 = 31; const DEFAULT_REQUEST_TIMEOUT_SECONDS: u64 = 20; const DEFAULT_MAX_REQUEST_BODY_BYTES: u64 = 16 * 1024 * 1024; const DEFAULT_SLOW_REQUEST_LOG_MS: u64 = 1_000; +const TELEMETRY_TAIL_MAX_BYTES: u64 = 8 * 1024 * 1024; +const TELEMETRY_TAIL_MAX_LINES: usize = 5_000; +const REPORT_CACHE_TTL: Duration = Duration::from_secs(120); const MAX_ALLOWED_PAGE_SIZE: u32 = 5_000; const MAX_ALLOWED_REPORT_DATE_RANGE_DAYS: i64 = 366; const MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS: u64 = 120; @@ -125,18 +131,10 @@ struct Cli { )] failed_units_cmd: String, - #[arg( - long, - default_value = "http://192.0.2.13:5610", - env = "DETMIR_PORTAL_WORKTIME_URL" - )] + #[arg(long, default_value = "", env = "DETMIR_PORTAL_WORKTIME_URL")] worktime_url: String, - #[arg( - long, - default_value = "http://192.0.2.2:8710", - env = "DETMIR_PORTAL_ONE_C_URL" - )] + #[arg(long, default_value = "", env = "DETMIR_PORTAL_ONE_C_URL")] one_c_url: String, #[arg( @@ -208,6 +206,9 @@ struct Cli { )] enabled_modules: String, + #[arg(long, default_value_t = true, env = "DETMIR_PORTAL_DLP_MODULE_ENABLED")] + dlp_module_enabled: bool, + #[arg( long, default_value = "/var/lib/detmir-portal", @@ -338,6 +339,61 @@ struct SecurityEventsSummary { error: Option, } +#[derive(Clone, Debug, Serialize)] +struct SecurityFindingInbox { + status: String, + backend: String, + open_count: u64, + critical_count: u64, + high_count: u64, + contained_count: u64, + query_ms: u128, + fallback_used: bool, + items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Clone, Debug, Serialize)] +struct SecurityFindingInboxItem { + finding_id: String, + first_seen_utc: String, + last_seen_utc: String, + host: String, + user: String, + ip: String, + department: String, + state: String, + severity: String, + confidence: String, + score: u64, + source: String, + rule_id: String, + rule_title: String, + summary: String, + recommended_action: String, + workflow_status: String, + last_workflow_event: String, + workflow_updated_at_utc: String, + workflow_actor: String, + decision_status: String, + rollback_plan_id: String, + plan_id: String, + management_channel_checked: bool, + evidence_ref: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SecurityFindingWorkflowRequest { + finding_id: String, + action: String, + comment: Option, + decision_status: Option, + rollback_plan_id: Option, + plan_id: Option, +} + impl SecurityEventsSummary { fn disabled() -> Self { Self { @@ -1075,6 +1131,7 @@ struct PortalLinks { #[derive(Clone, Debug)] struct Snapshot { generated_at_utc: String, + dlp_module_enabled: bool, detmir_status: SourceStatus, detmir_check: SourceStatus, failed_units: SourceStatus, @@ -1091,6 +1148,21 @@ struct Snapshot { security_events_summary: SecurityEventsSummary, } +type ReportCache = Arc>; + +#[derive(Clone, Debug, Default)] +struct ReportCacheState { + entry: Option, + refresh_in_progress: bool, +} + +#[derive(Clone, Debug)] +struct CachedReport { + created: Instant, + anonymize: bool, + report: Value, +} + #[derive(Debug)] struct ReportMetrics { users_count: usize, @@ -1306,10 +1378,10 @@ fn run() -> Result { validate_portal_config(&args)?; if args.json_smoke { let snapshot = build_snapshot(&args); - let incident_state = load_incident_state_best_effort(&args); - let incident_reviews = load_incident_review_best_effort(&args); - let incident_review_audit = load_incident_review_audit_best_effort(&args); - let cases = load_cases_best_effort(&args); + let incident_state = load_incident_state_for_modules(&args); + let incident_reviews = load_incident_review_for_modules(&args); + let incident_review_audit = load_incident_review_audit_for_modules(&args); + let cases = load_cases_for_modules(&args); let evidence = build_dlp_evidence_response(&args); let ueba_baseline_path = ueba_baseline_state_path(&args); let smoke = json!({ @@ -1331,15 +1403,17 @@ fn run() -> Result { let server = Server::http(&args.bind).map_err(|err| anyhow!("bind {}: {err}", args.bind))?; let snapshot_cache: SnapshotCache = new_snapshot_cache(); + let report_cache: ReportCache = new_report_cache(); eprintln!("detmir-portal listening on http://{}", args.bind); for request in server.incoming_requests() { let args = args.clone(); let snapshot_cache = clone_snapshot_cache(&snapshot_cache); + let report_cache = clone_report_cache(&report_cache); thread::spawn(move || { let result = if args.evidence_only { handle_evidence_only_request(request, &args) } else { - handle_request(request, &args, &snapshot_cache) + handle_request(request, &args, &snapshot_cache, &report_cache) }; if let Err(err) = result { eprintln!("detmir-portal request failed: {err:#}"); @@ -1349,7 +1423,12 @@ fn run() -> Result { Ok(0) } -fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) -> Result<()> { +fn handle_request( + request: Request, + args: &Cli, + snapshot_cache: &SnapshotCache, + report_cache: &ReportCache, +) -> Result<()> { mark_request_started(); let method = request.method().clone(); let url = request.url().to_string(); @@ -1368,6 +1447,12 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) } return handle_incident_review(request, args); } + if method == Method::Post && path == "/api/security/findings/workflow" { + if !role.can_access("security") { + return respond_forbidden(request, role, "security"); + } + return handle_security_finding_workflow(request, args); + } if method == Method::Post && path == "/api/cases" { if !role.can_access("forensics") && !role.can_access("incidents") { return respond_forbidden(request, role, "forensics"); @@ -1460,14 +1545,21 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) "/api/readiness/latest" => respond_json(request, &readiness_latest(args)), "/api/readiness/bundle" => respond_json(request, &readiness_bundle(args)), "/api/readiness/verify" => respond_json(request, &readiness_verify(args)), - "/api/summary" => respond_json( - request, - &build_summary(&cached_snapshot(args, snapshot_cache)), - ), + "/api/summary" => { + let summary = if let Some(snapshot) = cached_snapshot_or_refresh(args, snapshot_cache) { + build_summary(&snapshot) + } else { + build_initial_summary_payload(args) + }; + respond_json(request, &summary) + } "/api/operator" => { - let snapshot = cached_snapshot(args, snapshot_cache); - let incident_state = load_incident_state_best_effort(args); - respond_json(request, &build_operator(&snapshot, &incident_state)) + if let Some(snapshot) = cached_snapshot_or_refresh(args, snapshot_cache) { + let incident_state = load_incident_state_for_modules(args); + respond_json(request, &build_operator(&snapshot, &incident_state)) + } else { + respond_json(request, &build_initial_operator_payload(args)) + } } "/api/manager" => { if !role.can_access("workforce") { @@ -1504,7 +1596,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) ) } "/api/risk/narrative" => { - let report = build_report_payload(args, snapshot_cache, anonymize); + let report = cached_report_payload(args, snapshot_cache, report_cache, anonymize); let query = RiskNarrativeQuery::from_url(&url); respond_json( request, @@ -1512,7 +1604,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) ) } "/api/actions" => { - let report = build_report_payload(args, snapshot_cache, anonymize); + let report = cached_report_payload(args, snapshot_cache, report_cache, anonymize); respond_json(request, &build_action_center_from_report(&report, role)) } "/api/owner" => { @@ -1523,42 +1615,53 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) respond_json(request, &build_owner(&snapshot)) } "/api/reports" => { - let report = build_report_payload(args, snapshot_cache, anonymize); + let report = cached_report_payload(args, snapshot_cache, report_cache, anonymize); respond_json(request, &role_filtered_report(report, role)) } "/api/executive" => { if !role.can_access("executive") { return respond_forbidden(request, role, "executive"); } - let report = build_report_payload(args, snapshot_cache, anonymize); + let report = cached_report_payload(args, snapshot_cache, report_cache, anonymize); respond_json(request, &build_role_api_payload(report, role, "executive")) } "/api/workforce" => { if !role.can_access("workforce") { return respond_forbidden(request, role, "workforce"); } - let report = build_report_payload(args, snapshot_cache, anonymize); + let report = cached_report_payload(args, snapshot_cache, report_cache, anonymize); respond_json(request, &build_role_api_payload(report, role, "workforce")) } "/api/security" => { if !role.can_access("security") { return respond_forbidden(request, role, "security"); } - let report = build_report_payload(args, snapshot_cache, anonymize); + let report = cached_report_payload(args, snapshot_cache, report_cache, anonymize); respond_json(request, &build_role_api_payload(report, role, "security")) } + "/api/security/findings" => { + if !role.can_access("security") { + return respond_forbidden(request, role, "security"); + } + let config = security_events_config_from_args( + args, + Duration::from_secs(args.timeout_seconds).min(Duration::from_secs(5)), + false, + ); + respond_json(request, &build_security_finding_inbox(&config)) + } "/api/forensics" => { if !role.can_access("forensics") { return respond_forbidden(request, role, "forensics"); } - let report = build_report_payload(args, snapshot_cache, anonymize); + let report = cached_report_payload(args, snapshot_cache, report_cache, anonymize); respond_json(request, &build_role_api_payload(report, role, "forensics")) } "/api/ueba" => { if !role.can_access("ueba") { return respond_forbidden(request, role, "ueba"); } - let report = build_report_payload(args, snapshot_cache, anonymize); + let report = cached_report_payload(args, snapshot_cache, report_cache, anonymize); respond_json(request, &build_ueba_api_payload(&report, role)) } "/api/pfsense" => { @@ -1572,7 +1675,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) return respond_forbidden(request, role, "incidents"); } let snapshot = cached_snapshot(args, snapshot_cache); - let incident_state = load_incident_state_best_effort(args); + let incident_state = load_incident_state_for_modules(args); respond_json(request, &build_incidents(&snapshot, &incident_state)) } "/api/cases" => { @@ -1670,14 +1773,9 @@ fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> { fn build_snapshot(args: &Cli) -> Snapshot { let timeout = Duration::from_secs(args.timeout_seconds); - let security_events_config = SecurityEventsConfig { - backend: args.security_events_backend.clone(), - clickhouse_url: args.clickhouse_url.clone(), - clickhouse_database: args.clickhouse_database.clone(), - clickhouse_user: args.clickhouse_user.clone(), - clickhouse_password: args.clickhouse_password.clone(), - timeout: timeout.min(Duration::from_secs(5)), - }; + let dlp_enabled = dlp_module_enabled(args); + let security_events_config = + security_events_config_from_args(args, timeout.min(Duration::from_secs(5)), true); let agent_quality_history = load_agent_quality_history(&args.telemetry_store_path, 7); let agent_quality_history_summary = summarize_agent_quality_history(&agent_quality_history); let agent_quality_nodes = load_agent_quality_nodes(&args.telemetry_store_path, 7); @@ -1686,6 +1784,7 @@ fn build_snapshot(args: &Cli) -> Snapshot { build_agent_coverage_sla(&args.expected_nodes_path, &agent_quality_nodes, Utc::now()); Snapshot { generated_at_utc: now(), + dlp_module_enabled: dlp_enabled, detmir_status: command_json_source("detmir_status", &args.status_cmd, timeout), detmir_check: command_json_source("detmir_check", &args.check_cmd, timeout), failed_units: command_text_source("failed_units", &args.failed_units_cmd, timeout), @@ -1728,6 +1827,25 @@ fn build_snapshot(args: &Cli) -> Snapshot { } } +fn security_events_config_from_args( + args: &Cli, + timeout: Duration, + respect_dlp_gate: bool, +) -> SecurityEventsConfig { + SecurityEventsConfig { + backend: if respect_dlp_gate && !dlp_module_enabled(args) { + "disabled".to_string() + } else { + args.security_events_backend.clone() + }, + clickhouse_url: args.clickhouse_url.clone(), + clickhouse_database: args.clickhouse_database.clone(), + clickhouse_user: args.clickhouse_user.clone(), + clickhouse_password: args.clickhouse_password.clone(), + timeout, + } +} + fn load_agent_quality(path: &Path) -> AgentQuality { let Some(payload) = latest_telemetry_record(path) else { return AgentQuality::default(); @@ -1736,11 +1854,14 @@ fn load_agent_quality(path: &Path) -> AgentQuality { } fn latest_telemetry_record(path: &Path) -> Option { - let text = fs::read_to_string(path).ok()?; - text.lines().rev().find_map(|line| { - let envelope = serde_json::from_str::(line).ok()?; - envelope.get("record").cloned().or(Some(envelope)) - }) + tail_text_lines(path, TELEMETRY_TAIL_MAX_BYTES, TELEMETRY_TAIL_MAX_LINES) + .ok()? + .into_iter() + .rev() + .find_map(|line| { + let envelope = serde_json::from_str::(&line).ok()?; + envelope.get("record").cloned().or(Some(envelope)) + }) } fn load_agent_quality_history(path: &Path, days: i64) -> Vec { @@ -1752,12 +1873,13 @@ fn load_agent_quality_history_for_date( days: i64, today: NaiveDate, ) -> Vec { - let Ok(text) = fs::read_to_string(path) else { + let Ok(lines) = tail_text_lines(path, TELEMETRY_TAIL_MAX_BYTES, TELEMETRY_TAIL_MAX_LINES) + else { return Vec::new(); }; let start = today - chrono::Duration::days(days.saturating_sub(1)); let mut by_date = BTreeMap::new(); - for line in text.lines() { + for line in &lines { let Some((date, record)) = telemetry_record_date_and_payload(line) else { continue; }; @@ -1845,12 +1967,13 @@ fn load_agent_quality_nodes_for_date( days: i64, today: NaiveDate, ) -> Vec { - let Ok(text) = fs::read_to_string(path) else { + let Ok(lines) = tail_text_lines(path, TELEMETRY_TAIL_MAX_BYTES, TELEMETRY_TAIL_MAX_LINES) + else { return Vec::new(); }; let start = today - chrono::Duration::days(days.saturating_sub(1)); let mut by_node: BTreeMap = BTreeMap::new(); - for line in text.lines() { + for line in &lines { let Some((last_seen_utc, date, record)) = telemetry_record_time_date_and_payload(line) else { continue; @@ -1879,6 +2002,29 @@ fn load_agent_quality_nodes_for_date( nodes } +fn tail_text_lines(path: &Path, max_bytes: u64, max_lines: usize) -> std::io::Result> { + let mut file = File::open(path)?; + let size = file.metadata()?.len(); + let start = size.saturating_sub(max_bytes); + file.seek(SeekFrom::Start(start))?; + let mut buffer = Vec::with_capacity((size - start).min(max_bytes) as usize); + file.read_to_end(&mut buffer)?; + + let text = String::from_utf8_lossy(&buffer); + let mut lines = text.lines(); + if start > 0 { + let _ = lines.next(); + } + let mut out = lines + .filter(|line| !line.trim().is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + if out.len() > max_lines { + out = out.split_off(out.len() - max_lines); + } + Ok(out) +} + fn telemetry_node_key(record: &Value) -> String { record .get("hostname") @@ -2476,6 +2622,164 @@ fn build_security_events_summary(config: &SecurityEventsConfig) -> SecurityEvent } } +fn build_security_finding_inbox(config: &SecurityEventsConfig) -> SecurityFindingInbox { + let backend = config.backend.trim().to_ascii_lowercase(); + if backend.is_empty() || backend == "disabled" { + return security_finding_inbox_disabled(); + } + if backend != "clickhouse" { + return security_finding_inbox_fallback( + format!("неизвестный источник security finding inbox: {backend}"), + 0, + ); + } + let started = Instant::now(); + match query_clickhouse_security_finding_inbox(config) { + Ok(mut inbox) => { + inbox.query_ms = started.elapsed().as_millis(); + inbox + } + Err(err) => security_finding_inbox_fallback(err.to_string(), started.elapsed().as_millis()), + } +} + +fn security_finding_inbox_disabled() -> SecurityFindingInbox { + SecurityFindingInbox { + status: "disabled".to_string(), + backend: "disabled".to_string(), + open_count: 0, + critical_count: 0, + high_count: 0, + contained_count: 0, + query_ms: 0, + fallback_used: false, + items: Vec::new(), + error: None, + } +} + +fn security_finding_inbox_fallback( + error: impl Into, + query_ms: u128, +) -> SecurityFindingInbox { + SecurityFindingInbox { + status: "fallback".to_string(), + backend: "clickhouse".to_string(), + open_count: 0, + critical_count: 0, + high_count: 0, + contained_count: 0, + query_ms, + fallback_used: true, + items: Vec::new(), + error: Some(error.into()), + } +} + +fn query_clickhouse_security_finding_inbox( + config: &SecurityEventsConfig, +) -> Result { + let database = clickhouse_identifier(&config.clickhouse_database) + .ok_or_else(|| anyhow!("некорректное имя базы ClickHouse"))?; + let sql = format!( + r#" +SELECT + finding_id, + formatDateTime(first_seen, '%Y-%m-%dT%H:%i:%SZ') AS first_seen_utc, + formatDateTime(last_seen, '%Y-%m-%dT%H:%i:%SZ') AS last_seen_utc, + host, + user, + ip, + department, + state, + severity, + confidence, + toUInt64(score) AS score, + source, + rule_id, + rule_title, + summary, + recommended_action, + workflow_status, + last_workflow_event, + if(isNull(workflow_updated_at), '', formatDateTime(workflow_updated_at, '%Y-%m-%dT%H:%i:%SZ')) AS workflow_updated_at_utc, + workflow_actor, + decision_status, + rollback_plan_id, + plan_id, + toUInt8(management_channel_checked) AS management_channel_checked, + evidence_ref +FROM {database}.security_finding_inbox +WHERE workflow_status NOT IN ('released', 'rollback_verified', 'rejected', 'false_positive') +ORDER BY + multiIf(severity = 'critical', 4, severity = 'high', 3, severity = 'medium', 2, 1) DESC, + last_seen DESC, + host ASC +LIMIT 100 +FORMAT JSONEachRow +"# + ); + let items = clickhouse_query_json_lines(config, &sql)? + .into_iter() + .map(security_finding_item_from_json) + .collect::>(); + let critical_count = items + .iter() + .filter(|item| item.severity == "critical") + .count() as u64; + let high_count = items.iter().filter(|item| item.severity == "high").count() as u64; + let contained_count = items + .iter() + .filter(|item| item.state == "contained" || item.workflow_status == "contained") + .count() as u64; + Ok(SecurityFindingInbox { + status: "ok".to_string(), + backend: "clickhouse".to_string(), + open_count: items.len() as u64, + critical_count, + high_count, + contained_count, + query_ms: 0, + fallback_used: false, + items, + error: None, + }) +} + +fn security_finding_item_from_json(row: Value) -> SecurityFindingInboxItem { + SecurityFindingInboxItem { + finding_id: json_string(&row, &["finding_id"]).unwrap_or_default(), + first_seen_utc: json_string(&row, &["first_seen_utc"]).unwrap_or_default(), + last_seen_utc: json_string(&row, &["last_seen_utc"]).unwrap_or_default(), + host: json_string(&row, &["host"]).unwrap_or_default(), + user: json_string(&row, &["user"]).unwrap_or_default(), + ip: json_string(&row, &["ip"]).unwrap_or_default(), + department: json_string(&row, &["department"]).unwrap_or_default(), + state: json_string(&row, &["state"]).unwrap_or_else(|| "new".to_string()), + severity: json_string(&row, &["severity"]).unwrap_or_else(|| "low".to_string()), + confidence: json_string(&row, &["confidence"]).unwrap_or_else(|| "low".to_string()), + score: json_u64(&row, "score"), + source: json_string(&row, &["source"]).unwrap_or_default(), + rule_id: json_string(&row, &["rule_id"]).unwrap_or_default(), + rule_title: json_string(&row, &["rule_title"]).unwrap_or_default(), + summary: json_string(&row, &["summary"]).unwrap_or_default(), + recommended_action: json_string(&row, &["recommended_action"]) + .unwrap_or_else(|| "manual_review".to_string()), + workflow_status: json_string(&row, &["workflow_status"]) + .unwrap_or_else(|| "new".to_string()), + last_workflow_event: json_string(&row, &["last_workflow_event"]) + .unwrap_or_else(|| "created".to_string()), + workflow_updated_at_utc: json_string(&row, &["workflow_updated_at_utc"]) + .unwrap_or_default(), + workflow_actor: json_string(&row, &["workflow_actor"]).unwrap_or_default(), + decision_status: json_string(&row, &["decision_status"]).unwrap_or_default(), + rollback_plan_id: json_string(&row, &["rollback_plan_id"]).unwrap_or_default(), + plan_id: json_string(&row, &["plan_id"]).unwrap_or_default(), + management_channel_checked: json_u64(&row, "management_channel_checked") > 0, + evidence_ref: json_string(&row, &["evidence_ref"]).unwrap_or_default(), + } +} + fn query_clickhouse_security_events( config: &SecurityEventsConfig, ) -> Result { @@ -2594,6 +2898,31 @@ fn clickhouse_query_json_lines(config: &SecurityEventsConfig, sql: &str) -> Resu Ok(rows) } +fn clickhouse_execute(config: &SecurityEventsConfig, sql: &str) -> Result<()> { + let client = Client::builder() + .timeout(config.timeout) + .no_proxy() + .build() + .context("ClickHouse HTTP client")?; + let url = config.clickhouse_url.trim_end_matches('/'); + let mut request = client + .post(url) + .query(&[("database", config.clickhouse_database.trim())]) + .body(sql.to_string()); + if !config.clickhouse_user.trim().is_empty() { + request = request.basic_auth( + config.clickhouse_user.trim().to_string(), + Some(config.clickhouse_password.clone()), + ); + } + request + .send() + .context("ClickHouse request")? + .error_for_status() + .context("ClickHouse HTTP status")?; + Ok(()) +} + fn json_u64(value: &Value, key: &str) -> u64 { value .get(key) @@ -2837,6 +3166,71 @@ fn build_operator(snapshot: &Snapshot, incident_state: &IncidentStateFile) -> Va }) } +fn build_initial_operator_payload(args: &Cli) -> Value { + let generated_at_utc = now(); + let dlp_enabled = dlp_module_enabled(args); + json!({ + "generated_at_utc": generated_at_utc, + "cache_status": "warming", + "modules": { + "dlp": dlp_module_payload(dlp_enabled) + }, + "summary": { + "severity": "STALE", + "operator_ok": false, + "headline": "Портал прогревает первичный операционный срез", + "generated_at_utc": generated_at_utc, + "blocks": { + "portal": block("STALE", "Полный snapshot строится в фоне; быстрые health/readiness доступны"), + "dlp": if dlp_enabled { + block("STALE", "DLP/security enrichment прогревается в фоне") + } else { + block("DISABLED", "DLP module disabled; Workforce core доступен без DLP") + } + } + }, + "detmir_status": { + "ok": false, + "status": "STALE", + "summary": "snapshot cache warming", + "error": null, + "payload": null + }, + "detmir_check": { + "ok": false, + "status": "STALE", + "summary": "snapshot cache warming", + "error": null, + "payload": null + }, + "failed_units": { + "ok": true, + "status": "UNKNOWN", + "summary": "not checked during cache warming", + "error": null, + "payload": null + }, + "grafana_data": null, + "worktime_management": { + "ok": false, + "status": "STALE", + "summary": "snapshot cache warming", + "error": null, + "payload": null + }, + "security_events_summary": { + "status": if dlp_enabled { "STALE" } else { "disabled" }, + "summary": if dlp_enabled { + "Security/DLP enrichment прогревается в фоне" + } else { + "DLP module disabled; Workforce core remains available" + } + }, + "links": links(), + "incidents": [] + }) +} + fn build_manager(snapshot: &Snapshot) -> Value { let worktime = snapshot .worktime @@ -2969,6 +3363,7 @@ fn build_reports( ); let trend = workforce_trend_json(snapshot); let insight_items = workforce_insight_items(snapshot); + let workforce_operations = workforce_operations_payload(snapshot, anonymize); let workforce_policy_explain = build_workforce_policy_explain(snapshot, workforce_policy_path, anonymize); let workforce_kpi_explain = build_workforce_kpi_explain( @@ -3136,6 +3531,9 @@ fn build_reports( "generated_at_utc": snapshot.generated_at_utc, "period": "оперативный срез за сегодня и текущий runtime", "anonymized": anonymize, + "modules": { + "dlp": dlp_module_payload(snapshot.dlp_module_enabled) + }, "severity": summary.severity, "operator_ok": summary.operator_ok, "headline": headline, @@ -3148,6 +3546,7 @@ fn build_reports( report_kpi("Качество данных", agent_quality.quality_status.clone(), agent_quality.quality_status.clone(), &format!("источник: {}", agent_quality.collector_source)), report_kpi("Достоверность данных", agent_quality_explain.status.clone(), agent_quality_explain.status.clone(), &agent_quality_explain.title), report_kpi("Индекс активности", workforce_index_text(metrics.workforce_index), workforce_index_status(metrics.workforce_index), "proxy: активное время / плановое рабочее время"), + report_kpi("Операционная загрузка", workforce_operations.pointer("/summary/status").and_then(Value::as_str).unwrap_or("LOW_CONFIDENCE").to_string(), workforce_operations.pointer("/summary/status").and_then(Value::as_str).unwrap_or("LOW_CONFIDENCE").to_string(), "загрузка, простой, дисциплина процесса и confidence"), weighted_activity_kpi_from_policy(&workforce_policy_explain), report_kpi("Сотрудники", metrics.users_count.to_string(), worktime.status.clone(), "строки worktime за сегодня"), report_kpi("Активное время", human_duration(metrics.active_seconds), worktime.status.clone(), "сумма active_seconds"), @@ -3177,6 +3576,18 @@ fn build_reports( weighted_activity_item_from_policy(&workforce_policy_explain, workforce_policy_path), report_item("Рабочее время", worktime.status.clone(), worktime.text.clone()), report_item("Сводка руководителя", snapshot.worktime_management.status.clone(), snapshot.worktime_management.summary.clone()), + report_item( + "Операционная загрузка", + workforce_operations.pointer("/summary/status").and_then(Value::as_str).unwrap_or("LOW_CONFIDENCE"), + format!( + "требует действия={}, перегруз={}, недогруз={}, простой={}, low confidence={}", + workforce_operations.pointer("/summary/action_required_users").and_then(Value::as_i64).unwrap_or(0), + workforce_operations.pointer("/summary/load/overloaded_users").and_then(Value::as_i64).unwrap_or(0), + workforce_operations.pointer("/summary/load/underloaded_users").and_then(Value::as_i64).unwrap_or(0), + workforce_operations.pointer("/summary/idle/idle_users").and_then(Value::as_i64).unwrap_or(0), + workforce_operations.pointer("/summary/confidence/low_users").and_then(Value::as_i64).unwrap_or(0) + ), + ), report_item("Активное время", worktime.status.clone(), human_duration(metrics.active_seconds)), report_item("Приложения", worktime.status.clone(), metrics.apps_count.to_string()), report_item("Отчет", "OK", "готов к передаче руководителю") @@ -3232,11 +3643,13 @@ fn build_reports( "incident_review_audit_summary": incident_review_audit_summary, "workforce_policy": workforce_policy_explain, "workforce_kpi_explain": workforce_kpi_explain, + "workforce_operations": workforce_operations.clone(), "workforce": { "department_comparison": department_items, "owner_comparison": owner_items, "trend": trend, "insights": insight_items, + "operations": workforce_operations, "trend_status": trend_status(&trend), "history_note": "Месячный тренд требует накопленной daily history; текущий слой показывает validated daily management snapshot." }, @@ -3245,13 +3658,214 @@ fn build_reports( }) } +fn new_report_cache() -> ReportCache { + Arc::new(Mutex::new(ReportCacheState::default())) +} + +fn clone_report_cache(cache: &ReportCache) -> ReportCache { + Arc::clone(cache) +} + +fn cached_report_payload( + args: &Cli, + snapshot_cache: &SnapshotCache, + report_cache: &ReportCache, + anonymize: bool, +) -> Value { + record_report_request(); + { + let mut guard = report_cache.lock().expect("report cache mutex poisoned"); + if let Some(cached) = guard.entry.as_ref() { + if cached.anonymize == anonymize && cached.created.elapsed() <= REPORT_CACHE_TTL { + record_report_cache_hit(); + return cached.report.clone(); + } + if cached.anonymize == anonymize { + let report = cached.report.clone(); + if !guard.refresh_in_progress { + guard.refresh_in_progress = true; + spawn_report_cache_refresh( + args.clone(), + clone_snapshot_cache(snapshot_cache), + clone_report_cache(report_cache), + anonymize, + ); + } + record_report_cache_stale_hit(); + return report; + } + } + if guard.refresh_in_progress { + record_report_cache_stale_hit(); + return build_initial_report_payload(args, anonymize); + } + guard.refresh_in_progress = true; + } + + record_report_cache_miss(); + spawn_report_cache_refresh( + args.clone(), + clone_snapshot_cache(snapshot_cache), + clone_report_cache(report_cache), + anonymize, + ); + build_initial_report_payload(args, anonymize) +} + +fn spawn_report_cache_refresh( + args: Cli, + snapshot_cache: SnapshotCache, + report_cache: ReportCache, + anonymize: bool, +) { + thread::spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + build_report_payload(&args, &snapshot_cache, anonymize) + })); + match result { + Ok(report) => { + store_generated_report(&report_cache, anonymize, report); + record_report_generated(); + } + Err(_) => { + mark_report_cache_refresh_finished(&report_cache); + eprintln!("detmir-portal report cache refresh panicked"); + } + } + }); +} + +fn store_generated_report(report_cache: &ReportCache, anonymize: bool, report: Value) { + let mut guard = report_cache.lock().expect("report cache mutex poisoned"); + guard.entry = Some(CachedReport { + created: Instant::now(), + anonymize, + report, + }); + guard.refresh_in_progress = false; +} + +fn mark_report_cache_refresh_finished(report_cache: &ReportCache) { + let mut guard = report_cache.lock().expect("report cache mutex poisoned"); + guard.refresh_in_progress = false; +} + +fn dlp_module_payload(enabled: bool) -> Value { + json!({ + "enabled": enabled, + "hot_path": false, + "status": if enabled { "enabled" } else { "disabled" }, + "note": if enabled { + "DLP module enabled; heavy DLP data is served through cached/report endpoints" + } else { + "DLP module disabled; Workforce core remains available" + } + }) +} + +fn build_initial_summary_payload(args: &Cli) -> SummaryResponse { + let mut blocks = BTreeMap::new(); + blocks.insert( + "portal".to_string(), + block( + "STALE", + "Полный snapshot строится в фоне; быстрые health/readiness доступны", + ), + ); + blocks.insert( + "dlp".to_string(), + if dlp_module_enabled(args) { + block("STALE", "DLP/security enrichment прогревается в фоне") + } else { + block( + "DISABLED", + "DLP module disabled; Workforce core доступен без DLP", + ) + }, + ); + SummaryResponse { + severity: "STALE".to_string(), + operator_ok: false, + headline: "Портал прогревает первичный операционный срез".to_string(), + generated_at_utc: now(), + blocks, + } +} + +fn build_initial_report_payload(args: &Cli, anonymize: bool) -> Value { + let dlp_enabled = dlp_module_enabled(args); + let workforce_operations = json!({ + "summary": { + "status": "STALE", + "confidence": "LOW", + "note": "Первичный операционный срез прогревается в фоне" + }, + "rows": [], + "model": "cache_warming" + }); + json!({ + "generated_at_utc": now(), + "period": "первичный срез прогревается", + "anonymized": anonymize, + "cache_status": "warming", + "severity": "STALE", + "operator_ok": false, + "headline": "Портал прогревает полный операционный срез", + "executive_points": [ + "Быстрые health/readiness доступны.", + "Полный report cache строится в фоне.", + "Workforce core не зависит от тяжелого DLP hot path." + ], + "recommended_actions": [ + { + "priority": "P2", + "title": "Дождаться завершения prewarm", + "owner": "ops", + "status": "OPEN", + "details": "Повторный запрос получит полный cached report после фоновой сборки." + } + ], + "modules": { + "dlp": dlp_module_payload(dlp_enabled) + }, + "kpis": [ + report_kpi( + "Статус отчета", + "STALE".to_string(), + "STALE".to_string(), + "первичный полный срез прогревается" + ), + report_kpi( + "Операционная загрузка", + "LOW_CONFIDENCE".to_string(), + "LOW_CONFIDENCE".to_string(), + "полные показатели будут доступны после cache/prewarm" + ) + ], + "workforce_operations": workforce_operations.clone(), + "workforce": { + "operations": workforce_operations, + "history_note": "Первичный cache warming; полный validated daily management snapshot строится в фоне." + }, + "security_events_summary": { + "status": if dlp_enabled { "STALE" } else { "DISABLED" }, + "summary": if dlp_enabled { + "Security/DLP enrichment прогревается в фоне" + } else { + "DLP module disabled; Workforce core remains available" + } + }, + "markdown": "Первичный полный отчет прогревается в фоне. Разделы с расчетными выводами и derived detections/cases будут доступны после cache/prewarm.", + "links": links() + }) +} + fn build_report_payload(args: &Cli, snapshot_cache: &SnapshotCache, anonymize: bool) -> Value { - record_report_generated(); let snapshot = cached_snapshot(args, snapshot_cache); - let incident_state = load_incident_state_best_effort(args); - let incident_reviews = load_incident_review_best_effort(args); - let incident_review_audit = load_incident_review_audit_best_effort(args); - let cases = load_cases_best_effort(args); + let incident_state = load_incident_state_for_modules(args); + let incident_reviews = load_incident_review_for_modules(args); + let incident_review_audit = load_incident_review_audit_for_modules(args); + let cases = load_cases_for_modules(args); let evidence = build_dlp_evidence_response(args); let ueba_baseline_path = ueba_baseline_state_path(args); build_reports( @@ -3291,6 +3905,8 @@ fn role_filtered_report(report: Value, role: PortalRole) -> Value { "generated_at_utc", "period", "anonymized", + "cache_status", + "modules", "severity", "operator_ok", "headline", @@ -3312,6 +3928,7 @@ fn role_filtered_report(report: Value, role: PortalRole) -> Value { "risk_heatmap", "security_events_summary", "workforce", + "workforce_operations", "workforce_kpi_explain", "markdown", ] { @@ -3331,6 +3948,7 @@ fn role_filtered_report(report: Value, role: PortalRole) -> Value { "business_risk", "risk_heatmap", "workforce", + "workforce_operations", "workforce_policy", "workforce_kpi_explain", "markdown", @@ -5483,6 +6101,59 @@ fn workforce_insight_items(snapshot: &Snapshot) -> Vec { }) } +fn workforce_operations_payload(snapshot: &Snapshot, anonymize: bool) -> Value { + let Some(source) = snapshot + .worktime_management + .payload + .as_ref() + .and_then(|payload| payload.get("workforce_operations")) + else { + return json!({ + "status": "NO_DATA", + "summary": { + "status": "LOW_CONFIDENCE", + "users_count": 0, + "action_required_users": 0, + "guardrail": "missing management payload" + }, + "rows": [], + "model": { + "type": "rule_based", + "ml": false, + "llm": false, + "version": "workforce-operations-v1" + } + }); + }; + let mut payload = source.clone(); + if let Some(object) = payload.as_object_mut() { + let rows = object + .get("rows") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .enumerate() + .map(|(idx, mut row)| { + if anonymize { + if let Some(row_object) = row.as_object_mut() { + row_object + .insert("user".to_string(), json!(format!("Сотрудник {}", idx + 1))); + row_object.insert( + "manager_owner".to_string(), + json!(format!("Ответственный {}", idx + 1)), + ); + } + } + row + }) + .collect::>(); + object.insert("rows".to_string(), Value::Array(rows)); + object.insert("anonymized".to_string(), json!(anonymize)); + } + payload +} + fn trend_status(trend: &Value) -> String { let points = trend.as_array().map(Vec::len).unwrap_or(0); if points >= 20 { @@ -8615,6 +9286,26 @@ fn handle_incident_review(mut request: Request, args: &Cli) -> Result<()> { } } +fn handle_security_finding_workflow(mut request: Request, args: &Cli) -> Result<()> { + let actor = request_actor(&request); + let body = match read_limited_body(&mut request, 32 * 1024) { + Ok(body) => body, + Err(err) if is_payload_too_large(&err) => return respond_payload_too_large(request), + Err(err) => return Err(err), + }; + match apply_security_finding_workflow(args, &actor, &body) { + Ok(response) => respond_json(request, &response), + Err(err) => respond_json_status( + request, + StatusCode(400), + &json!({ + "ok": false, + "error": err.to_string() + }), + ), + } +} + fn handle_investigation_pack( request: Request, args: &Cli, @@ -8921,6 +9612,67 @@ fn apply_case_status(args: &Cli, case_id: &str, body: &str) -> Result Result { + let request: SecurityFindingWorkflowRequest = serde_json::from_str(body) + .map_err(|err| anyhow!("invalid security finding workflow JSON: {err}"))?; + let finding_id = validate_short_token(&request.finding_id, "finding_id", 128)?; + let (event_type, status) = validate_security_finding_workflow_action(&request.action)?; + let config = security_events_config_from_args( + args, + Duration::from_secs(args.timeout_seconds).min(Duration::from_secs(5)), + false, + ); + if !config.backend.trim().eq_ignore_ascii_case("clickhouse") { + return Err(anyhow!( + "Security Finding Inbox workflow requires ClickHouse backend" + )); + } + let database = clickhouse_identifier(&config.clickhouse_database) + .ok_or_else(|| anyhow!("некорректное имя базы ClickHouse"))?; + let row = json!({ + "ts": now(), + "finding_id": finding_id, + "event_type": event_type, + "status": status, + "actor": sanitize_text(actor, 128), + "comment": sanitize_optional_text(request.comment, 512).unwrap_or_default(), + "decision_status": sanitize_optional_text(request.decision_status, 128).unwrap_or_default(), + "rollback_plan_id": sanitize_optional_text(request.rollback_plan_id, 128).unwrap_or_default(), + "plan_id": sanitize_optional_text(request.plan_id, 128).unwrap_or_default(), + "evidence_json": serde_json::to_string(&json!({ + "source": "detmir_portal", + "mutation": "workflow_event_only", + "note": "Portal does not execute Windows Firewall apply" + }))?, + }); + let sql = format!( + "INSERT INTO {database}.security_finding_workflow_events FORMAT JSONEachRow\n{}\n", + serde_json::to_string(&row)? + ); + clickhouse_execute(&config, &sql)?; + Ok(json!({ + "ok": true, + "finding_id": row.get("finding_id").cloned().unwrap_or(Value::Null), + "event_type": event_type, + "status": status, + "mutation": "workflow_event_only" + })) +} + +fn validate_security_finding_workflow_action(value: &str) -> Result<(&'static str, &'static str)> { + match value.trim() { + "decide" | "decide_requested" => Ok(("decide_requested", "decision_pending")), + "plan" | "plan_requested" => Ok(("plan_requested", "plan_pending")), + "approve" | "approved" => Ok(("approved", "approved")), + "apply" | "apply_requested" => Ok(("apply_requested", "apply_pending")), + "verify" | "verify_requested" => Ok(("verify_requested", "verify_pending")), + "rollback" | "rollback_requested" => Ok(("rollback_requested", "rollback_pending")), + "reject" | "rejected" => Ok(("rejected", "rejected")), + "false_positive" => Ok(("false_positive", "false_positive")), + _ => Err(anyhow!("unsupported security finding workflow action")), + } +} + fn build_case_list(args: &Cli) -> CaseListResponse { let mut cases = load_cases_best_effort(args) .cases @@ -9023,6 +9775,14 @@ fn load_incident_state_best_effort(args: &Cli) -> IncidentStateFile { } } +fn load_incident_state_for_modules(args: &Cli) -> IncidentStateFile { + if dlp_module_enabled(args) { + load_incident_state_best_effort(args) + } else { + IncidentStateFile::default() + } +} + fn load_incident_review_best_effort(args: &Cli) -> IncidentReviewFile { match load_incident_review(args) { Ok(state) => state, @@ -9033,6 +9793,14 @@ fn load_incident_review_best_effort(args: &Cli) -> IncidentReviewFile { } } +fn load_incident_review_for_modules(args: &Cli) -> IncidentReviewFile { + if dlp_module_enabled(args) { + load_incident_review_best_effort(args) + } else { + IncidentReviewFile::default() + } +} + fn load_incident_review_audit_best_effort(args: &Cli) -> Vec { match load_incident_review_audit(args) { Ok(entries) => entries, @@ -9043,6 +9811,14 @@ fn load_incident_review_audit_best_effort(args: &Cli) -> Vec Vec { + if dlp_module_enabled(args) { + load_incident_review_audit_best_effort(args) + } else { + Vec::new() + } +} + fn load_cases_best_effort(args: &Cli) -> CaseFile { match load_cases(args) { Ok(state) => state, @@ -9053,6 +9829,14 @@ fn load_cases_best_effort(args: &Cli) -> CaseFile { } } +fn load_cases_for_modules(args: &Cli) -> CaseFile { + if dlp_module_enabled(args) { + load_cases_best_effort(args) + } else { + CaseFile::default() + } +} + fn load_incident_state(args: &Cli) -> Result { let path = incident_state_path(args); if !path.exists() { @@ -9181,10 +9965,27 @@ fn cases_path(args: &Cli) -> PathBuf { args.state_dir.join("data").join("cases.json") } +fn dlp_module_enabled(args: &Cli) -> bool { + args.dlp_module_enabled +} + fn build_dlp_evidence_response(args: &Cli) -> DlpEvidenceResponse { let generated_at_utc = now(); let db_available = args.dlp_db_path.exists(); let screenshot_root_available = args.evidence_root.exists(); + if !dlp_module_enabled(args) { + return DlpEvidenceResponse { + ok: true, + generated_at_utc, + db_available, + screenshot_root_available, + limit: args.evidence_limit, + items: Vec::new(), + error: Some( + "DLP module disabled by DETMIR_PORTAL_DLP_MODULE_ENABLED=false".to_string(), + ), + }; + } if !db_available { return DlpEvidenceResponse { ok: true, @@ -10002,6 +10803,12 @@ fn grafana_block(snapshot: &Snapshot) -> SummaryBlock { } fn dlp_block(snapshot: &Snapshot) -> SummaryBlock { + if !snapshot.dlp_module_enabled { + return block( + "DISABLED", + "DLP module disabled; Workforce core доступен без DLP", + ); + } let Some(status) = snapshot.detmir_status.payload.as_ref() else { return block("UNKNOWN", "Нет DLP данных"); }; @@ -10130,6 +10937,9 @@ fn grafana_service(snapshot: &Snapshot) -> Option { } fn dlp_ok(snapshot: &Snapshot) -> bool { + if !snapshot.dlp_module_enabled { + return true; + } snapshot .detmir_status .payload @@ -10294,6 +11104,7 @@ mod tests { }; Snapshot { generated_at_utc: "2026-06-07T10:00:00Z".to_string(), + dlp_module_enabled: true, detmir_status: SourceStatus { ok: true, status: "OK".to_string(), @@ -10583,7 +11394,9 @@ mod tests { fn role_filtered_reports_do_not_cross_default_scopes() { let report = json!({ "generated_at_utc": "2026-06-06T00:00:00Z", + "cache_status": "warming", "headline": "demo", + "modules": {"dlp": {"enabled": false, "status": "disabled", "hot_path": false}}, "executive_dashboard": {"summary": {"main_risk": "demo"}}, "workforce": {"department_comparison": []}, "workforce_policy": {"configured": false}, @@ -10626,6 +11439,8 @@ mod tests { let executive = role_filtered_report(report.clone(), PortalRole::Executive); assert!(executive.get("workforce").is_some()); assert!(executive.get("executive_dashboard").is_some()); + assert_eq!(executive["cache_status"], "warming"); + assert_eq!(executive["modules"]["dlp"]["status"], "disabled"); assert!(executive.get("security_events_summary").is_some()); assert!(executive.get("risk_incident_candidates").is_none()); assert!(executive.get("security_correlation").is_none()); @@ -10740,6 +11555,7 @@ mod tests { let policy_path = dir.path().join("ueba-policy.yaml"); let snapshot = Snapshot { generated_at_utc: "2026-06-07T10:00:00Z".to_string(), + dlp_module_enabled: true, detmir_status: ok_source(), detmir_check: ok_source(), failed_units: ok_source(), @@ -11600,6 +12416,7 @@ mod tests { slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, environment: "test".to_string(), enabled_modules: "executive,workforce,security,forensics,admin".to_string(), + dlp_module_enabled: true, state_dir: dir.path().join("state"), dlp_db_path: dir.path().join("dlp.sqlite"), evidence_root: dir.path().to_path_buf(), @@ -11716,6 +12533,7 @@ mod tests { slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, environment: "test".to_string(), enabled_modules: "executive,workforce,security,forensics,admin".to_string(), + dlp_module_enabled: true, state_dir: dir.path().join("state"), dlp_db_path: dir.path().join("dlp.sqlite"), evidence_root: dir.path().to_path_buf(), @@ -11795,6 +12613,7 @@ mod tests { slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, environment: "test".to_string(), enabled_modules: "executive,workforce,security,forensics,admin".to_string(), + dlp_module_enabled: true, state_dir: dir.path().join("state"), dlp_db_path: dir.path().join("dlp.sqlite"), evidence_root: dir.path().to_path_buf(), @@ -11952,10 +12771,261 @@ mod tests { assert!(!security_events_executive_text(&disabled).contains("ClickHouse")); } + #[test] + fn disabled_dlp_module_is_not_workforce_failure() { + let dir = tempfile::tempdir().unwrap(); + let state_data_dir = dir.path().join("state").join("data"); + fs::create_dir_all(&state_data_dir).unwrap(); + fs::write(state_data_dir.join("cases.json"), "{not-json").unwrap(); + let args = Cli { + bind: "127.0.0.1:0".to_string(), + status_cmd: "true".to_string(), + check_cmd: "true".to_string(), + failed_units_cmd: "true".to_string(), + worktime_url: "http://127.0.0.1".to_string(), + one_c_url: "http://127.0.0.1".to_string(), + workforce_policy_path: dir.path().join("workforce-policy.json"), + ueba_policy_path: dir.path().join("ueba-policy.yaml"), + timeout_seconds: 1, + max_page_size: DEFAULT_MAX_PAGE_SIZE, + default_page_size: DEFAULT_PAGE_SIZE, + max_report_date_range_days: DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, + request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT_SECONDS, + max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, + slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, + environment: "test".to_string(), + enabled_modules: "executive,workforce,security,forensics,admin".to_string(), + dlp_module_enabled: false, + state_dir: dir.path().join("state"), + dlp_db_path: dir.path().join("missing-dlp.sqlite"), + evidence_root: dir.path().join("missing-evidence"), + readiness_bundle_dir: dir.path().join("readiness-bundle"), + evidence_limit: 10, + evidence_max_bytes: 1024, + json_smoke: false, + evidence_only: false, + evidence_upload_token: None, + telemetry_api_key: "dummy".to_string(), + telemetry_store_path: dir.path().join("telemetry.jsonl"), + expected_nodes_path: dir.path().join("expected_nodes.json"), + security_events_backend: "clickhouse".to_string(), + clickhouse_url: "http://127.0.0.1:1".to_string(), + clickhouse_database: "analytics_1c".to_string(), + clickhouse_user: "default".to_string(), + clickhouse_password: String::new(), + }; + let cases = load_cases_for_modules(&args); + assert!(cases.cases.is_empty()); + let evidence = build_dlp_evidence_response(&args); + assert!(evidence.ok); + assert!(evidence.items.is_empty()); + assert!( + evidence + .error + .as_deref() + .unwrap_or("") + .contains("DLP module disabled") + ); + let snapshot = Snapshot { + generated_at_utc: "2026-06-25T10:00:00Z".to_string(), + dlp_module_enabled: false, + detmir_status: SourceStatus { + ok: true, + status: "WARN".to_string(), + summary: "dlp warn".to_string(), + error: None, + payload: Some(json!({ + "dlp_ok": false, + "dlp_counts": {"ok": 1, "warn": 9, "fail": 3} + })), + }, + detmir_check: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: String::new(), + error: None, + payload: None, + }, + failed_units: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: String::new(), + error: None, + payload: None, + }, + worktime: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: String::new(), + error: None, + payload: None, + }, + worktime_management: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: String::new(), + error: None, + payload: None, + }, + one_c: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: String::new(), + error: None, + payload: None, + }, + one_c_overview: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: String::new(), + error: None, + payload: None, + }, + agent_quality: AgentQuality::default(), + agent_quality_history: Vec::new(), + agent_quality_history_summary: AgentQualityHistorySummary::default(), + agent_quality_nodes: Vec::new(), + agent_quality_nodes_summary: AgentQualityNodesSummary::default(), + agent_coverage_sla: AgentCoverageSla::default(), + security_events_summary: SecurityEventsSummary::disabled(), + }; + assert!(dlp_ok(&snapshot)); + assert_eq!(dlp_block(&snapshot).status, "DISABLED"); + } + + #[test] + fn cold_report_cache_returns_warming_payload_without_blocking() { + let dir = tempfile::tempdir().unwrap(); + let args = Cli { + bind: "127.0.0.1:0".to_string(), + status_cmd: "printf '{\"severity\":\"OK\",\"ok_for_operator\":true}'".to_string(), + check_cmd: "printf '{\"summary\":{\"bucket_ok\":1}}'".to_string(), + failed_units_cmd: "true".to_string(), + worktime_url: "http://127.0.0.1:1".to_string(), + one_c_url: "http://127.0.0.1:1".to_string(), + workforce_policy_path: dir.path().join("workforce-policy.json"), + ueba_policy_path: dir.path().join("ueba-policy.yaml"), + timeout_seconds: 1, + max_page_size: DEFAULT_MAX_PAGE_SIZE, + default_page_size: DEFAULT_PAGE_SIZE, + max_report_date_range_days: DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, + request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT_SECONDS, + max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, + slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, + environment: "test".to_string(), + enabled_modules: "executive,workforce,security,forensics,admin".to_string(), + dlp_module_enabled: false, + state_dir: dir.path().join("state"), + dlp_db_path: dir.path().join("missing-dlp.sqlite"), + evidence_root: dir.path().join("missing-evidence"), + readiness_bundle_dir: dir.path().join("readiness-bundle"), + evidence_limit: 10, + evidence_max_bytes: 1024, + json_smoke: false, + evidence_only: false, + evidence_upload_token: None, + telemetry_api_key: "dummy".to_string(), + telemetry_store_path: dir.path().join("telemetry.jsonl"), + expected_nodes_path: dir.path().join("expected_nodes.json"), + security_events_backend: "clickhouse".to_string(), + clickhouse_url: "http://127.0.0.1:1".to_string(), + clickhouse_database: "analytics_1c".to_string(), + clickhouse_user: "default".to_string(), + clickhouse_password: String::new(), + }; + let snapshot_cache = new_snapshot_cache(); + let report_cache = new_report_cache(); + + let report = cached_report_payload(&args, &snapshot_cache, &report_cache, false); + + assert_eq!(report["cache_status"], "warming"); + assert_eq!(report["severity"], "STALE"); + assert_eq!(report["modules"]["dlp"]["status"], "disabled"); + assert!( + report["kpis"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); + assert!( + report["markdown"] + .as_str() + .unwrap_or("") + .contains("расчетными выводами") + ); + let guard = report_cache.lock().unwrap(); + assert!(guard.refresh_in_progress || guard.entry.is_some()); + } + + #[test] + fn cold_operator_snapshot_returns_warming_payload_without_blocking() { + let dir = tempfile::tempdir().unwrap(); + let args = Cli { + bind: "127.0.0.1:0".to_string(), + status_cmd: "sleep 1; printf '{\"severity\":\"OK\",\"ok_for_operator\":true}'" + .to_string(), + check_cmd: "printf '{\"summary\":{\"bucket_ok\":1}}'".to_string(), + failed_units_cmd: "true".to_string(), + worktime_url: "http://127.0.0.1:1".to_string(), + one_c_url: "http://127.0.0.1:1".to_string(), + workforce_policy_path: dir.path().join("workforce-policy.json"), + ueba_policy_path: dir.path().join("ueba-policy.yaml"), + timeout_seconds: 2, + max_page_size: DEFAULT_MAX_PAGE_SIZE, + default_page_size: DEFAULT_PAGE_SIZE, + max_report_date_range_days: DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, + request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT_SECONDS, + max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, + slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, + environment: "test".to_string(), + enabled_modules: "executive,workforce,security,forensics,admin".to_string(), + dlp_module_enabled: false, + state_dir: dir.path().join("state"), + dlp_db_path: dir.path().join("missing-dlp.sqlite"), + evidence_root: dir.path().join("missing-evidence"), + readiness_bundle_dir: dir.path().join("readiness-bundle"), + evidence_limit: 10, + evidence_max_bytes: 1024, + json_smoke: false, + evidence_only: false, + evidence_upload_token: None, + telemetry_api_key: "dummy".to_string(), + telemetry_store_path: dir.path().join("telemetry.jsonl"), + expected_nodes_path: dir.path().join("expected_nodes.json"), + security_events_backend: "clickhouse".to_string(), + clickhouse_url: "http://127.0.0.1:1".to_string(), + clickhouse_database: "analytics_1c".to_string(), + clickhouse_user: "default".to_string(), + clickhouse_password: String::new(), + }; + let snapshot_cache = new_snapshot_cache(); + + let maybe_snapshot = cached_snapshot_or_refresh(&args, &snapshot_cache); + let operator = maybe_snapshot + .as_ref() + .map(|snapshot| build_operator(snapshot, &IncidentStateFile::default())) + .unwrap_or_else(|| build_initial_operator_payload(&args)); + + assert!(maybe_snapshot.is_none()); + assert_eq!(operator["cache_status"], "warming"); + assert_eq!(operator["summary"]["severity"], "STALE"); + assert_eq!(operator["modules"]["dlp"]["status"], "disabled"); + assert_eq!(operator["incidents"].as_array().map(Vec::len), Some(0)); + let summary = maybe_snapshot + .as_ref() + .map(build_summary) + .unwrap_or_else(|| build_initial_summary_payload(&args)); + assert_eq!(summary.severity, "STALE"); + assert_eq!(summary.blocks["portal"].status, "STALE"); + assert_eq!(summary.blocks["dlp"].status, "DISABLED"); + let guard = snapshot_cache.lock().unwrap(); + assert!(guard.refresh_in_progress || guard.entry.is_some()); + } + #[test] fn reports_include_commercial_kpis_and_disclaimer() { let snapshot = Snapshot { generated_at_utc: "2026-06-03T10:00:00Z".to_string(), + dlp_module_enabled: true, detmir_status: SourceStatus { ok: true, status: "OK".to_string(), @@ -12465,6 +13535,7 @@ mod tests { slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, environment: "test".to_string(), enabled_modules: "executive,workforce,security,forensics,admin".to_string(), + dlp_module_enabled: true, state_dir: case_dir.path().join("state"), dlp_db_path: case_dir.path().join("dlp.sqlite"), evidence_root: case_dir.path().to_path_buf(), @@ -12700,6 +13771,7 @@ confidence: .unwrap(); let snapshot = Snapshot { generated_at_utc: "2026-06-03T10:00:00Z".to_string(), + dlp_module_enabled: true, detmir_status: SourceStatus { ok: true, status: "WARN".to_string(), @@ -12799,6 +13871,7 @@ confidence: fn snapshot_for(date: &str, active_seconds: i64, department_coverage: f64) -> Snapshot { Snapshot { generated_at_utc: format!("{date}T10:00:00Z"), + dlp_module_enabled: true, detmir_status: SourceStatus { ok: true, status: "OK".to_string(), @@ -12912,6 +13985,7 @@ confidence: fn weighted_activity_uses_role_application_policy() { let snapshot = Snapshot { generated_at_utc: "2026-06-03T10:00:00Z".to_string(), + dlp_module_enabled: true, detmir_status: SourceStatus { ok: true, status: "OK".to_string(), @@ -13022,6 +14096,7 @@ confidence: .unwrap(); let snapshot = Snapshot { generated_at_utc: "2026-06-03T10:00:00Z".to_string(), + dlp_module_enabled: true, detmir_status: SourceStatus { ok: true, status: "OK".to_string(), diff --git a/adk-rust/crates/detmir-portal/src/production/limits.rs b/adk-rust/crates/detmir-portal/src/production/limits.rs index fb1a6c5..5950592 100644 --- a/adk-rust/crates/detmir-portal/src/production/limits.rs +++ b/adk-rust/crates/detmir-portal/src/production/limits.rs @@ -11,6 +11,7 @@ use anyhow::{Result, anyhow}; use chrono::NaiveDate; use serde_json::{Value, json}; use tiny_http::StatusCode; +use url::Url; use crate::{ Cli, MAX_ALLOWED_PAGE_SIZE, MAX_ALLOWED_REPORT_DATE_RANGE_DAYS, MAX_ALLOWED_REQUEST_BODY_BYTES, @@ -77,6 +78,11 @@ pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> { "invalid config max_request_body_bytes: expected 1024..={MAX_ALLOWED_REQUEST_BODY_BYTES}" )); } + validate_runtime_url("worktime_url", &args.worktime_url)?; + validate_runtime_url("one_c_url", &args.one_c_url)?; + validate_probe_command("status_cmd", &args.status_cmd)?; + validate_probe_command("check_cmd", &args.check_cmd)?; + validate_probe_command("failed_units_cmd", &args.failed_units_cmd)?; // SECURITY: environment and module names can reach metrics/log labels. // Restrict them to short ASCII tokens to avoid label injection and runaway @@ -112,6 +118,46 @@ pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> { Ok(()) } +fn validate_runtime_url(name: &str, value: &str) -> Result<()> { + let url = Url::parse(value).map_err(|err| anyhow!("invalid config {name}: {err}"))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(anyhow!("invalid config {name}: expected http or https URL")); + } + let Some(host) = url.host_str() else { + return Err(anyhow!("invalid config {name}: missing host")); + }; + if is_placeholder_host(host) { + return Err(anyhow!( + "invalid config {name}: placeholder/documentation host is not allowed in production" + )); + } + Ok(()) +} + +fn is_placeholder_host(host: &str) -> bool { + let host = host.trim().to_ascii_lowercase(); + host.is_empty() + || host == "host-example" + || host.ends_with(".example") + || host.starts_with("192.0.2.") + || host.starts_with("198.51.100.") + || host.starts_with("203.0.113.") +} + +fn validate_probe_command(name: &str, command: &str) -> Result<()> { + let command = command.trim(); + if command.is_empty() { + return Err(anyhow!("invalid config {name}: command is empty")); + } + let forbidden = ['\n', '\r', '\0', ';', '|', '&', '<', '>', '`']; + if command.contains("$(") || command.chars().any(|ch| forbidden.contains(&ch)) { + return Err(anyhow!( + "invalid config {name}: shell control operators are not allowed" + )); + } + Ok(()) +} + fn is_safe_environment_name(value: &str) -> bool { let value = value.trim(); !value.is_empty() @@ -230,6 +276,7 @@ mod tests { slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, environment: "test".to_string(), enabled_modules: "executive,workforce,security,forensics,admin".to_string(), + dlp_module_enabled: true, state_dir: dir.join("state"), dlp_db_path: dir.join("dlp.sqlite"), evidence_root: dir.to_path_buf(), @@ -293,6 +340,39 @@ mod tests { ); } + #[test] + fn config_validation_rejects_placeholder_endpoints_and_shell_operators() { + let dir = tempfile::tempdir().unwrap(); + let args = test_cli(dir.path()); + + let mut invalid = args.clone(); + invalid.worktime_url = "http://192.0.2.13:5610".to_string(); + assert!( + validate_portal_config(&invalid) + .unwrap_err() + .to_string() + .contains("placeholder") + ); + + let mut invalid = args.clone(); + invalid.one_c_url = "http://198.51.100.2:8710".to_string(); + assert!( + validate_portal_config(&invalid) + .unwrap_err() + .to_string() + .contains("placeholder") + ); + + let mut invalid = args.clone(); + invalid.check_cmd = "detmir-check --json; curl http://127.0.0.1".to_string(); + assert!( + validate_portal_config(&invalid) + .unwrap_err() + .to_string() + .contains("shell control") + ); + } + #[test] fn query_limits_reject_page_size_and_report_range() { let dir = tempfile::tempdir().unwrap(); diff --git a/adk-rust/crates/detmir-portal/src/production/metrics.rs b/adk-rust/crates/detmir-portal/src/production/metrics.rs index 99e34e6..a862deb 100644 --- a/adk-rust/crates/detmir-portal/src/production/metrics.rs +++ b/adk-rust/crates/detmir-portal/src/production/metrics.rs @@ -34,6 +34,10 @@ struct HttpMetricValue { #[derive(Clone, Debug, Default)] struct PortalMetrics { http: BTreeMap, + report_requests_total: u64, + report_cache_hits_total: u64, + report_cache_misses_total: u64, + report_cache_stale_hits_total: u64, reports_generated_total: u64, ingestion_records_total: u64, ingestion_rejected_total: u64, @@ -70,6 +74,32 @@ pub(crate) fn record_report_generated() { } } +pub(crate) fn record_report_request() { + if let Ok(mut metrics) = portal_metrics().lock() { + metrics.report_requests_total = metrics.report_requests_total.saturating_add(1); + } +} + +pub(crate) fn record_report_cache_hit() { + if let Ok(mut metrics) = portal_metrics().lock() { + metrics.report_cache_hits_total = metrics.report_cache_hits_total.saturating_add(1); + } +} + +pub(crate) fn record_report_cache_stale_hit() { + if let Ok(mut metrics) = portal_metrics().lock() { + metrics.report_cache_hits_total = metrics.report_cache_hits_total.saturating_add(1); + metrics.report_cache_stale_hits_total = + metrics.report_cache_stale_hits_total.saturating_add(1); + } +} + +pub(crate) fn record_report_cache_miss() { + if let Ok(mut metrics) = portal_metrics().lock() { + metrics.report_cache_misses_total = metrics.report_cache_misses_total.saturating_add(1); + } +} + pub(crate) fn record_ingestion_accepted() { if let Ok(mut metrics) = portal_metrics().lock() { metrics.ingestion_records_total = metrics.ingestion_records_total.saturating_add(1); @@ -149,6 +179,26 @@ pub(crate) fn render_prometheus_metrics(args: &Cli) -> String { .ok(); } for (name, help, value) in [ + ( + "awatch_report_requests_total", + "Report payload requests handled by the portal cache layer", + metrics.report_requests_total, + ), + ( + "awatch_report_cache_hits_total", + "Report payload requests served from the in-process cache", + metrics.report_cache_hits_total, + ), + ( + "awatch_report_cache_misses_total", + "Report payload requests that triggered report regeneration", + metrics.report_cache_misses_total, + ), + ( + "awatch_report_cache_stale_hits_total", + "Report payload requests served from stale cache while refresh runs", + metrics.report_cache_stale_hits_total, + ), ( "awatch_reports_generated_total", "Reports generated by the portal", diff --git a/adk-rust/crates/detmir-portal/src/production/mod.rs b/adk-rust/crates/detmir-portal/src/production/mod.rs index 3473dac..73d4e50 100644 --- a/adk-rust/crates/detmir-portal/src/production/mod.rs +++ b/adk-rust/crates/detmir-portal/src/production/mod.rs @@ -22,7 +22,8 @@ pub(crate) use limits::{is_limited_api_route, validate_api_query_limits, validat pub(crate) use logging::log_http_request; pub(crate) use metrics::{ record_http_metric, record_ingestion_accepted, record_ingestion_rejected, - record_report_generated, render_prometheus_metrics, + record_report_cache_hit, record_report_cache_miss, record_report_cache_stale_hit, + record_report_generated, record_report_request, render_prometheus_metrics, }; pub(crate) use readiness::build_readyz; pub(crate) use request_context::{http_request_metadata, mark_request_started}; diff --git a/adk-rust/crates/detmir-portal/src/snapshot_cache.rs b/adk-rust/crates/detmir-portal/src/snapshot_cache.rs index 107e728..58389d1 100644 --- a/adk-rust/crates/detmir-portal/src/snapshot_cache.rs +++ b/adk-rust/crates/detmir-portal/src/snapshot_cache.rs @@ -6,13 +6,20 @@ use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; +use std::thread; use std::time::{Duration, Instant}; use crate::{Cli, HealthResponse, Snapshot, build_health, build_snapshot, now}; const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(120); -pub(crate) type SnapshotCache = Arc>>; +pub(crate) type SnapshotCache = Arc>; + +#[derive(Clone, Debug, Default)] +pub(crate) struct SnapshotCacheState { + pub(crate) entry: Option, + pub(crate) refresh_in_progress: bool, +} #[derive(Clone, Debug)] pub(crate) struct CachedSnapshot { @@ -21,7 +28,7 @@ pub(crate) struct CachedSnapshot { } pub(crate) fn new_snapshot_cache() -> SnapshotCache { - Arc::new(Mutex::new(None)) + Arc::new(Mutex::new(SnapshotCacheState::default())) } pub(crate) fn clone_snapshot_cache(cache: &SnapshotCache) -> SnapshotCache { @@ -29,23 +36,76 @@ pub(crate) fn clone_snapshot_cache(cache: &SnapshotCache) -> SnapshotCache { } pub(crate) fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot { - let mut guard = cache.lock().expect("snapshot cache mutex poisoned"); - if let Some(cached) = guard.as_ref() { - if cached.created.elapsed() <= SNAPSHOT_CACHE_TTL { - return cached.snapshot.clone(); + { + let guard = cache.lock().expect("snapshot cache mutex poisoned"); + if let Some(cached) = guard.entry.as_ref() { + if cached.created.elapsed() <= SNAPSHOT_CACHE_TTL { + return cached.snapshot.clone(); + } } } + let snapshot = build_snapshot(args); - *guard = Some(CachedSnapshot { + let mut guard = cache.lock().expect("snapshot cache mutex poisoned"); + guard.entry = Some(CachedSnapshot { created: Instant::now(), snapshot: snapshot.clone(), }); + guard.refresh_in_progress = false; snapshot } +pub(crate) fn cached_snapshot_or_refresh(args: &Cli, cache: &SnapshotCache) -> Option { + let mut should_spawn = false; + let mut snapshot_to_return = None; + { + let mut guard = cache.lock().expect("snapshot cache mutex poisoned"); + if let Some(cached) = guard.entry.as_ref() { + let snapshot = cached.snapshot.clone(); + if cached.created.elapsed() <= SNAPSHOT_CACHE_TTL { + return Some(snapshot); + } + if !guard.refresh_in_progress { + guard.refresh_in_progress = true; + should_spawn = true; + } + snapshot_to_return = Some(snapshot); + } else if !guard.refresh_in_progress { + guard.refresh_in_progress = true; + should_spawn = true; + } + } + + if should_spawn { + spawn_snapshot_refresh(args.clone(), clone_snapshot_cache(cache)); + } + snapshot_to_return +} + +fn spawn_snapshot_refresh(args: Cli, cache: SnapshotCache) { + thread::spawn(move || { + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_snapshot(&args))); + let mut guard = cache.lock().expect("snapshot cache mutex poisoned"); + match result { + Ok(snapshot) => { + guard.entry = Some(CachedSnapshot { + created: Instant::now(), + snapshot, + }); + } + Err(_) => { + eprintln!("detmir-portal snapshot cache refresh panicked"); + } + } + guard.refresh_in_progress = false; + }); +} + pub(crate) fn build_fast_health(cache: &SnapshotCache) -> HealthResponse { match cache.try_lock() { Ok(guard) => guard + .entry .as_ref() .map(|cached| build_health(&cached.snapshot)) .unwrap_or_else(lightweight_health), diff --git a/adk-rust/crates/detmir-portal/src/workforce_kpi_explain.rs b/adk-rust/crates/detmir-portal/src/workforce_kpi_explain.rs index 05a744d..eeaff8c 100644 --- a/adk-rust/crates/detmir-portal/src/workforce_kpi_explain.rs +++ b/adk-rust/crates/detmir-portal/src/workforce_kpi_explain.rs @@ -549,6 +549,7 @@ mod tests { }; Snapshot { generated_at_utc: "2026-06-07T10:00:00Z".to_string(), + dlp_module_enabled: true, detmir_status: SourceStatus { ok: true, status: "OK".to_string(), diff --git a/adk-rust/crates/detmir-readiness/src/main.rs b/adk-rust/crates/detmir-readiness/src/main.rs index 8125087..7d618e2 100644 --- a/adk-rust/crates/detmir-readiness/src/main.rs +++ b/adk-rust/crates/detmir-readiness/src/main.rs @@ -18,7 +18,9 @@ const DEFAULT_AW_ENV_FILE: &str = "/etc/activitywatch/aw-server.env"; const DEFAULT_GRAFANA_ENV_FILE: &str = "/etc/detmir-grafana-check.env"; const DEFAULT_GRAFANA_URL: &str = "http://127.0.0.1:3000"; const DEFAULT_GRAFANA_DATASOURCE_UID: &str = "influxdb_aw"; -const DEFAULT_SYSTEMD_SERVICES: &str = "activitywatch-server,aw-worktime-api,aw-worktime-influx-exporter.timer,aw-dlp-influx-exporter.timer"; +const DEFAULT_SYSTEMD_SERVICES: &str = + "activitywatch-server,aw-worktime-api,aw-worktime-influx-exporter.timer"; +const DEFAULT_DLP_SYSTEMD_SERVICES: &str = "aw-dlp-influx-exporter.timer"; const DEFAULT_RETENTION_DAYS: i64 = 30; #[derive(Debug, Parser)] @@ -218,14 +220,26 @@ fn run(cli: &Cli) -> Result { let mut checks = Vec::new(); let worktime = influx_config(&aw_env, "AW_WORKTIME_INFLUX"); let dlp = influx_config(&aw_env, "AW_DLP_INFLUX"); + let dlp_enabled = env_bool(&aw_env, "AW_DLP_ENABLED", true); checks.push(check_influx_env(&worktime, cli.allow_disabled_influx)); - checks.push(check_influx_env(&dlp, cli.allow_disabled_influx)); + if dlp_enabled { + checks.push(check_influx_env(&dlp, cli.allow_disabled_influx)); + } else { + checks.push(warn( + "env:AW_DLP_INFLUX", + "DLP Influx runtime disabled by AW_DLP_ENABLED=false", + json!({"enabled": false, "mode": "disabled"}), + )); + } if cli.skip_systemd { checks.push(warn("systemd", "systemd checks skipped", json!({}))); } else { - checks.extend(check_systemd_services(&cli.systemd_services)); + checks.extend(check_systemd_services(&systemd_services_for_mode( + &cli.systemd_services, + dlp_enabled, + ))); } if cli.skip_influx_write { @@ -236,7 +250,15 @@ fn run(cli: &Cli) -> Result { )); } else { checks.push(check_influx_write(&client, "worktime", &worktime)); - checks.push(check_influx_write(&client, "dlp", &dlp)); + if dlp_enabled { + checks.push(check_influx_write(&client, "dlp", &dlp)); + } else { + checks.push(warn( + "influx:write:dlp", + "DLP write probe skipped because DLP is disabled", + json!({"enabled": false, "mode": "disabled"}), + )); + } } if cli.skip_grafana { @@ -270,7 +292,7 @@ fn run(cli: &Cli) -> Result { git_commit: cli.git_commit.clone(), counts, checks, - limitations: build_limitations(cli), + limitations: build_limitations(cli, dlp_enabled), }) } @@ -338,6 +360,20 @@ fn split_csv(value: &str) -> Vec { .collect() } +fn systemd_services_for_mode(csv: &str, dlp_enabled: bool) -> String { + let mut services = split_csv(csv); + if dlp_enabled { + for service in split_csv(DEFAULT_DLP_SYSTEMD_SERVICES) { + if !services.iter().any(|item| item == &service) { + services.push(service); + } + } + } else { + services.retain(|service| !service.contains("dlp")); + } + services.into_iter().collect::>().join(",") +} + fn hostname() -> String { Command::new("hostname") .output() @@ -348,7 +384,7 @@ fn hostname() -> String { .unwrap_or_else(|| "unknown".to_string()) } -fn build_limitations(cli: &Cli) -> Vec { +fn build_limitations(cli: &Cli, dlp_enabled: bool) -> Vec { let mut limitations = Vec::new(); limitations.push( "Проверка подтверждает состояние runtime на момент формирования акта и не заменяет аудит конфигурации, нагрузочное тестирование или приемочные испытания заказчика.".to_string(), @@ -377,6 +413,11 @@ fn build_limitations(cli: &Cli) -> Vec { .to_string(), ); } + if !dlp_enabled { + limitations.push( + "DLP runtime отключен штатно через AW_DLP_ENABLED=false; readiness не считает DLP services/timers и DLP Influx write обязательными.".to_string(), + ); + } limitations } diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 09f53db..e95c65d 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -19,6 +19,22 @@ aw_db_vacuum_timer_enabled: false tasks: + - name: Refuse inconsistent DLP resource profile + ansible.builtin.assert: + that: + - aw_dlp_profile | default('core_only') in ['core_only', 'light', 'on_demand', 'full'] + - (aw_dlp_profile | default('core_only') == 'core_only') or (aw_dlp_enabled | default(false) | bool) + - (aw_dlp_enabled | default(false) | bool) or not ( + aw_dlp_influx_enabled | default(false) | bool + or aw_dlp_ioc_enabled | default(false) | bool + or aw_dlp_policy_engine_enabled | default(false) | bool + or aw_dlp_content_analysis_enabled | default(false) | bool + or aw_dlp_integrations_enabled | default(false) | bool + or aw_dlp_case_management_enabled | default(false) | bool + or aw_dlp_compliance_enabled | default(false) | bool + ) + fail_msg: "Inconsistent DLP profile: keep aw_dlp_enabled=false with all DLP component flags false, or explicitly choose aw_dlp_enabled=true and aw_dlp_profile=light|on_demand|full." + - name: Установить базовые пакеты ansible.builtin.apt: name: @@ -78,6 +94,7 @@ - "{{ aw_server_data_dir }}/backups" - "{{ aw_server_data_dir }}/slo" - "{{ aw_server_data_dir }}/browser-smoke" + - "{{ aw_security_finding_executor_work_dir | default(aw_server_data_dir ~ '/security-finding-executor') }}" - "{{ aw_rus_health_state_dir }}" - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" @@ -108,6 +125,7 @@ - "{{ aw_server_data_dir }}/backups" - "{{ aw_server_data_dir }}/slo" - "{{ aw_server_data_dir }}/browser-smoke" + - "{{ aw_security_finding_executor_work_dir | default(aw_server_data_dir ~ '/security-finding-executor') }}" - "{{ aw_rus_health_state_dir }}" - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" @@ -713,7 +731,9 @@ - aw_effective_dlp_influx_token | length > 0 - (aw_effective_dlp_influx_token | string | lower | regex_search('^(change_me|changeme|replace-me|replace_me|token|secret|password|api_key|influx_token|write_token|your_.*|<.*>)$')) is none fail_msg: "aw_dlp_influx_enabled=true, но token пуст и в локальном env, и в текущем /etc/activitywatch/aw-server.env. Exporter будет падать и Grafana не получит DLP-ряды." - when: aw_dlp_influx_enabled | default(false) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_influx_enabled | default(false) | bool - name: Проверить destination для AW worktime Influx exporter ansible.builtin.assert: @@ -745,7 +765,9 @@ - (aw_dlp_influx_hosts | default('') | string | length) > 0 - "'WINDOWS_USER_EXAMPLE' not in (aw_dlp_influx_hosts | default('') | string)" fail_msg: "aw_dlp_influx_enabled=true, но URL/org/bucket/hosts похожи на public example/TEST-NET значения. Задайте live значения в private inventory/env, не в public repo." - when: aw_dlp_influx_enabled | default(false) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_influx_enabled | default(false) | bool - name: Записать /etc/activitywatch/aw-server.env перед хотфиксами ansible.builtin.copy: @@ -764,7 +786,7 @@ AW_SERVER_GROUP={{ aw_server_group }} AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }} AW_WORKTIME_TZ={{ aw_worktime_timezone }} - AW_WORKTIME_HOST={{ aw_effective_worktime_host | default(aw_effective_monitored_windows_hostname | default('SHARKON2025')) }} + AW_WORKTIME_HOST={{ aw_effective_worktime_host | default(aw_effective_monitored_windows_hostname | default('HOST-EXAMPLE')) }} AW_WORKTIME_EVENTS_LIMIT={{ aw_worktime_events_limit | default(5000) }} AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS={{ aw_worktime_aw_http_timeout_seconds | default(6) }} AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS={{ aw_worktime_events_cache_ttl_seconds | default(300) }} @@ -787,7 +809,7 @@ AW_WORKTIME_INFLUX_URL={{ aw_worktime_influx_url | default('') }} AW_WORKTIME_INFLUX_ORG={{ aw_worktime_influx_org | default('proxmox') }} AW_WORKTIME_INFLUX_BUCKET={{ aw_worktime_influx_bucket | default('aw_metrics') }} - AW_WORKTIME_INFLUX_HOSTS={{ aw_worktime_influx_hosts | default('SHARKON2025') }} + AW_WORKTIME_INFLUX_HOSTS={{ aw_worktime_influx_hosts | default(aw_effective_monitored_windows_hostname | default('HOST-EXAMPLE')) }} AW_WORKTIME_INFLUX_DAYS={{ aw_worktime_influx_days | default('today,yesterday') }} AW_WORKTIME_INFLUX_TOKEN={{ aw_effective_worktime_influx_token | default('') }} AW_WORKTIME_MANAGEMENT_HISTORY_DIR={{ aw_worktime_management_history_dir | default(aw_server_data_dir ~ '/worktime-management-history') }} @@ -798,11 +820,28 @@ AW_WORKTIME_MANAGER_TREND_DELTA_PCT={{ aw_worktime_manager_trend_delta_pct | default(10) }} AW_WORKTIME_MANAGER_OFF_HOURS_THRESHOLD_SECONDS={{ aw_worktime_manager_off_hours_threshold_seconds | default(1800) }} AW_WORKTIME_MANAGER_INTERPRETATION_POLICY={{ aw_worktime_interpretation_policy_path | default('/etc/activitywatch/worktime-interpretation-policy.json') }} - AW_DLP_INFLUX_ENABLED={{ 'true' if (aw_dlp_influx_enabled | default(false) | bool) else 'false' }} + AW_DLP_ENABLED={{ 'true' if (aw_dlp_enabled | default(false) | bool) else 'false' }} + AW_DLP_PROFILE={{ aw_dlp_profile | default('core_only') }} + AW_DLP_DISABLED_REASON={{ aw_dlp_disabled_reason | default('') }} + AW_DLP_DISABLED_SINCE={{ aw_dlp_disabled_since | default('') }} + AW_DLP_GUARD_ENABLED={{ 'true' if (aw_dlp_light_guard_enabled | default(true) | bool) else 'false' }} + AW_DLP_GUARD_STATE_DIR={{ aw_dlp_light_guard_state_dir | default(aw_server_data_dir ~ '/health') }} + AW_DLP_GUARD_LOAD_RATIO={{ aw_dlp_light_guard_load_ratio | default('1.50') }} + AW_DLP_GUARD_MEM_AVAILABLE_PCT_MIN={{ aw_dlp_light_guard_mem_available_pct_min | default('15') }} + AW_DLP_GUARD_IOWAIT_PCT_MAX={{ aw_dlp_light_guard_iowait_pct_max | default('20') }} + AW_DLP_GUARD_STRIKES_REQUIRED={{ aw_dlp_light_guard_strikes_required | default(3) }} + AW_DLP_CONTROL_BIN=/usr/local/bin/detmir-dlp-runtime-control + AW_CONTAINMENT_ENABLED={{ 'true' if (aw_containment_enabled | default(false) | bool) else 'false' }} + AW_CONTAINMENT_MODE={{ aw_containment_mode | default('shadow') }} + AW_CONTAINMENT_POLICY={{ aw_containment_policy_path | default('/etc/activitywatch/containment-policy.json') }} + AW_CONTAINMENT_DEFAULT_TTL_MINUTES={{ aw_containment_default_ttl_minutes | default(60) }} + AW_CONTAINMENT_REQUIRE_ADMIN_CHANNEL_CHECK={{ 'true' if (aw_containment_require_admin_channel_check | default(true) | bool) else 'false' }} + AW_CONTAINMENT_ALLOW_AUTO_FOR_SERVERS={{ 'true' if (aw_containment_allow_auto_for_servers | default(false) | bool) else 'false' }} + AW_DLP_INFLUX_ENABLED={{ 'true' if ((aw_dlp_enabled | default(false) | bool) and (aw_dlp_influx_enabled | default(false) | bool)) else 'false' }} AW_DLP_INFLUX_URL={{ aw_dlp_influx_url | default('') }} AW_DLP_INFLUX_ORG={{ aw_dlp_influx_org | default('proxmox') }} AW_DLP_INFLUX_BUCKET={{ aw_dlp_influx_bucket | default('aw_metrics') }} - AW_DLP_INFLUX_HOSTS={{ aw_dlp_influx_hosts | default('SHARKON2025') }} + AW_DLP_INFLUX_HOSTS={{ aw_dlp_influx_hosts | default(aw_effective_monitored_windows_hostname | default('HOST-EXAMPLE')) }} AW_DLP_INFLUX_LOOKBACK_DAYS={{ aw_dlp_influx_lookback_days | default(30) }} AW_DLP_INFLUX_EVENT_LIMIT={{ aw_dlp_influx_event_limit | default(2000) }} AW_DLP_INFLUX_TOKEN={{ aw_effective_dlp_influx_token | default('') }} @@ -820,6 +859,7 @@ AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS={{ aw_rus_health_session_events_max_age_seconds | default(86400) }} AW_RUS_HEALTH_GUARD_MAX_AGE_SECONDS={{ aw_rus_health_guard_max_age_seconds | default(300) }} AW_RUS_HEALTH_GUARD_REQUIRED={{ 1 if (aw_rus_health_guard_required | default(true) | bool) else 0 }} + AW_RUS_HEALTH_RDP_TCP_REQUIRED={{ 'true' if (aw_rus_health_rdp_tcp_required | default(true) | bool) else 'false' }} AW_RUS_SLO_STATE_DIR={{ aw_server_data_dir }}/slo AW_RUS_SLO_AW_BASE=http://127.0.0.1:5600 AW_RUS_SLO_WORKTIME_BASE={{ aw_rus_health_worktime_api_base | default('http://127.0.0.1:5610') }} @@ -837,6 +877,104 @@ AW_HAYABUSA_TELEGRAM_MIN_SEVERITY={{ aw_hayabusa_telegram_min_severity | default('high') }} AW_HAYABUSA_TELEGRAM_BOT_TOKEN={{ aw_hayabusa_telegram_bot_token | default('') }} AW_HAYABUSA_TELEGRAM_CHAT_IDS={{ aw_hayabusa_telegram_chat_ids | default('') }} + AW_SECURITY_FINDING_INBOX_ENABLED={{ 'true' if (aw_security_finding_inbox_enabled | default(false) | bool) else 'false' }} + AW_SECURITY_FINDING_INBOX_REQUIRED={{ 'true' if (aw_security_finding_inbox_required | default(false) | bool) else 'false' }} + AW_SECURITY_FINDING_INBOX_BIN={{ aw_security_finding_inbox_bin | default('/usr/local/bin/security-finding-inbox') }} + AW_SECURITY_FINDING_INBOX_MIN_SEVERITY={{ aw_security_finding_inbox_min_severity | default('medium') }} + AW_SECURITY_FINDING_EXECUTOR_WORK_DIR={{ aw_security_finding_executor_work_dir | default(aw_server_data_dir ~ '/security-finding-executor') }} + AW_SECURITY_FINDING_EXECUTOR_LOCK={{ aw_security_finding_executor_lock | default('/var/lock/aw-security-finding-executor.lock') }} + AW_CONTAINMENT_ENGINE_BIN={{ aw_containment_engine_bin | default('/usr/local/bin/containment-engine') }} + AW_CONTAINMENT_MANAGEMENT_ALLOWLIST={{ aw_containment_management_allowlist | default('') }} + AW_CONTAINMENT_BLOCKED_REMOTE_ADDRESSES={{ aw_containment_blocked_remote_addresses | default('') }} + + - name: Установить runtime control для optional DLP контура + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/detmir_dlp_runtime_control.sh" + dest: /usr/local/bin/detmir-dlp-runtime-control + owner: root + group: root + mode: "0755" + + - name: Установить load guard для lightweight DLP контура + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/detmir_dlp_load_guard.sh" + dest: /usr/local/bin/detmir-dlp-load-guard + owner: root + group: root + mode: "0755" + + - name: Установить systemd unit DLP load guard + ansible.builtin.copy: + dest: /etc/systemd/system/detmir-dlp-load-guard.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=DetMir lightweight DLP load guard + After=activitywatch-server.service + + [Service] + Type=oneshot + EnvironmentFile=-/etc/activitywatch/aw-server.env + ExecStart=/usr/local/bin/detmir-dlp-load-guard + Nice=10 + IOSchedulingClass=best-effort + IOSchedulingPriority=7 + TimeoutStartSec=45 + + - name: Установить systemd timer DLP load guard + ansible.builtin.copy: + dest: /etc/systemd/system/detmir-dlp-load-guard.timer + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Run DetMir lightweight DLP load guard + + [Timer] + OnBootSec=3min + OnUnitActiveSec=1min + AccuracySec=30s + Persistent=false + + [Install] + WantedBy=timers.target + + - name: Включить DLP load guard timer + ansible.builtin.systemd: + name: detmir-dlp-load-guard.timer + enabled: true + state: started + daemon_reload: true + when: aw_dlp_light_guard_enabled | default(true) | bool + + - name: Отключить DLP load guard timer, если guard явно выключен + ansible.builtin.systemd: + name: detmir-dlp-load-guard.timer + enabled: false + state: stopped + daemon_reload: true + failed_when: false + when: not (aw_dlp_light_guard_enabled | default(true) | bool) + + - name: Создать каталог containment policy + ansible.builtin.file: + path: "{{ (aw_containment_policy_path | default('/etc/activitywatch/containment-policy.json')) | dirname }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Установить default containment policy, если live policy отсутствует + ansible.builtin.copy: + src: "{{ aw_repo_root }}/configs/containment-policy.example.json" + dest: "{{ aw_containment_policy_path | default('/etc/activitywatch/containment-policy.json') }}" + owner: root + group: root + mode: "0644" + force: false - name: Создать каталог DLP policy engine ansible.builtin.file: @@ -845,7 +983,9 @@ owner: "{{ aw_server_user }}" group: "{{ aw_server_group }}" mode: "0755" - when: aw_dlp_policy_engine_enabled | default(false) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_policy_engine_enabled | default(false) | bool - name: Установить systemd unit DLP policy engine ansible.builtin.copy: @@ -854,7 +994,9 @@ owner: root group: root mode: "0644" - when: aw_dlp_policy_engine_enabled | default(false) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_policy_engine_enabled | default(false) | bool - name: Проверить локальный Rust DLP policy engine ansible.builtin.stat: @@ -889,7 +1031,7 @@ owner: "{{ aw_server_user }}" group: "{{ aw_server_group }}" mode: "0755" - when: aw_dlp_content_analysis_enabled | default(true) | bool + when: aw_dlp_content_analysis_enabled | default(false) | bool - name: Скопировать файлы DLP content analysis ansible.builtin.copy: @@ -898,7 +1040,7 @@ owner: "{{ aw_server_user }}" group: "{{ aw_server_group }}" mode: "0644" - when: aw_dlp_content_analysis_enabled | default(true) | bool + when: aw_dlp_content_analysis_enabled | default(false) | bool - name: Установить wrapper запуска DLP content analysis через virtualenv ansible.builtin.copy: @@ -907,7 +1049,7 @@ owner: root group: root mode: "0755" - when: aw_dlp_content_analysis_enabled | default(true) | bool + when: aw_dlp_content_analysis_enabled | default(false) | bool - name: Проверить локальный Rust DLP content analyzer ansible.builtin.stat: @@ -915,7 +1057,7 @@ delegate_to: localhost register: dlp_content_analyzer_rust_binary become: false - when: aw_dlp_content_analysis_enabled | default(true) | bool + when: aw_dlp_content_analysis_enabled | default(false) | bool - name: Установить Rust DLP content analyzer ansible.builtin.copy: @@ -925,7 +1067,7 @@ group: root mode: "0755" when: - - aw_dlp_content_analysis_enabled | default(true) | bool + - aw_dlp_content_analysis_enabled | default(false) | bool - dlp_content_analyzer_rust_binary.stat.exists | default(false) - name: Создать virtualenv DLP content analysis @@ -933,13 +1075,13 @@ cmd: python3 -m venv /opt/activitywatch/dlp-content-analysis/.venv args: creates: /opt/activitywatch/dlp-content-analysis/.venv/bin/python - when: aw_dlp_content_analysis_enabled | default(true) | bool + when: aw_dlp_content_analysis_enabled | default(false) | bool - name: Установить зависимости DLP content analysis ansible.builtin.pip: requirements: /opt/activitywatch/dlp-content-analysis/requirements.txt virtualenv: /opt/activitywatch/dlp-content-analysis/.venv - when: aw_dlp_content_analysis_enabled | default(true) | bool + when: aw_dlp_content_analysis_enabled | default(false) | bool - name: Создать каталог DLP integrations ansible.builtin.file: @@ -948,7 +1090,9 @@ owner: "{{ aw_server_user }}" group: "{{ aw_server_group }}" mode: "0755" - when: aw_dlp_integrations_enabled | default(true) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_integrations_enabled | default(false) | bool - name: Скопировать файлы DLP integrations ansible.builtin.copy: @@ -961,7 +1105,9 @@ - cef-config.yaml - syslog-forwarder-config.yaml - webhook-config.yaml - when: aw_dlp_integrations_enabled | default(true) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_integrations_enabled | default(false) | bool - name: Создать state каталог DLP integrations ansible.builtin.file: @@ -970,7 +1116,9 @@ owner: "{{ aw_server_user }}" group: "{{ aw_server_group }}" mode: "0755" - when: aw_dlp_integrations_enabled | default(true) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_integrations_enabled | default(false) | bool - name: Установить systemd unit CEF exporter ansible.builtin.copy: @@ -979,7 +1127,9 @@ owner: root group: root mode: "0644" - when: aw_dlp_integrations_enabled | default(true) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_integrations_enabled | default(false) | bool - name: Установить systemd timer CEF exporter ansible.builtin.copy: @@ -988,7 +1138,9 @@ owner: root group: root mode: "0644" - when: aw_dlp_integrations_enabled | default(true) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_integrations_enabled | default(false) | bool - name: Проверить локальный Rust CEF exporter ansible.builtin.stat: @@ -996,14 +1148,16 @@ delegate_to: localhost register: dlp_cef_exporter_rust_binary become: false - when: aw_dlp_integrations_enabled | default(true) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_integrations_enabled | default(false) | bool - name: Требовать Rust CEF exporter artifact ansible.builtin.assert: that: - dlp_cef_exporter_rust_binary.stat.exists | default(false) fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-cef-exporter" - when: aw_dlp_integrations_enabled | default(true) | bool + when: aw_dlp_integrations_enabled | default(false) | bool - name: Установить Rust CEF exporter ansible.builtin.copy: @@ -1013,7 +1167,7 @@ group: root mode: "0755" when: - - aw_dlp_integrations_enabled | default(true) | bool + - aw_dlp_integrations_enabled | default(false) | bool - dlp_cef_exporter_rust_binary.stat.exists | default(false) - name: Установить systemd unit syslog forwarder @@ -1023,7 +1177,7 @@ owner: root group: root mode: "0644" - when: aw_dlp_integrations_enabled | default(true) | bool + when: aw_dlp_integrations_enabled | default(false) | bool - name: Установить systemd timer syslog forwarder ansible.builtin.copy: @@ -1032,7 +1186,7 @@ owner: root group: root mode: "0644" - when: aw_dlp_integrations_enabled | default(true) | bool + when: aw_dlp_integrations_enabled | default(false) | bool - name: Проверить локальный Rust syslog forwarder ansible.builtin.stat: @@ -1040,14 +1194,14 @@ delegate_to: localhost register: dlp_syslog_forwarder_rust_binary become: false - when: aw_dlp_integrations_enabled | default(true) | bool + when: aw_dlp_integrations_enabled | default(false) | bool - name: Требовать Rust syslog forwarder artifact ansible.builtin.assert: that: - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-syslog-forwarder" - when: aw_dlp_integrations_enabled | default(true) | bool + when: aw_dlp_integrations_enabled | default(false) | bool - name: Установить Rust syslog forwarder ansible.builtin.copy: @@ -1057,7 +1211,7 @@ group: root mode: "0755" when: - - aw_dlp_integrations_enabled | default(true) | bool + - aw_dlp_integrations_enabled | default(false) | bool - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) - name: Установить systemd unit webhook sender @@ -1067,7 +1221,7 @@ owner: root group: root mode: "0644" - when: aw_dlp_integrations_enabled | default(true) | bool + when: aw_dlp_integrations_enabled | default(false) | bool - name: Установить systemd timer webhook sender ansible.builtin.copy: @@ -1076,7 +1230,7 @@ owner: root group: root mode: "0644" - when: aw_dlp_integrations_enabled | default(true) | bool + when: aw_dlp_integrations_enabled | default(false) | bool - name: Проверить локальный Rust webhook sender ansible.builtin.stat: @@ -1084,14 +1238,14 @@ delegate_to: localhost register: dlp_webhook_sender_rust_binary become: false - when: aw_dlp_integrations_enabled | default(true) | bool + when: aw_dlp_integrations_enabled | default(false) | bool - name: Требовать Rust webhook sender artifact ansible.builtin.assert: that: - dlp_webhook_sender_rust_binary.stat.exists | default(false) fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-webhook-sender" - when: aw_dlp_integrations_enabled | default(true) | bool + when: aw_dlp_integrations_enabled | default(false) | bool - name: Установить Rust webhook sender ansible.builtin.copy: @@ -1101,7 +1255,7 @@ group: root mode: "0755" when: - - aw_dlp_integrations_enabled | default(true) | bool + - aw_dlp_integrations_enabled | default(false) | bool - dlp_webhook_sender_rust_binary.stat.exists | default(false) - name: Создать каталог DLP case management @@ -1111,7 +1265,9 @@ owner: "{{ aw_server_user }}" group: "{{ aw_server_group }}" mode: "0755" - when: aw_dlp_case_management_enabled | default(true) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_case_management_enabled | default(false) | bool - name: Установить systemd unit DLP case management ansible.builtin.copy: @@ -1120,7 +1276,9 @@ owner: root group: root mode: "0644" - when: aw_dlp_case_management_enabled | default(true) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_case_management_enabled | default(false) | bool - name: Проверить локальный Rust DLP case management ansible.builtin.stat: @@ -1128,14 +1286,14 @@ delegate_to: localhost register: aw_dlp_case_management_rust_binary become: false - when: aw_dlp_case_management_enabled | default(true) | bool + when: aw_dlp_case_management_enabled | default(false) | bool - name: Требовать Rust DLP case management artifact ansible.builtin.assert: that: - aw_dlp_case_management_rust_binary.stat.exists | default(false) fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-case-management" - when: aw_dlp_case_management_enabled | default(true) | bool + when: aw_dlp_case_management_enabled | default(false) | bool - name: Установить Rust DLP case management ansible.builtin.copy: @@ -1145,7 +1303,7 @@ group: root mode: "0755" when: - - aw_dlp_case_management_enabled | default(true) | bool + - aw_dlp_case_management_enabled | default(false) | bool - aw_dlp_case_management_rust_binary.stat.exists | default(false) - name: Создать каталоги DLP compliance @@ -1159,7 +1317,9 @@ - /opt/activitywatch/dlp-compliance - /opt/activitywatch/dlp-compliance/templates - "{{ aw_dlp_compliance_report_dir }}" - when: aw_dlp_compliance_enabled | default(true) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_compliance_enabled | default(false) | bool - name: Скопировать файлы DLP compliance ansible.builtin.copy: @@ -1173,7 +1333,9 @@ - { src: "templates/pci-dss-report.html", dest: "/opt/activitywatch/dlp-compliance/templates/pci-dss-report.html", mode: "0644" } - { src: "report-scheduler.service", dest: "/etc/systemd/system/aw-dlp-report-scheduler.service", mode: "0644" } - { src: "report-scheduler.timer", dest: "/etc/systemd/system/aw-dlp-report-scheduler.timer", mode: "0644" } - when: aw_dlp_compliance_enabled | default(true) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_compliance_enabled | default(false) | bool - name: Проверить локальный Rust DLP compliance ansible.builtin.stat: @@ -1181,14 +1343,18 @@ delegate_to: localhost register: aw_dlp_compliance_rust_binary become: false - when: aw_dlp_compliance_enabled | default(true) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_compliance_enabled | default(false) | bool - name: Требовать Rust DLP compliance artifact ansible.builtin.assert: that: - aw_dlp_compliance_rust_binary.stat.exists | default(false) fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-compliance" - when: aw_dlp_compliance_enabled | default(true) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_compliance_enabled | default(false) | bool - name: Установить Rust DLP compliance ansible.builtin.copy: @@ -1198,7 +1364,7 @@ group: root mode: "0755" when: - - aw_dlp_compliance_enabled | default(true) | bool + - aw_dlp_compliance_enabled | default(false) | bool - aw_dlp_compliance_rust_binary.stat.exists | default(false) - name: Проверить локальный Rust dlp-admin-cli @@ -1437,6 +1603,47 @@ mode: "0755" when: dlp_health_check_rust_binary.stat.exists | default(false) + - name: Проверить локальный Rust containment-engine + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/containment-engine" + delegate_to: localhost + register: containment_engine_rust_binary + become: false + + - name: Установить Rust containment-engine + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/containment-engine" + dest: /usr/local/bin/containment-engine + owner: root + group: root + mode: "0755" + when: containment_engine_rust_binary.stat.exists | default(false) + + - name: Проверить локальный Rust security-finding-inbox + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/security-finding-inbox" + delegate_to: localhost + register: security_finding_inbox_rust_binary + become: false + + - name: Установить Rust security-finding-inbox + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/security-finding-inbox" + dest: /usr/local/bin/security-finding-inbox + owner: root + group: root + mode: "0755" + when: security_finding_inbox_rust_binary.stat.exists | default(false) + + - name: Установить systemd unit Security Finding Inbox executor + ansible.builtin.copy: + src: "{{ aw_repo_root }}/ops/systemd/aw-security-finding-executor.service" + dest: /etc/systemd/system/aw-security-finding-executor.service + owner: root + group: root + mode: "0644" + notify: Перезагрузить systemd + - name: Проверить локальный Rust AW-RUS healthd ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-rus-healthd" @@ -1644,7 +1851,9 @@ owner: root group: root mode: "0644" - when: aw_dlp_influx_enabled | default(false) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_influx_enabled | default(false) | bool - name: Проверить локальный Rust AW DLP Influx exporter ansible.builtin.stat: @@ -1652,14 +1861,18 @@ delegate_to: localhost register: aw_dlp_influx_exporter_rust_binary become: false - when: aw_dlp_influx_enabled | default(false) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_influx_enabled | default(false) | bool - name: Требовать Rust AW DLP Influx exporter artifact ansible.builtin.assert: that: - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-influx-exporter" - when: aw_dlp_influx_enabled | default(false) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_influx_enabled | default(false) | bool - name: Установить Rust AW DLP Influx exporter ansible.builtin.copy: @@ -1679,7 +1892,9 @@ owner: root group: root mode: "0644" - when: aw_dlp_influx_enabled | default(false) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_influx_enabled | default(false) | bool - name: Проверить локальный Rust DetMir readiness checker ansible.builtin.stat: @@ -1831,42 +2046,42 @@ name: aw-dlp-cef-exporter.timer enabled: true state: restarted - when: aw_dlp_integrations_enabled | default(true) | bool + when: aw_dlp_integrations_enabled | default(false) | bool - name: Включить и перезапустить timer syslog forwarder ansible.builtin.systemd: name: aw-dlp-syslog-forwarder.timer enabled: true state: restarted - when: aw_dlp_integrations_enabled | default(true) | bool + when: aw_dlp_integrations_enabled | default(false) | bool - name: Включить и перезапустить timer webhook sender ansible.builtin.systemd: name: aw-dlp-webhook-sender.timer enabled: true state: restarted - when: aw_dlp_integrations_enabled | default(true) | bool + when: aw_dlp_integrations_enabled | default(false) | bool - name: Включить и перезапустить DLP case management ansible.builtin.systemd: name: aw-dlp-case-management.service enabled: true state: restarted - when: aw_dlp_case_management_enabled | default(true) | bool + when: aw_dlp_case_management_enabled | default(false) | bool - name: Включить и перезапустить timer DLP compliance report ansible.builtin.systemd: name: aw-dlp-report-scheduler.timer enabled: true state: restarted - when: aw_dlp_compliance_enabled | default(true) | bool + when: aw_dlp_compliance_enabled | default(false) | bool - name: Выполнить разовый прогон DLP compliance report ansible.builtin.systemd: name: aw-dlp-report-scheduler.service state: started failed_when: false - when: aw_dlp_compliance_enabled | default(true) | bool + when: aw_dlp_compliance_enabled | default(false) | bool - name: Включить и перезапустить AW worktime API ansible.builtin.systemd: @@ -2233,6 +2448,11 @@ mode: "0755" when: dlp_aggregator_rust_binary.stat.exists | default(false) + - name: Удалить stale drop-in, переопределяющий lightweight DLP aggregator + ansible.builtin.file: + path: /etc/systemd/system/activitywatch-dlp-aggregator.service.d/20-rust-switch.conf + state: absent + - name: Установить systemd unit для агрегатора ansible.builtin.copy: dest: /etc/systemd/system/activitywatch-dlp-aggregator.service @@ -2241,7 +2461,7 @@ mode: "0644" content: | [Unit] - Description=ActivityWatch DLP Event Aggregator + Description=ActivityWatch Lightweight DLP Event Aggregator After=activitywatch-server.service [Service] @@ -2251,7 +2471,18 @@ ExecStart=/usr/local/bin/dlp-aggregator-rust \ --aw-url http://127.0.0.1:{{ aw_server_port }}/api/0 \ --sqlite-path {{ aw_server_data_dir }}/dlp_warehouse.sqlite \ - --state-path {{ aw_server_data_dir }}/dlp-aggregator-state.json + --state-path {{ aw_server_data_dir }}/dlp-aggregator-state.json \ + --bucket-prefixes {{ aw_dlp_aggregator_bucket_prefixes | default('aw-file-operations_,aw-dlp-incidents_') }} \ + --lookback-hours {{ aw_dlp_aggregator_lookback_hours | default(2) }} \ + --overlap-seconds {{ aw_dlp_aggregator_overlap_seconds | default(60) }} \ + --limit {{ aw_dlp_aggregator_limit | default(500) }} \ + --timeout {{ aw_dlp_aggregator_timeout_seconds | default(8) }} + Nice=10 + IOSchedulingClass=best-effort + IOSchedulingPriority=7 + CPUQuota={{ aw_dlp_aggregator_cpu_quota | default('10%') }} + MemoryMax={{ aw_dlp_aggregator_memory_max | default('256M') }} + TimeoutStartSec={{ (aw_dlp_aggregator_timeout_seconds | default(8) | int) + 15 }} [Install] WantedBy=multi-user.target @@ -2261,10 +2492,10 @@ dest: /etc/systemd/system/activitywatch-dlp-aggregator.timer content: | [Unit] - Description=Run ActivityWatch DLP Aggregator every 5 minutes + Description=Run ActivityWatch Lightweight DLP Aggregator [Timer] - OnCalendar=*:3/10:10 + OnCalendar={{ aw_dlp_aggregator_on_calendar | default('*:3/15:10') }} AccuracySec=30s RandomizedDelaySec=30s Persistent=false @@ -2278,9 +2509,14 @@ enabled: true state: started daemon_reload: true + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_light_collector_enabled | default(false) | bool - name: Настроить IOC enrichment из Hayabusa Sigma - when: aw_dlp_ioc_enabled | default(false) | bool + when: + - aw_dlp_enabled | default(false) | bool + - aw_dlp_ioc_enabled | default(false) | bool block: - name: Создать каталог IOC enrichment ansible.builtin.file: diff --git a/ansible/deploy_detmir_portal.yml b/ansible/deploy_detmir_portal.yml index 9eb46b2..ed27ede 100644 --- a/ansible/deploy_detmir_portal.yml +++ b/ansible/deploy_detmir_portal.yml @@ -15,8 +15,16 @@ detmir_portal_workforce_policy_path: "/etc/detmir-portal-workforce-policy.json" detmir_portal_ueba_policy_path: "/etc/detmir-portal-ueba-policy.yaml" detmir_portal_readiness_bundle_dir: "{{ detmir_portal_readiness_bundle_dir_override | default('/var/lib/activitywatch/health/readiness-bundle', true) }}" + detmir_portal_dlp_module_enabled: "{{ detmir_portal_dlp_module_enabled_override | default(false) }}" tasks: + - name: Refuse inconsistent DetMir portal DLP profile + ansible.builtin.assert: + that: + - detmir_portal_dlp_profile | default('core_only') in ['core_only', 'light', 'on_demand', 'full'] + - (detmir_portal_dlp_profile | default('core_only') != 'core_only') or not (detmir_portal_dlp_module_enabled | bool) + fail_msg: "Inconsistent DetMir portal DLP profile: core_only must keep DETMIR_PORTAL_DLP_MODULE_ENABLED=false." + - name: Check local detmir-portal binary ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/detmir-portal" @@ -54,6 +62,8 @@ DETMIR_PORTAL_UEBA_POLICY_PATH={{ detmir_portal_ueba_policy_path }} DETMIR_PORTAL_TIMEOUT_SECONDS=25 DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal + DETMIR_PORTAL_DLP_MODULE_ENABLED={{ detmir_portal_dlp_module_enabled | bool | ternary('true', 'false') }} + DETMIR_PORTAL_DLP_PROFILE={{ detmir_portal_dlp_profile | default('core_only') }} DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite DETMIR_PORTAL_EVIDENCE_ROOT=/var/lib/detmir-portal/evidence DETMIR_PORTAL_READINESS_BUNDLE_DIR={{ detmir_portal_readiness_bundle_dir }} @@ -65,6 +75,65 @@ CLICKHOUSE_USER={{ detmir_clickhouse_user | default('default') }} CLICKHOUSE_PASSWORD={{ detmir_clickhouse_password | default('') }} + - name: Install lightweight DLP warehouse sync helper + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/detmir_dlp_warehouse_sync.sh" + dest: /usr/local/bin/detmir-dlp-warehouse-sync + owner: root + group: root + mode: "0755" + + - name: Install lightweight DLP warehouse sync service + ansible.builtin.copy: + dest: /etc/systemd/system/detmir-dlp-warehouse-sync.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Sync lightweight DetMir DLP SQLite warehouse for portal + After=network-online.target + Wants=network-online.target + + [Service] + Type=oneshot + Environment=AW_DLP_WAREHOUSE_SOURCE_HOST={{ detmir_portal_dlp_warehouse_source_host | default('igor@10.10.10.13') }} + Environment=AW_DLP_WAREHOUSE_SOURCE_PATH={{ detmir_portal_dlp_warehouse_source_path | default('/var/lib/activitywatch/dlp_warehouse.sqlite') }} + Environment=AW_DLP_WAREHOUSE_DEST_PATH={{ detmir_portal_dlp_warehouse_dest_path | default('/var/lib/activitywatch/dlp_warehouse.sqlite') }} + Environment=AW_DLP_WAREHOUSE_SYNC_STATE_DIR={{ detmir_portal_dlp_warehouse_sync_state_dir | default('/var/lib/activitywatch/health') }} + ExecStart=/usr/local/bin/detmir-dlp-warehouse-sync + Nice=10 + IOSchedulingClass=best-effort + IOSchedulingPriority=7 + TimeoutStartSec=60 + + - name: Install lightweight DLP warehouse sync timer + ansible.builtin.copy: + dest: /etc/systemd/system/detmir-dlp-warehouse-sync.timer + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Run lightweight DetMir DLP SQLite warehouse sync + + [Timer] + OnBootSec=4min + OnUnitActiveSec={{ detmir_portal_dlp_warehouse_sync_interval | default('2min') }} + AccuracySec=30s + Persistent=false + + [Install] + WantedBy=timers.target + + - name: Enable lightweight DLP warehouse sync timer + ansible.builtin.systemd: + name: detmir-dlp-warehouse-sync.timer + enabled: true + state: started + daemon_reload: true + when: detmir_portal_dlp_module_enabled | bool + - name: Preserve local ClickHouse security-events settings when available ansible.builtin.shell: | set -euo pipefail @@ -117,7 +186,9 @@ state: absent loop: - /etc/systemd/system/detmir-portal.service.d/20-timeouts.conf + - /etc/systemd/system/detmir-portal.service.d/20-prod-timeout.conf - /etc/systemd/system/detmir-portal.service.d/30-warm-cache.conf + - /etc/systemd/system/detmir-portal.service.d/30-prewarm-after-start.conf register: detmir_portal_stale_overrides - name: Install initial workforce policy when absent @@ -174,6 +245,11 @@ WantedBy=multi-user.target register: detmir_portal_service_unit + - name: Remove stale detmir-portal timeout override + ansible.builtin.file: + path: /etc/systemd/system/detmir-portal.service.d/10-detmir-check-env.conf + state: absent + - name: Reload systemd ansible.builtin.systemd: daemon_reload: true diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index 87e2593..c6b4503 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -33,7 +33,10 @@ aw_worktime_manager_trend_min_points: 3 aw_worktime_manager_trend_delta_pct: 10 aw_worktime_manager_off_hours_threshold_seconds: 1800 aw_worktime_interpretation_policy_path: "/etc/activitywatch/worktime-interpretation-policy.json" -aw_dlp_influx_enabled: true +aw_dlp_profile: "core_only" +detmir_portal_dlp_profile: "light" +detmir_portal_dlp_module_enabled_override: true +aw_dlp_influx_enabled: false aw_dlp_influx_url: "http://192.0.2.10:8086" aw_dlp_influx_org: "proxmox" aw_dlp_influx_bucket: "aw_metrics" @@ -47,6 +50,7 @@ aw_worktime_host: "{{ aw_monitored_windows_hostname }}" aw_rus_health_worktime_api_base: "http://127.0.0.1:5610" aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health" aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation" +aw_rus_health_rdp_tcp_required: false aw_hayabusa_auto_case_enabled: true aw_hayabusa_auto_case_min_severity: "medium" aw_hayabusa_telegram_enabled: true @@ -65,22 +69,46 @@ aw_server_cors_origins: aw_apply_worktime_settings: true -aw_dlp_ioc_enabled: true +aw_dlp_ioc_enabled: false +aw_dlp_enabled: false +aw_dlp_disabled_reason: "" +aw_dlp_disabled_since: "" +aw_dlp_light_collector_enabled: false +aw_dlp_light_guard_enabled: true +aw_dlp_light_guard_load_ratio: "1.50" +aw_dlp_light_guard_mem_available_pct_min: "15" +aw_dlp_light_guard_iowait_pct_max: "20" +aw_dlp_light_guard_strikes_required: 3 +aw_dlp_light_guard_state_dir: "{{ aw_server_data_dir }}/health" +aw_dlp_aggregator_bucket_prefixes: "aw-file-operations_,aw-dlp-incidents_" +aw_dlp_aggregator_limit: 500 +aw_dlp_aggregator_lookback_hours: 2 +aw_dlp_aggregator_overlap_seconds: 60 +aw_dlp_aggregator_timeout_seconds: 8 +aw_dlp_aggregator_on_calendar: "*:3/15:10" +aw_dlp_aggregator_cpu_quota: "10%" +aw_dlp_aggregator_memory_max: "256M" +aw_containment_enabled: false +aw_containment_mode: "shadow" +aw_containment_policy_path: "/etc/activitywatch/containment-policy.json" +aw_containment_default_ttl_minutes: 60 +aw_containment_require_admin_channel_check: true +aw_containment_allow_auto_for_servers: false aw_dlp_ioc_workdir: "/opt/activitywatch/dlp-ioc" aw_dlp_ioc_rules_zip_url: "https://github.com/Yamato-Security/hayabusa-rules/archive/refs/heads/main.zip" aw_dlp_ioc_refresh_on_boot_sec: "5min" aw_dlp_ioc_refresh_interval: "6h" -aw_dlp_policy_engine_enabled: true +aw_dlp_policy_engine_enabled: false aw_dlp_policy_engine_bind_host: "0.0.0.0" aw_dlp_policy_engine_port: 5601 aw_dlp_policy_engine_db_path: "{{ aw_server_data_dir }}/dlp-policy-engine.sqlite" -aw_dlp_content_analysis_enabled: true -aw_dlp_integrations_enabled: true -aw_dlp_case_management_enabled: true +aw_dlp_content_analysis_enabled: false +aw_dlp_integrations_enabled: false +aw_dlp_case_management_enabled: false aw_dlp_case_bind_host: "0.0.0.0" aw_dlp_case_port: 5602 aw_dlp_case_db_path: "/opt/activitywatch/dlp-case-management/cases.db" -aw_dlp_compliance_enabled: true +aw_dlp_compliance_enabled: false aw_dlp_compliance_report_dir: "/opt/activitywatch/dlp-compliance/reports" aw_dlp_compliance_template_path: "/opt/activitywatch/dlp-compliance/templates/152-fz-report.html" aw_server_post_deploy_health_check_enabled: true diff --git a/aw-server/aw-server.env.example b/aw-server/aw-server.env.example index 910db38..5ab552a 100755 --- a/aw-server/aw-server.env.example +++ b/aw-server/aw-server.env.example @@ -16,7 +16,7 @@ AW_SERVER_GROUP=activitywatch AW_SERVER_PUBLIC_HOST=aw-server AW_WORKTIME_REPORT_BASE=http://aw-server:5610 AW_WORKTIME_TZ=Europe/Moscow -AW_WORKTIME_HOST=SHARKON2025 +AW_WORKTIME_HOST=HOST-EXAMPLE AW_WORKTIME_EVENTS_LIMIT=5000 AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS=6 AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS=300 @@ -33,6 +33,16 @@ AW_WORKTIME_MANAGEMENT_WARM_URL=http://127.0.0.1:5610/reports/worktime/managemen AW_WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS=70 # DLP IOC Configuration +AW_DLP_ENABLED=false +AW_DLP_PROFILE=core_only +AW_DLP_DISABLED_REASON=detmir_prod_resource_guardrail +AW_DLP_DISABLED_SINCE= +AW_CONTAINMENT_ENABLED=false +AW_CONTAINMENT_MODE=shadow +AW_CONTAINMENT_POLICY=/etc/activitywatch/containment-policy.json +AW_CONTAINMENT_DEFAULT_TTL_MINUTES=60 +AW_CONTAINMENT_REQUIRE_ADMIN_CHANNEL_CHECK=true +AW_CONTAINMENT_ALLOW_AUTO_FOR_SERVERS=false AW_DLP_IOC_DIR=/opt/activitywatch/dlp-ioc/output # DLP Policy Engine Configuration @@ -50,22 +60,24 @@ AW_HEALTH_CHECK_ENABLED=true AW_HEALTH_CHECK_INTERVAL=60 AW_EXPECT_START_OF_DAY=00:00 AW_EXPECT_ALWAYS_ACTIVE_PATTERN=aw-watcher-window -AW_EXPECT_LANDINGPAGE=/#/activity/SHARKON2025/view/ +AW_EXPECT_LANDINGPAGE=/#/activity/HOST-EXAMPLE/view/ AW_HEALTH_STRICT_FILEOPS=0 AW_MONITORED_WINDOWS_HOST= -AW_MONITORED_WINDOWS_HOSTNAME=SHARKON2025 +AW_MONITORED_WINDOWS_HOSTNAME=HOST-EXAMPLE AW_RUS_HEALTH_WORKTIME_API=http://127.0.0.1:5610 AW_RUS_HEALTH_STATE_DIR=/var/lib/activitywatch/health AW_RUS_HEALTH_VALIDATION_DIR=/var/lib/activitywatch/health/windows-validation AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS=86400 AW_RUS_HEALTH_GUARD_MAX_AGE_SECONDS=300 AW_RUS_HEALTH_GUARD_REQUIRED=1 +AW_RUS_HEALTH_RDP_TCP_REQUIRED=true +AW_RUS_HEALTH_WRAPPER_TIMEOUT_SECONDS=90 AW_RUS_SLO_AW_BASE=http://127.0.0.1:5600 AW_RUS_SLO_WORKTIME_BASE=http://127.0.0.1:5610 AW_RUS_SLO_TARGET_PERCENT=99.97 AW_BROWSER_SMOKE_AW_BASE=http://127.0.0.1:5600 AW_BROWSER_SMOKE_WORKTIME_BASE=http://127.0.0.1:5610 -AW_BROWSER_SMOKE_HOST=SHARKON2025 +AW_BROWSER_SMOKE_HOST=HOST-EXAMPLE AW_BROWSER_SMOKE_OUTPUT_DIR=/var/lib/activitywatch/browser-smoke AW_BROWSER_SMOKE_KEEP_RUNS=24 AW_BROWSER_SMOKE_ENGINE=chromium-cli @@ -79,6 +91,15 @@ AW_HAYABUSA_TELEGRAM_ENABLED=true AW_HAYABUSA_TELEGRAM_MIN_SEVERITY=high AW_HAYABUSA_TELEGRAM_BOT_TOKEN= AW_HAYABUSA_TELEGRAM_CHAT_IDS= +AW_SECURITY_FINDING_INBOX_ENABLED=false +AW_SECURITY_FINDING_INBOX_REQUIRED=false +AW_SECURITY_FINDING_INBOX_BIN=/usr/local/bin/security-finding-inbox +AW_SECURITY_FINDING_INBOX_MIN_SEVERITY=medium +AW_SECURITY_FINDING_EXECUTOR_WORK_DIR=/var/lib/activitywatch/security-finding-executor +AW_SECURITY_FINDING_EXECUTOR_LOCK=/var/lock/aw-security-finding-executor.lock +AW_CONTAINMENT_ENGINE_BIN=/usr/local/bin/containment-engine +AW_CONTAINMENT_MANAGEMENT_ALLOWLIST= +AW_CONTAINMENT_BLOCKED_REMOTE_ADDRESSES= # Integration Test Configuration AW_INTEGRATION_TEST_ENABLED=false diff --git a/docs/DETMIR_CURRENT_STATE_RU.md b/docs/DETMIR_CURRENT_STATE_RU.md index e0cc2e8..5af7fa7 100644 --- a/docs/DETMIR_CURRENT_STATE_RU.md +++ b/docs/DETMIR_CURRENT_STATE_RU.md @@ -26,19 +26,38 @@ logical host id остаётся `SHARKON2025`. Подробный post-restore `653b22b0fbf29a22f7de42ade7b689490b1de16fa07e785e4e0efd3078e7a3bc`. - Бэкап предыдущего binary на сервере: `/usr/local/bin/detmir-portal.bak.20260625T045640Z`. -- Runtime mode после phase 1 deploy: - `DETMIR_PORTAL_DLP_MODULE_ENABLED=false`. +- Runtime mode после 2026-06-30 prod hardening: + server-side DLP runtime зафиксирован в `core_only/disabled`. + Portal DLP UI/API module может оставаться включённым для чтения исторического + SQLite/evidence-среза, но это не означает запуск DLP collectors/exporters. - Server-side optional DLP runtime control: `AW_DLP_ENABLED=false|true` и `DETMIR_DLP_ENABLED=false|true`. +- Current resource profile: `AW_DLP_ENABLED=false`, + `AW_DLP_PROFILE=core_only`; возврат в `light` выполняется только вручную + после проверки нагрузки. - Runtime control/statistics script: `scripts/detmir_dlp_runtime_control.sh` / live `/usr/local/bin/detmir-dlp-runtime-control`. -- Live DLP runtime state after 2026-06-25 controlled disable: - `AW_DLP_ENABLED=false`, `AW_DLP_INFLUX_ENABLED=false`; - active/enabled DLP units: `0/0`. +- DLP runtime state after 2026-06-30 prod hardening: + `AW_DLP_ENABLED=false`, `AW_DLP_PROFILE=core_only`, + `AW_DLP_INFLUX_ENABLED=false`; optional DLP units should be + `inactive/disabled`. `detmir-dlp-load-guard.timer` remains enabled and active + as protection for any later operator re-enable. - Reason: DLP runtime materially increases Proxmox VM/LXC, InfluxDB, Grafana, - ClickHouse and AW server load. In production DetMir it is currently kept - disabled, but remains a documented optional module that can be enabled later. + ClickHouse and AW server load. In production DetMir the safe default is + `core_only`; `light` is a reconnectable profile, not the automatic default. +- Auto-disable guard: + `scripts/detmir_dlp_load_guard.sh` / live + `/usr/local/bin/detmir-dlp-load-guard`. При перегрузе переводит DLP в + `core_only` через runtime-control и пишет evidence в + `/var/lib/activitywatch/health/dlp-light-guard-state.json`. +- DLP warehouse sync для портала: + `scripts/detmir_dlp_warehouse_sync.sh` / live + `/usr/local/bin/detmir-dlp-warehouse-sync`. Доставляет локальный SQLite + snapshot на portal host для UEBA/DLP views без heavy DLP hot path. +- Loki CT is intentionally excluded from the current DetMir production resource + profile. It must not be returned by routine deploy/recovery while the goal is + to keep Proxmox VM/LXC load low. - Health после деплоя: `/healthz` возвращал `status=ok`. - Readiness после деплоя: `/readyz` возвращал `status=ready`. @@ -84,9 +103,11 @@ logical host id остаётся `SHARKON2025`. Подробный post-restore - DLP evidence, screenshots, endpoint signals, case review и forensics enrichment требуют больше CPU/IO/сетевых операций, чем Workforce core. -Вывод: DLP/evidence/forensics enrichment уже вынесен из обязательного hot path -phase 1 через `DETMIR_PORTAL_DLP_MODULE_ENABLED=false`, но полная оптимизация -тяжелого snapshot/prewarm остается отдельной инженерной задачей. +Вывод: DLP/evidence/forensics enrichment вынесен из обязательного hot path. +Phase 1 делал это через `DETMIR_PORTAL_DLP_MODULE_ENABLED=false`; текущий +lightweight-профиль оставляет DLP-status/UEBA-сигналы включенными без тяжелого +evidence/case/exporter path. Полная оптимизация тяжелого snapshot/prewarm +остается отдельной инженерной задачей. ## Целевая граница после переработки @@ -154,23 +175,45 @@ AW_DLP_ENABLED=true|false DETMIR_DLP_ENABLED=true|false ``` -Default остается `true`, чтобы существующее поведение не менялось без явного -решения администратора. Для ускоренного Workforce/operator режима допускается -`DETMIR_PORTAL_DLP_MODULE_ENABLED=false`; в этом режиме портал: +DetMir production default после 2026-06-30 hardening: +`AW_DLP_ENABLED=false` / `AW_DLP_PROFILE=core_only`. Portal DLP module may stay +enabled for historical/security views, but server-side DLP collectors/exporters +remain off. В этом режиме портал: - не читает DLP incident/case/review/audit файлы в основном report/operator path; -- отключает security-events backend внутри snapshot, не меняя сохраненные - ClickHouse credentials; -- возвращает disabled-state для DLP evidence API; -- не считает отсутствие DLP ошибкой Workforce core. +- использует только уже имеющийся лёгкий DLP-срез для UEBA и статуса; +- не включает evidence/case/exporters/Loki/Influx-heavy path; +- не считает отсутствие heavy DLP ошибкой Workforce core. -Ansible-параметр поставки: +Ansible-параметр поставки для старого disabled-профиля: ```yaml detmir_portal_dlp_module_enabled_override: false ``` +Для текущего safe production профиля: + +```yaml +detmir_portal_dlp_module_enabled_override: true +aw_dlp_profile: "core_only" +aw_dlp_enabled: false +aw_dlp_influx_enabled: false +aw_dlp_light_collector_enabled: false +aw_dlp_light_guard_enabled: true +``` + +Возврат в `light` выполняется только после resource check: + +```bash +sudo AW_DLP_DISABLED_REASON=operator_reenable_after_resource_check \ + /usr/local/bin/detmir-dlp-runtime-control set-profile light +sudo sed -i \ + -e 's/^AW_DLP_ENABLED=.*/AW_DLP_ENABLED=true/' \ + -e 's/^AW_DLP_PROFILE=.*/AW_DLP_PROFILE=light/' \ + /etc/activitywatch/aw-server.env +``` + Отдельный `detmir-portal-evidence` сервис не отключается этим флагом и остается самостоятельным контуром evidence/API при наличии отдельной конфигурации. @@ -189,6 +232,7 @@ Hayabusa/Velociraptor boundary: Server-side optional DLP runtime описан отдельно: - [DLP_OPTIONAL_RUNTIME_RU.md](DLP_OPTIONAL_RUNTIME_RU.md). +- [DLP_RESOURCE_PROFILES_RU.md](DLP_RESOURCE_PROFILES_RU.md). При `AW_DLP_ENABLED=false`: @@ -197,10 +241,27 @@ Server-side optional DLP runtime описан отдельно: - `detmir-check`, `check-aw-full` и `check-aw-data` не считают DLP buckets обязательными; - `detmir-readiness` не требует DLP Influx write и DLP systemd units; +- DLP profile changes use + `/usr/local/bin/detmir-dlp-runtime-control set-profile ` and keep a + rollback snapshot for `/usr/local/bin/detmir-dlp-runtime-control rollback`; - перед отключением и после отключения собираются JSON-срезы в `/var/lib/activitywatch/health/dlp-runtime-history/`, latest-срез остается в `/var/lib/activitywatch/health/dlp-runtime-state.json`. +При `AW_DLP_PROFILE=light`: + +- `activitywatch-dlp-aggregator.timer` собирает только ограниченный набор DLP + events в локальный SQLite warehouse; +- `detmir-dlp-warehouse-sync.timer` доставляет этот warehouse на portal host + атомарным snapshot; +- UEBA может учитывать `dlp_warn`/`dlp_fail` без запуска Loki/Influx-heavy path; +- `detmir-dlp-load-guard.timer` автоматически переводит профиль в `core_only` + при превышении порогов load/RAM/iowait; +- тяжёлые DLP units (`aw-dlp-influx-exporter`, report/syslog/webhook/CEF, + policy engine, case management, evidence API) должны оставаться выключенными. +- если `detmir-dlp-load-guard.timer` видит повторный перегруз, он автоматически + возвращает DLP runtime в `core_only`. + Live disable evidence 2026-06-25: - `dlp-health-check` returned `ok=true`, `dlp:mode=disabled`; @@ -307,6 +368,7 @@ curl -sS --max-time 5 http://10.10.10.2:8720/healthz - Не удалять DLP collectors и warehouse ради ускорения портала. - Не включать heavy DLP или Velociraptor server runtime автоматически при обычном deploy без ресурсного решения. +- Не включать Loki CT автоматически при обычном deploy/recovery DetMir. - Не менять UI/API несовместимо: новые поля должны быть additive. - Не заявлять completed DLP decoupling до live deploy и browser/API smoke. - Не позиционировать AWatch-rus как сертифицированную DLP/SIEM/EDR/СЗИ. diff --git a/docs/DLP_OPTIONAL_RUNTIME_RU.md b/docs/DLP_OPTIONAL_RUNTIME_RU.md index 1d8976f..de4ffca 100644 --- a/docs/DLP_OPTIONAL_RUNTIME_RU.md +++ b/docs/DLP_OPTIONAL_RUNTIME_RU.md @@ -1,14 +1,37 @@ # Optional DLP runtime for DetMir -Цель: DLP-контур должен отключаться управляемо, без ложных аварий в health/readiness и без автоматического подъема heavy-пайплайна, когда задача контура - снизить нагрузку на InfluxDB, Grafana и ClickHouse. +Цель: DLP-контур должен оставаться подключаемым, но production default для +DetMir сейчас `core_only/disabled`. Возврат в лёгкий режим выполняется вручную +после resource check; при перегрузе guard снова переводит DLP в `core_only`. + +Ресурсные профили и rollback-процедура описаны отдельно: +[DLP_RESOURCE_PROFILES_RU.md](DLP_RESOURCE_PROFILES_RU.md). ## Что отключается +Текущий production-профиль DetMir после 2026-06-30 prod hardening - +`core_only/disabled`: + +- `AW_DLP_ENABLED=false`; +- `AW_DLP_PROFILE=core_only`; +- `AW_DLP_INFLUX_ENABLED=false`; +- optional DLP timers/services inactive/disabled; +- `detmir-dlp-load-guard.timer` остаётся enabled/active как защита на случай + последующего operator re-enable; +- heavy DLP units, Influx exporter, evidence/case/report/integration units и + Loki остаются выключенными. + +Автоотключение выполняет `detmir-dlp-load-guard`: при превышении порогов +load/RAM/iowait он переводит DLP в `core_only` через +`detmir-dlp-runtime-control set-profile core_only`. После стабилизации контур +возвращается вручную командой `set-profile light`. + Штатный runtime off включает: - `AW_DLP_ENABLED=false` на AW server; - `DETMIR_DLP_ENABLED=false` в управляющем DetMir contour check; -- `DETMIR_PORTAL_DLP_MODULE_ENABLED=false` для portal UI/API DLP-модуля; +- portal UI/API DLP-модуль может оставаться включённым для исторического + SQLite/evidence-среза; это не запускает server-side DLP runtime; - остановку DLP timers/services: - `aw-dlp-influx-exporter.timer`; - `activitywatch-dlp-aggregator.timer`; @@ -137,10 +160,31 @@ AW_DLP_ENABLED=false check-aw-full ## Возврат DLP +Для DetMir предпочтительно возвращать не весь DLP сразу, а лёгкий профиль. +Перед этим проверить load/RAM/iowait на Proxmox/AW/Influx/Grafana/ClickHouse. + ```bash -sudo sed -i 's/^AW_DLP_ENABLED=.*/AW_DLP_ENABLED=true/' /etc/activitywatch/aw-server.env -sudo /usr/local/bin/detmir-dlp-runtime-control enable -sudo systemctl restart aw-worktime-api.service || true +sudo AW_DLP_DISABLED_REASON=operator_reenable_after_resource_check \ + /usr/local/bin/detmir-dlp-runtime-control set-profile light +sudo sed -i \ + -e 's/^AW_DLP_ENABLED=.*/AW_DLP_ENABLED=true/' \ + -e 's/^AW_DLP_PROFILE=.*/AW_DLP_PROFILE=light/' \ + -e 's/^AW_DLP_INFLUX_ENABLED=.*/AW_DLP_INFLUX_ENABLED=false/' \ + /etc/activitywatch/aw-server.env +``` + +Если профиль ухудшил состояние контура: + +```bash +sudo /usr/local/bin/detmir-dlp-runtime-control rollback +``` + +`on_demand` и `full` включаются только вручную после отдельного resource +preflight: + +```bash +sudo /usr/local/bin/detmir-dlp-runtime-control set-profile on_demand +sudo /usr/local/bin/detmir-dlp-runtime-control set-profile full ``` Для portal: @@ -187,13 +231,27 @@ production DetMir без отдельного ресурсного решени В inventory/group vars: ```yaml +aw_dlp_profile: "core_only" aw_dlp_enabled: false -aw_dlp_disabled_reason: "operator_disabled_to_reduce_influx_grafana_clickhouse_load" -aw_dlp_disabled_since: "2026-06-25" -detmir_portal_dlp_module_enabled_override: false +aw_dlp_influx_enabled: false +aw_dlp_light_collector_enabled: false +aw_dlp_light_guard_enabled: true +detmir_portal_dlp_module_enabled_override: true ``` -При `aw_dlp_enabled: false` playbook пишет `AW_DLP_ENABLED=false`, не включает DLP service/timer runtime и не должен возвращать DLP Influx exporter/aggregator в active state. +Для временного operator re-enable в `light`: + +```yaml +aw_dlp_profile: "light" +aw_dlp_enabled: true +aw_dlp_influx_enabled: false +aw_dlp_light_collector_enabled: true +aw_dlp_light_guard_enabled: true +``` + +При `aw_dlp_profile: light` playbook включает только лёгкий агрегатор, IOC +refresh и load guard. DLP Influx exporter, report/syslog/webhook/CEF, +policy/case/evidence и Loki не должны возвращаться в active state. ## Ограничения diff --git a/docs/DLP_RESOURCE_PROFILES_RU.md b/docs/DLP_RESOURCE_PROFILES_RU.md new file mode 100644 index 0000000..ed66558 --- /dev/null +++ b/docs/DLP_RESOURCE_PROFILES_RU.md @@ -0,0 +1,196 @@ +# DLP resource profiles for DetMir + +Дата фиксации: 2026-06-30. + +Цель: сохранить стабильный Workforce/AW hot path на малом DetMir Proxmox +контуре и оставить DLP подключаемым модулем. Loki CT в текущем production +resource profile отключен намеренно и не является обязательной зависимостью +AWatch-rus. + +## Профили + +### `core_only` + +Production default и аварийный/экономный профиль для DetMir. + +- DLP runtime: выключен. +- DLP Influx exporter: выключен. +- DLP aggregators/report/syslog/webhook/CEF/case/evidence units: выключены. +- Loki/Promtail: выключены. +- Workforce, Worktime, ActivityWatch, ClickHouse 1C, Grafana core, + Hayabusa/Security Finding Inbox: работают независимо от DLP. + +Назначение: безопасное состояние при перегрузе CPU/RAM/IOPS или при ручном +отключении DLP. + +### `light` + +Операторский re-enable профиль для DetMir после проверки ресурсов: лёгкий DLP +режим без Loki и без Influx-heavy path. + +- Разрешены `activitywatch-dlp-aggregator.timer` и + `aw-dlp-ioc-refresh.timer`. +- `dlp-aggregator-rust` собирает ограниченный срез из bucket-ов + `aw-file-operations_` и `aw-dlp-incidents_` в локальный + `dlp_warehouse.sqlite` для последующей UEBA-корреляции. +- `detmir-dlp-warehouse-sync.timer` доставляет SQLite warehouse на portal host + через атомарный snapshot, чтобы DetMir Portal/UEBA читали локальный файл, а + не блокировали AW server hot path. +- Для агрегатора заданы короткий lookback, малый event limit, timeout, + `CPUQuota` и `MemoryMax`. +- Evidence, screenshots, case management и exporters остаются выключенными. +- InfluxDB/Grafana/Loki не участвуют в hot path лёгкого DLP. +- Используется для ежедневной эксплуатации, когда нужны DLP-сигналы для UEBA, + но нельзя нагружать Proxmox/Influx/Grafana/ClickHouse. + +### `on_demand` + +Временный режим для конкретного инцидента или окна проверки. + +- Разрешены IOC refresh, policy engine, case management и evidence API. +- Influx exporter, CEF/syslog/webhook/report scheduler и aggregator остаются + выключенными, если администратор отдельно не выбрал `full`. +- После окна проверки профиль должен быть возвращён в `core_only`. + +### `full` + +Только вручную, только после resource preflight. + +- Может включать DLP Influx exporter, aggregator, reports, integrations, + policy/case и evidence. +- На DetMir не является штатным production режимом. +- Запрещено включать автоматически при обычном deploy/recovery. + +## Управление + +На AW server: + +```bash +sudo /usr/local/bin/detmir-dlp-runtime-control status +sudo /usr/local/bin/detmir-dlp-runtime-control set-profile core_only +sudo /usr/local/bin/detmir-dlp-runtime-control set-profile light +sudo /usr/local/bin/detmir-dlp-runtime-control set-profile on_demand +sudo /usr/local/bin/detmir-dlp-runtime-control set-profile full +sudo /usr/local/bin/detmir-dlp-load-guard +``` + +Перед каждым `set-profile` скрипт сохраняет rollback-снимок active/enabled +состояния DLP units: + +```text +/var/lib/activitywatch/health/dlp-runtime-rollback.state +``` + +Откат к предыдущему состоянию: + +```bash +sudo /usr/local/bin/detmir-dlp-runtime-control rollback +``` + +Важно: rollback восстанавливает только systemd active/enabled состояния DLP +units. Он не меняет retention, не удаляет данные и не включает Loki CT. + +## Автоотключение при перегрузе + +`detmir-dlp-load-guard.timer` запускает +`/usr/local/bin/detmir-dlp-load-guard`. Guard читает `/proc/loadavg`, +`/proc/meminfo` и `/proc/stat`; если load, свободная память или iowait выходят +за пороги несколько запусков подряд (`AW_DLP_GUARD_STRIKES_REQUIRED`, default +`3`), а DLP units активны, он переводит DLP в `core_only` через: + +```bash +AW_DLP_DISABLED_REASON=auto_disabled_by_dlp_load_guard: \ + /usr/local/bin/detmir-dlp-runtime-control set-profile core_only +``` + +State и история пишутся в: + +```text +/var/lib/activitywatch/health/dlp-light-guard-state.json +/var/lib/activitywatch/health/dlp-light-guard-history/ +``` + +Единичный IO/load spike фиксируется как `observe_overload`, но DLP не +отключается до достижения порога подряд. Guard не перезапускает +ActivityWatch/портал, не меняет маршруты, не трогает ClickHouse/Grafana и не +включает Loki. Возврат из `core_only` в `light` делает администратор после +стабилизации контура и проверки Proxmox/AW/Influx/Grafana/ClickHouse load. Если +перегруз повторится, guard снова переведёт профиль в `core_only`. + +## Доставка DLP warehouse на портал + +Portal читает DLP-срез из локального +`/var/lib/activitywatch/dlp_warehouse.sqlite`. На разнесённом контуре DetMir +этот файл создаётся на AW server, поэтому используется лёгкий sync: + +```bash +sudo systemctl start detmir-dlp-warehouse-sync.service +sudo systemctl status detmir-dlp-warehouse-sync.timer +sudo jq . /var/lib/activitywatch/health/dlp-warehouse-sync-state.json +``` + +Sync делает SQLite backup на AW server и атомарно заменяет локальный файл на +portal host. Он не запускает DLP evidence/case/exporters и не включает Loki. + +## Ansible defaults + +Для DetMir production defaults должны оставаться экономными и +самозащищающимися: + +```yaml +aw_dlp_profile: "core_only" +aw_dlp_enabled: false +aw_dlp_influx_enabled: false +aw_dlp_light_collector_enabled: false +aw_dlp_light_guard_enabled: true +detmir_portal_dlp_profile: "core_only" +detmir_portal_dlp_module_enabled_override: true +``` + +Для временного возврата в лёгкий профиль: + +```yaml +aw_dlp_profile: "light" +aw_dlp_enabled: true +aw_dlp_influx_enabled: false +aw_dlp_light_collector_enabled: true +aw_dlp_light_guard_enabled: true +detmir_portal_dlp_profile: "light" +detmir_portal_dlp_module_enabled_override: true +``` + +Все тяжёлые DLP component flags должны быть `false`, пока администратор явно не +выбрал `on_demand` или `full`. + +## Resource preflight перед `full` + +Перед временным включением `full` проверить: + +- Proxmox host load и steal/wait; +- свободную RAM и swap pressure; +- IOPS/latency storage; +- ClickHouse health и backlog ingest; +- InfluxDB/Grafana health, если они участвуют в выбранном профиле; +- ActivityWatch `/healthz`, Worktime API и portal latency; +- отсутствие старого Loki CT в autostart. + +Если любой core-сервис деградирует, DLP возвращается в `core_only`. + +## Проверка + +```bash +DETMIR_RESILIENCE_EXPECT_DLP_PROFILE=light \ +DETMIR_RESILIENCE_EXPECT_LOKI_OFF=1 \ +scripts/detmir_resilience_check.sh --repo +``` + +Live check на сервере в `light` должен показывать inactive для heavy DLP units +и Loki units. В `core_only` inactive должны быть все optional DLP units. + +## Запрещённые утверждения + +- Не заявлять, что AWatch-rus заменяет DLP/SIEM/EDR. +- Не заявлять, что Loki обязателен для DetMir production. +- Не заявлять DLP health OK, если DLP выключен. +- Не запускать автоматическое блокирование рабочих станций без approve/apply + workflow. diff --git a/docs/PRODUCTION_READINESS_RU.md b/docs/PRODUCTION_READINESS_RU.md index bb54c6e..c4c4b58 100644 --- a/docs/PRODUCTION_READINESS_RU.md +++ b/docs/PRODUCTION_READINESS_RU.md @@ -47,6 +47,8 @@ bounded payload/query limits и role-gate smoke. | `--slow-request-log-ms` | `AWATCH_PORTAL_SLOW_REQUEST_LOG_MS` | Порог медленного запроса для логов | | `--environment` | `AWATCH_PORTAL_ENVIRONMENT` | Безопасное имя окружения | | `--enabled-modules` | `AWATCH_PORTAL_ENABLED_MODULES` | Разрешенные модули портала | +| `--dlp-module-enabled` | `DETMIR_PORTAL_DLP_MODULE_ENABLED` | Включает DLP/security status для портала; может оставаться `true` для исторического SQLite/evidence-среза без запуска server-side DLP runtime | +| DLP resource profile | `AW_DLP_PROFILE`, `DETMIR_PORTAL_DLP_PROFILE` | Для DetMir production default `core_only`; `light` включается оператором после resource check | Ограничения применяются к тяжелым API: @@ -66,6 +68,21 @@ bounded payload/query limits и role-gate smoke. возвращает `400`; - слишком большое тело запроса возвращает `413`; - role gate возвращает `403`. +- при `DETMIR_PORTAL_DLP_MODULE_ENABLED=true` и `AW_DLP_PROFILE=core_only` + портал может показывать исторический DLP/security status без запуска + collectors/exporters. +- при `DETMIR_PORTAL_DLP_MODULE_ENABLED=true` и `AW_DLP_PROFILE=light` + Workforce core, `/healthz`, `/readyz`, `/api/reports` и `/api/operator` + должны оставаться доступными без тяжелого DLP/case/evidence чтения. +- при `AW_DLP_ENABLED=false` и `DETMIR_DLP_ENABLED=false` server-side + DLP health/readiness/checks должны возвращать контролируемый disabled-state, + а не пытаться поднять DLP Influx/exporter/aggregator/case runtime. + Runbook: [DLP_OPTIONAL_RUNTIME_RU.md](DLP_OPTIONAL_RUNTIME_RU.md). +- при `AW_DLP_PROFILE=light` активны только lightweight collector/IOC/guard; + Loki/DLP heavy runtime должен оставаться inactive. При перегрузе + `detmir-dlp-load-guard` переводит DLP в `core_only`; возврат выполняется + только через profile switch и rollback, см. + [DLP_RESOURCE_PROFILES_RU.md](DLP_RESOURCE_PROFILES_RU.md). ### Request ID, logs и metrics diff --git a/docs/PROJECT_STATUS_RU.md b/docs/PROJECT_STATUS_RU.md index d6eae68..61f8def 100644 --- a/docs/PROJECT_STATUS_RU.md +++ b/docs/PROJECT_STATUS_RU.md @@ -66,23 +66,25 @@ backup, registry-readiness документации, плана российск `653b22b0fbf29a22f7de42ade7b689490b1de16fa07e785e4e0efd3078e7a3bc`. - DetMir portal cold-start UI hang: mitigated. During cold/prewarm state the UI now shows `STALE / Первичный срез прогревается`, not endless loading. -- DetMir DLP hot-path boundary: phase 1 deployed on the portal service with - `DETMIR_PORTAL_DLP_MODULE_ENABLED=false`. +- DetMir DLP hot-path boundary: phase 1 deployed; current production runtime + uses `AW_DLP_ENABLED=false`, `AW_DLP_PROFILE=core_only`. The portal DLP module + may stay enabled for historical/security views, but server-side DLP + collectors/exporters are off. - DetMir optional DLP runtime controls: implemented in code/docs through `AW_DLP_ENABLED`, `DETMIR_DLP_ENABLED`, `scripts/detmir_dlp_runtime_control.sh` and - `docs/DLP_OPTIONAL_RUNTIME_RU.md`. -- DetMir optional DLP runtime live state: disabled on 2026-06-25 to reduce - InfluxDB/Grafana/ClickHouse/AW server load. Evidence: - `dlp-health-check=dlp:mode disabled`, `detmir-dlp=dlp:mode disabled`, - active/enabled DLP units `0/0`, history snapshots under - `/var/lib/activitywatch/health/dlp-runtime-history/`. -- DetMir DLP contour status: disabled for the current production resource - profile, not removed. It remains a documented optional module and must only be - re-enabled after explicit operator decision and Proxmox/InfluxDB/Grafana/ - ClickHouse capacity check. -- DetMir DLP buckets in manual full check: `SKIPPED` under - `AW_DLP_ENABLED=false`, not reported as dead. + `docs/DLP_OPTIONAL_RUNTIME_RU.md`. Resource profiles + `core_only|light|on_demand|full` and rollback are documented in + `docs/DLP_RESOURCE_PROFILES_RU.md`. +- DetMir optional DLP runtime state: 2026-06-25 controlled disable evidence is + retained; 2026-06-30 prod hardening keeps production in `core_only` by + default. `light` can be re-enabled by operator command after + Proxmox/InfluxDB/Grafana/ClickHouse capacity check. +- DetMir DLP contour status: server-side DLP collection is currently disabled; + heavy DLP remains optional and must only be enabled after explicit operator + decision and resource check. +- DetMir DLP auto-disable guard: `detmir-dlp-load-guard` records load/RAM/iowait + state and switches DLP to `core_only` if thresholds are exceeded. - DetMir RDP collector freshness after 2026-06-29 restore: physical RDP target is `192.168.100.19`, stable AW logical host id remains `SHARKON2025`. Buckets are fresh/inactive as expected, collector guard quarantine was reset, @@ -112,6 +114,9 @@ backup, registry-readiness документации, плана российск resource usage. Proxmox LXC `202 loki-logs` is stopped, active config has `onboot: 0`, and smoke checks skip Loki by default unless `AW_SMOKE_LOKI_ENABLED=1` is set. +- DetMir DLP rollback guard: `detmir-dlp-runtime-control set-profile` stores + the previous DLP systemd active/enabled state and `rollback` restores it. + Rollback does not start Loki CT. - DetMir restore baseline 2026-06-29: `docs/DETMIR_RESTORE_BASELINE_2026-06-29_RU.md`. - DetMir API smoke after phase 1: `/healthz` and `/readyz` OK; @@ -197,9 +202,9 @@ backup, registry-readiness документации, плана российск - External peer review remains pending. - Community adoption remains low until external contributors, public reviews and sustained third-party activity appear. -- DetMir DLP runtime disable is complete for the current live contour; deeper - long-term DLP product modularization and retention/cleanup policy remain - separate future work. +- DetMir lightweight DLP profile is implemented in repo defaults/scripts/docs; + heavy DLP modularization and retention/cleanup policy remain separate future + work. - DetMir RDP collector/session recovery after 2026-06-29 restore is verified by live smoke: `check-aw-full` reports `FRESH=8 STALE=0 DEAD=0`. diff --git a/scripts/check_detmir_rust_release_artifacts.sh b/scripts/check_detmir_rust_release_artifacts.sh index f5482cc..7d1efe2 100644 --- a/scripts/check_detmir_rust_release_artifacts.sh +++ b/scripts/check_detmir_rust_release_artifacts.sh @@ -4,8 +4,33 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" RELEASE_DIR="$TARGET_ROOT/release" +SCOPE="${CHECK_DETMIR_RUST_RELEASE_SCOPE:-prod-runtime}" -required_bins=( +prod_runtime_bins=( + aw-1c-ingest + aw-hayabusa-autoprocess-rust + aw-rus-healthd + aw-slo-monitor + aw-workforce-ingest + detmir-auto + detmir-portal + detmir-readiness + dlp-aggregator + dlp-case-management + dlp-cef-exporter + dlp-compliance + dlp-influx-exporter + dlp-policy-engine + dlp-syslog-forwarder + dlp-webhook-sender + worktime-api + worktime-autoheal + worktime-influx-exporter + worktime-prewarm + worktime-ui-bridge +) + +workspace_bins=( detmir-status detmir-adk-status detmir-check @@ -61,8 +86,23 @@ required_bins=( aw-hayabusa-from-windows-rust aw-hayabusa-autoprocess-rust aw-1c-ingest + containment-engine + security-finding-inbox ) +case "$SCOPE" in + prod-runtime) + required_bins=("${prod_runtime_bins[@]}") + ;; + workspace) + required_bins=("${workspace_bins[@]}") + ;; + *) + echo "Unsupported CHECK_DETMIR_RUST_RELEASE_SCOPE=$SCOPE; expected prod-runtime or workspace" >&2 + exit 2 + ;; +esac + missing=0 for bin in "${required_bins[@]}"; do if [[ -x "$RELEASE_DIR/$bin" ]]; then @@ -76,7 +116,7 @@ done if (( missing != 0 )); then cat >&2 </dev/null 2>&1; then + for unit in "${DLP_GUARDED_UNITS[@]}"; do + if systemctl is-active --quiet "$unit" 2>/dev/null; then + [[ "$first" -eq 1 ]] || printf ',' + first=0 + json_string "$unit" + fi + done + fi + printf ']' +} + +active_dlp_unit_count() { + local count=0 unit + if command -v systemctl >/dev/null 2>&1; then + for unit in "${DLP_GUARDED_UNITS[@]}"; do + if systemctl is-active --quiet "$unit" 2>/dev/null; then + count=$((count + 1)) + fi + done + fi + printf '%s\n' "$count" +} + +read_load1() { + awk '{print $1}' /proc/loadavg 2>/dev/null || printf '0' +} + +read_cpu_count() { + local cores + cores="$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf '1')" + if [[ ! "$cores" =~ ^[0-9]+$ || "$cores" -lt 1 ]]; then + cores=1 + fi + printf '%s\n' "$cores" +} + +read_mem_available_pct() { + awk ' + /^MemTotal:/ { total=$2 } + /^MemAvailable:/ { available=$2 } + END { + if (total > 0) { + printf "%.2f", (available * 100.0 / total) + } else { + printf "0" + } + } + ' /proc/meminfo 2>/dev/null || printf '0' +} + +read_cpu_sample() { + awk '/^cpu / { + idle=$5 + iowait=$6 + total=0 + for (i=2; i<=NF; i++) total += $i + printf "%s %s\n", total, iowait + exit + }' /proc/stat 2>/dev/null || printf '0 0' +} + +read_iowait_pct() { + local total1 wait1 total2 wait2 dtotal dwait + read -r total1 wait1 < <(read_cpu_sample) + sleep 1 + read -r total2 wait2 < <(read_cpu_sample) + dtotal=$((total2 - total1)) + dwait=$((wait2 - wait1)) + if [[ "$dtotal" -le 0 || "$dwait" -lt 0 ]]; then + printf '0' + return + fi + awk -v wait="$dwait" -v total="$dtotal" 'BEGIN { printf "%.2f", wait * 100.0 / total }' +} + +is_over_threshold() { + local value="$1" + local threshold="$2" + awk -v value="$value" -v threshold="$threshold" 'BEGIN { exit !(value > threshold) }' +} + +is_under_threshold() { + local value="$1" + local threshold="$2" + awk -v value="$value" -v threshold="$threshold" 'BEGIN { exit !(value < threshold) }' +} + +write_state() { + local action="$1" + local reason="$2" + local load1="$3" + local cores="$4" + local load_threshold="$5" + local mem_pct="$6" + local iowait_pct="$7" + local active_count="$8" + local active_units_json="$9" + local control_exit="${10}" + local strikes="${11:-0}" + local now stamp tmp history + + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + stamp="$(date -u +%Y%m%dT%H%M%SZ)" + mkdir -p "$STATE_DIR" "$STATE_HISTORY_DIR" + tmp="$(mktemp "${STATE_FILE}.tmp.XXXXXX")" + { + printf '{' + printf '"generated_at_utc":%s,' "$(json_string "$now")" + printf '"profile":%s,' "$(json_string "$PROFILE")" + printf '"guard_enabled":%s,' "$(json_string "$ENABLED")" + printf '"action":%s,' "$(json_string "$action")" + printf '"reason":%s,' "$(json_string "$reason")" + printf '"consecutive_overload_count":%s,' "$(number_or_null "$strikes")" + printf '"consecutive_overload_required":%s,' "$(number_or_null "$STRIKES_REQUIRED")" + printf '"control_bin":%s,' "$(json_string "$CONTROL_BIN")" + printf '"control_exit":%s,' "$(number_or_null "$control_exit")" + printf '"metrics":{' + printf '"load1":%s,' "$(number_or_null "$load1")" + printf '"cpu_count":%s,' "$(number_or_null "$cores")" + printf '"load_threshold":%s,' "$(number_or_null "$load_threshold")" + printf '"mem_available_pct":%s,' "$(number_or_null "$mem_pct")" + printf '"mem_available_pct_min":%s,' "$(number_or_null "$MEM_AVAILABLE_PCT_MIN")" + printf '"iowait_pct":%s,' "$(number_or_null "$iowait_pct")" + printf '"iowait_pct_max":%s' "$(number_or_null "$IOWAIT_PCT_MAX")" + printf '},' + printf '"active_dlp_unit_count":%s,' "$(number_or_null "$active_count")" + printf '"active_dlp_units":%s' "$active_units_json" + printf '}\n' + } >"$tmp" + mv "$tmp" "$STATE_FILE" + history="${STATE_HISTORY_DIR}/dlp-light-guard-${stamp}.json" + cp -a "$STATE_FILE" "$history" + printf 'dlp guard action=%s reason=%s state=%s history=%s\n' "$action" "$reason" "$STATE_FILE" "$history" +} + +main() { + local load1 cores load_threshold mem_pct iowait_pct active_count active_units_json overloaded reason control_exit strikes prev_strikes + + load1="$(read_load1)" + cores="$(read_cpu_count)" + load_threshold="$(awk -v cores="$cores" -v ratio="$LOAD_RATIO" 'BEGIN { printf "%.2f", cores * ratio }')" + mem_pct="$(read_mem_available_pct)" + iowait_pct="$(read_iowait_pct)" + active_units_json="$(active_dlp_units_json)" + active_count="$(active_dlp_unit_count)" + overloaded=0 + reason="within_thresholds" + prev_strikes="$( + python3 - "$STATE_FILE" <<'PY' 2>/dev/null || true +import json, sys +try: + print(int(json.load(open(sys.argv[1])).get("consecutive_overload_count", 0))) +except Exception: + print(0) +PY + )" + [[ "$prev_strikes" =~ ^[0-9]+$ ]] || prev_strikes=0 + strikes=0 + + if is_over_threshold "$load1" "$load_threshold"; then + overloaded=1 + reason="load1_above_threshold" + elif is_under_threshold "$mem_pct" "$MEM_AVAILABLE_PCT_MIN"; then + overloaded=1 + reason="mem_available_below_threshold" + elif is_over_threshold "$iowait_pct" "$IOWAIT_PCT_MAX"; then + overloaded=1 + reason="iowait_above_threshold" + fi + + if [[ "$ENABLED" != "true" && "$ENABLED" != "1" && "$ENABLED" != "yes" ]]; then + write_state "skipped" "guard_disabled" "$load1" "$cores" "$load_threshold" "$mem_pct" "$iowait_pct" "$active_count" "$active_units_json" "0" "0" + return 0 + fi + + if [[ "$overloaded" -eq 0 ]]; then + write_state "none" "$reason" "$load1" "$cores" "$load_threshold" "$mem_pct" "$iowait_pct" "$active_count" "$active_units_json" "0" "0" + return 0 + fi + + strikes=$((prev_strikes + 1)) + + if [[ "$strikes" -lt "$STRIKES_REQUIRED" ]]; then + write_state "observe_overload" "$reason" "$load1" "$cores" "$load_threshold" "$mem_pct" "$iowait_pct" "$active_count" "$active_units_json" "0" "$strikes" + return 0 + fi + + if [[ "$active_count" -eq 0 ]]; then + write_state "none" "${reason}_but_no_active_dlp_units" "$load1" "$cores" "$load_threshold" "$mem_pct" "$iowait_pct" "$active_count" "$active_units_json" "0" "$strikes" + return 0 + fi + + if [[ ! -x "$CONTROL_BIN" ]]; then + write_state "failed" "${reason}_control_bin_missing" "$load1" "$cores" "$load_threshold" "$mem_pct" "$iowait_pct" "$active_count" "$active_units_json" "127" "$strikes" + printf 'DLP guard cannot disable overloaded DLP: executable not found: %s\n' "$CONTROL_BIN" >&2 + return 127 + fi + + control_exit=0 + AW_DLP_DISABLED_REASON="auto_disabled_by_dlp_load_guard:${reason}" "$CONTROL_BIN" set-profile core_only || control_exit=$? + if [[ "$control_exit" -eq 0 ]]; then + write_state "auto_disabled" "$reason" "$load1" "$cores" "$load_threshold" "$mem_pct" "$iowait_pct" "$active_count" "$active_units_json" "$control_exit" "$strikes" + else + write_state "failed" "${reason}_control_exit_${control_exit}" "$load1" "$cores" "$load_threshold" "$mem_pct" "$iowait_pct" "$active_count" "$active_units_json" "$control_exit" "$strikes" + fi + return "$control_exit" +} + +main "$@" diff --git a/scripts/detmir_dlp_runtime_control.sh b/scripts/detmir_dlp_runtime_control.sh new file mode 100644 index 0000000..c60af8c --- /dev/null +++ b/scripts/detmir_dlp_runtime_control.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-status}" +PROFILE="${2:-${AW_DLP_PROFILE:-core_only}}" +AW_BASE="${AW_DLP_CONTROL_AW_BASE:-http://127.0.0.1:5600}" +HOSTNAME_FILTER="${AW_DLP_CONTROL_HOSTNAME:-${AW_LOGICAL_HOST_ID:-${AW_MONITORED_WINDOWS_HOSTNAME:-HOST-EXAMPLE}}}" +STATE_DIR="${AW_DLP_CONTROL_STATE_DIR:-/var/lib/activitywatch/health}" +STATE_FILE="${AW_DLP_CONTROL_STATE_FILE:-${STATE_DIR}/dlp-runtime-state.json}" +STATE_HISTORY_DIR="${AW_DLP_CONTROL_HISTORY_DIR:-${STATE_DIR}/dlp-runtime-history}" +ROLLBACK_FILE="${AW_DLP_CONTROL_ROLLBACK_FILE:-${STATE_DIR}/dlp-runtime-rollback.state}" +REASON="${AW_DLP_DISABLED_REASON:-dlp_runtime_profile_control}" + +DLP_UNITS=( + aw-dlp-influx-exporter.timer + aw-dlp-influx-exporter.service + activitywatch-dlp-aggregator.timer + activitywatch-dlp-aggregator.service + aw-dlp-report-scheduler.timer + aw-dlp-report-scheduler.service + aw-dlp-syslog-forwarder.timer + aw-dlp-syslog-forwarder.service + aw-dlp-webhook-sender.timer + aw-dlp-webhook-sender.service + aw-dlp-cef-exporter.timer + aw-dlp-cef-exporter.service + aw-dlp-ioc-refresh.timer + aw-dlp-ioc-refresh.service + aw-dlp-policy-engine.service + aw-dlp-case-management.service + detmir-portal-evidence.service +) + +DLP_BUCKET_PREFIXES=( + aw-dlp-endpoint-signals + aw-dlp-incidents + aw-dlp-review + aw-dlp-rules +) + +DLP_LIGHT_UNITS=( + activitywatch-dlp-aggregator.timer + aw-dlp-ioc-refresh.timer +) + +DLP_ON_DEMAND_UNITS=( + aw-dlp-ioc-refresh.timer + aw-dlp-policy-engine.service + aw-dlp-case-management.service + detmir-portal-evidence.service +) + +json_escape() { + local value="$1" + python3 -c 'import json,sys; print(json.dumps(sys.argv[1], ensure_ascii=False))' "$value" +} + +unit_json() { + local first=1 unit active enabled load + printf '[' + for unit in "${DLP_UNITS[@]}"; do + load="$(systemctl show -p LoadState --value "$unit" 2>/dev/null || true)" + if [[ "$load" == "not-found" || -z "$load" ]]; then + active="not-found" + enabled="not-found" + else + active="$(systemctl is-active "$unit" 2>/dev/null || true)" + enabled="$(systemctl is-enabled "$unit" 2>/dev/null || true)" + fi + [[ "$first" -eq 1 ]] || printf ',' + first=0 + printf '{"unit":%s,"load":%s,"active":%s,"enabled":%s}' \ + "$(json_escape "$unit")" \ + "$(json_escape "${load:-not-found}")" \ + "$(json_escape "${active:-unknown}")" \ + "$(json_escape "${enabled:-unknown}")" + done + printf ']' +} + +bucket_json() { + local first=1 prefix bucket url payload ts count + printf '[' + for prefix in "${DLP_BUCKET_PREFIXES[@]}"; do + bucket="${prefix}_${HOSTNAME_FILTER}" + url="${AW_BASE%/}/api/0/buckets/${bucket}/events?limit=1" + payload="$(curl -sS --connect-timeout 3 --max-time 8 "$url" 2>/dev/null || true)" + ts="$(printf '%s' "$payload" | jq -r '.[0].timestamp // ""' 2>/dev/null || true)" + count="$(printf '%s' "$payload" | jq -r 'if type == "array" then length else 0 end' 2>/dev/null || printf '0')" + [[ "$first" -eq 1 ]] || printf ',' + first=0 + printf '{"bucket":%s,"sample_count":%s,"latest_timestamp":%s}' \ + "$(json_escape "$bucket")" \ + "${count:-0}" \ + "$(json_escape "$ts")" + done + printf ']' +} + +unit_exists() { + local unit="$1" + systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q . || systemctl status "$unit" >/dev/null 2>&1 +} + +stop_disable_all_dlp() { + local unit + for unit in "${DLP_UNITS[@]}"; do + if unit_exists "$unit"; then + systemctl stop "$unit" >/dev/null 2>&1 || true + systemctl disable "$unit" >/dev/null 2>&1 || true + systemctl reset-failed "$unit" >/dev/null 2>&1 || true + fi + done +} + +enable_start_units() { + local unit + for unit in "$@"; do + if unit_exists "$unit"; then + systemctl enable --now "$unit" >/dev/null 2>&1 || true + fi + done +} + +capture_rollback_state() { + local tmp unit load active enabled + mkdir -p "$STATE_DIR" + tmp="$(mktemp "${ROLLBACK_FILE}.tmp.XXXXXX")" + { + printf '# generated_at_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf '# reason=pre_profile_change\n' + for unit in "${DLP_UNITS[@]}"; do + load="$(systemctl show -p LoadState --value "$unit" 2>/dev/null || true)" + if [[ "$load" == "not-found" || -z "$load" ]]; then + active="not-found" + enabled="not-found" + else + active="$(systemctl is-active "$unit" 2>/dev/null || true)" + enabled="$(systemctl is-enabled "$unit" 2>/dev/null || true)" + fi + printf '%s|%s|%s|%s\n' "$unit" "${load:-not-found}" "$active" "$enabled" + done + } >"$tmp" + mv "$tmp" "$ROLLBACK_FILE" +} + +write_stats() { + local mode="${1:-current}" now stamp tmp history_file + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + stamp="$(date -u +%Y%m%dT%H%M%SZ)" + mkdir -p "$STATE_DIR" "$STATE_HISTORY_DIR" + tmp="$(mktemp "${STATE_FILE}.tmp.XXXXXX")" + { + printf '{' + printf '"generated_at_utc":%s,' "$(json_escape "$now")" + printf '"mode":%s,' "$(json_escape "$mode")" + printf '"profile":%s,' "$(json_escape "${AW_DLP_PROFILE:-$PROFILE}")" + printf '"reason":%s,' "$(json_escape "$REASON")" + printf '"aw_base":%s,' "$(json_escape "$AW_BASE")" + printf '"hostname":%s,' "$(json_escape "$HOSTNAME_FILTER")" + printf '"units":' + unit_json + printf ',"buckets":' + bucket_json + printf '}\n' + } >"$tmp" + mv "$tmp" "$STATE_FILE" + history_file="${STATE_HISTORY_DIR}/dlp-runtime-${mode}-${stamp}.json" + cp -a "$STATE_FILE" "$history_file" + printf 'latest=%s\nhistory=%s\n' "$STATE_FILE" "$history_file" +} + +apply_profile() { + local target_profile="$1" + capture_rollback_state + case "$target_profile" in + core_only|disabled|off) + PROFILE="core_only" + stop_disable_all_dlp + AW_DLP_PROFILE="core_only" write_stats "disabled" + ;; + light) + PROFILE="light" + stop_disable_all_dlp + enable_start_units "${DLP_LIGHT_UNITS[@]}" + AW_DLP_PROFILE="light" write_stats "enabled_light" + ;; + on_demand) + PROFILE="on_demand" + stop_disable_all_dlp + enable_start_units "${DLP_ON_DEMAND_UNITS[@]}" + AW_DLP_PROFILE="on_demand" write_stats "enabled_on_demand" + ;; + full|enabled|on) + PROFILE="full" + stop_disable_all_dlp + enable_start_units "${DLP_LIGHT_UNITS[@]}" + enable_start_units \ + aw-dlp-influx-exporter.timer \ + activitywatch-dlp-aggregator.timer \ + aw-dlp-report-scheduler.timer \ + aw-dlp-syslog-forwarder.timer \ + aw-dlp-webhook-sender.timer \ + aw-dlp-cef-exporter.timer \ + aw-dlp-policy-engine.service \ + aw-dlp-case-management.service \ + detmir-portal-evidence.service + AW_DLP_PROFILE="full" write_stats "enabled_full" + ;; + *) + printf 'unsupported DLP profile: %s\n' "$target_profile" >&2 + printf 'supported profiles: core_only, light, on_demand, full\n' >&2 + exit 2 + ;; + esac +} + +disable_dlp() { + apply_profile "core_only" +} + +enable_dlp() { + apply_profile "full" +} + +rollback_dlp() { + local unit load active enabled + if [[ ! -s "$ROLLBACK_FILE" ]]; then + printf 'rollback state not found: %s\n' "$ROLLBACK_FILE" >&2 + exit 1 + fi + stop_disable_all_dlp + while IFS='|' read -r unit load active enabled; do + [[ -n "${unit:-}" && "${unit:0:1}" != "#" ]] || continue + [[ "$load" != "not-found" ]] || continue + if [[ "$enabled" == "enabled" ]]; then + systemctl enable "$unit" >/dev/null 2>&1 || true + fi + if [[ "$active" == "active" ]]; then + systemctl start "$unit" >/dev/null 2>&1 || true + fi + done <"$ROLLBACK_FILE" + write_stats "rollback" +} + +case "$ACTION" in + status|stats) + write_stats "current" + ;; + profile) + printf '%s\n' "${AW_DLP_PROFILE:-$PROFILE}" + ;; + set-profile) + apply_profile "$PROFILE" + ;; + disable) + disable_dlp + ;; + enable) + enable_dlp + ;; + rollback) + rollback_dlp + ;; + *) + printf 'Usage: %s [status|stats|profile|set-profile |disable|enable|rollback]\n' "$0" >&2 + exit 2 + ;; +esac diff --git a/scripts/detmir_dlp_warehouse_sync.sh b/scripts/detmir_dlp_warehouse_sync.sh new file mode 100644 index 0000000..f8ceef8 --- /dev/null +++ b/scripts/detmir_dlp_warehouse_sync.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE_HOST="${AW_DLP_WAREHOUSE_SOURCE_HOST:-igor@10.10.10.13}" +SOURCE_PATH="${AW_DLP_WAREHOUSE_SOURCE_PATH:-/var/lib/activitywatch/dlp_warehouse.sqlite}" +DEST_PATH="${AW_DLP_WAREHOUSE_DEST_PATH:-/var/lib/activitywatch/dlp_warehouse.sqlite}" +STATE_DIR="${AW_DLP_WAREHOUSE_SYNC_STATE_DIR:-/var/lib/activitywatch/health}" +STATE_FILE="${AW_DLP_WAREHOUSE_SYNC_STATE_FILE:-${STATE_DIR}/dlp-warehouse-sync-state.json}" +SSH_OPTS="${AW_DLP_WAREHOUSE_SSH_OPTS:--o BatchMode=yes -o ConnectTimeout=5}" +REMOTE_TMP="/tmp/dlp_warehouse_sync_$$.sqlite" +LOCAL_TMP="" + +json_string() { + python3 -c 'import json,sys; print(json.dumps(sys.argv[1], ensure_ascii=False))' "$1" +} + +write_state() { + local status="$1" + local message="$2" + local rows="${3:-}" + local bytes="${4:-}" + local now tmp + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + mkdir -p "$STATE_DIR" + tmp="$(mktemp "${STATE_FILE}.tmp.XXXXXX")" + { + printf '{' + printf '"generated_at_utc":%s,' "$(json_string "$now")" + printf '"status":%s,' "$(json_string "$status")" + printf '"message":%s,' "$(json_string "$message")" + printf '"source_host":%s,' "$(json_string "$SOURCE_HOST")" + printf '"source_path":%s,' "$(json_string "$SOURCE_PATH")" + printf '"dest_path":%s,' "$(json_string "$DEST_PATH")" + if [[ "$rows" =~ ^[0-9]+$ ]]; then + printf '"dlp_events":%s,' "$rows" + else + printf '"dlp_events":null,' + fi + if [[ "$bytes" =~ ^[0-9]+$ ]]; then + printf '"bytes":%s' "$bytes" + else + printf '"bytes":null' + fi + printf '}\n' + } >"$tmp" + mv "$tmp" "$STATE_FILE" +} + +cleanup_remote() { + ssh $SSH_OPTS "$SOURCE_HOST" "rm -f '$REMOTE_TMP'" >/dev/null 2>&1 || true +} + +main() { + local dest_dir rows bytes + dest_dir="$(dirname "$DEST_PATH")" + mkdir -p "$dest_dir" "$STATE_DIR" + LOCAL_TMP="$(mktemp "${DEST_PATH}.tmp.XXXXXX")" + trap 'rm -f "${LOCAL_TMP:-}"; cleanup_remote' EXIT + + ssh $SSH_OPTS "$SOURCE_HOST" \ + "set -euo pipefail; if command -v sqlite3 >/dev/null 2>&1; then sqlite3 '$SOURCE_PATH' \".backup '$REMOTE_TMP'\" || cp -f '$SOURCE_PATH' '$REMOTE_TMP'; else cp -f '$SOURCE_PATH' '$REMOTE_TMP'; fi; test -s '$REMOTE_TMP'" + scp $SSH_OPTS "$SOURCE_HOST:$REMOTE_TMP" "$LOCAL_TMP" + chmod 0644 "$LOCAL_TMP" + mv "$LOCAL_TMP" "$DEST_PATH" + + bytes="$(stat -c %s "$DEST_PATH" 2>/dev/null || printf '')" + rows="$(sqlite3 "$DEST_PATH" 'select count(*) from dlp_events;' 2>/dev/null || printf '')" + write_state "ok" "synced" "$rows" "$bytes" + printf 'dlp warehouse synced: source=%s:%s dest=%s rows=%s bytes=%s\n' \ + "$SOURCE_HOST" "$SOURCE_PATH" "$DEST_PATH" "${rows:-unknown}" "${bytes:-unknown}" +} + +main "$@" diff --git a/scripts/diag_and_manual_restart.sh b/scripts/diag_and_manual_restart.sh index 3800adb..6afcdb8 100644 --- a/scripts/diag_and_manual_restart.sh +++ b/scripts/diag_and_manual_restart.sh @@ -53,6 +53,26 @@ done log() { printf "%s %s\n" "$(date +"%F %T")" "$*" >&2; } die() { log "ERROR: $*"; exit 1; } +is_truthy() { + case "${1:-}" in + 1|true|TRUE|yes|YES|on|ON) return 0 ;; + *) return 1 ;; + esac +} + +require_real_value() { + local name="$1" + local value="${!name:-}" + if [[ -z "$value" ]]; then + die "missing required variable: $name" + fi + case "$value" in + *192.0.2.*|*198.51.100.*|*203.0.113.*|*HOST-EXAMPLE*|*.example*) + die "refusing placeholder value for $name: $value" + ;; + esac +} + command -v ansible >/dev/null 2>&1 || die "ansible not found" command -v ansible-playbook >/dev/null 2>&1 || die "ansible-playbook not found" [[ -f "$INVENTORY" ]] || die "inventory not found: $INVENTORY" @@ -68,10 +88,14 @@ restart_server_components() { "activitywatch-server" "aw-worktime-api" "aw-worktime-ui-bridge.timer" - "aw-dlp-policy-engine.service" - "aw-dlp-aggregator.timer" - "activitywatch-dlp-aggregator.timer" ) + if is_truthy "${DETMIR_DLP_ENABLED:-${AW_DLP_ENABLED:-false}}"; then + units+=( + "aw-dlp-policy-engine.service" + "aw-dlp-aggregator.timer" + "activitywatch-dlp-aggregator.timer" + ) + fi for unit in "${units[@]}"; do if ansible -i "$INVENTORY" aw_server -b -m ansible.builtin.command -a "systemctl status ${unit}" >/dev/null 2>&1; then ansible -i "$INVENTORY" aw_server -b -m ansible.builtin.systemd -a "name=${unit} state=restarted enabled=true" || true @@ -80,24 +104,32 @@ restart_server_components() { } seed_server_dlp_events() { + if ! is_truthy "${ALLOW_DLP_SEED_EVENTS:-0}"; then + log "Skipping DLP freshness seeding; set ALLOW_DLP_SEED_EVENTS=1 with real DETMIR_HOSTNAME/DETMIR_AW_SERVER_HOST to allow it." + return 0 + fi + require_real_value DETMIR_HOSTNAME + require_real_value DETMIR_AW_SERVER_HOST log "Seeding DLP freshness events on aw_server..." - local ts + local ts host server_host ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + host="${DETMIR_HOSTNAME}" + server_host="${DETMIR_AW_SERVER_HOST}" ansible -i "$INVENTORY" aw_server -b -m ansible.builtin.shell -a "cat >/tmp/aw-endpoint-seed.json <<'JSON' -{\"timestamp\":\"${ts}\",\"duration\":0.0,\"data\":{\"hostname\":\"HOST-EXAMPLE\",\"signalType\":\"self_test\",\"source\":\"diag_and_manual_restart\",\"username\":\"system\",\"queueDepth\":0,\"eventsEnqueued\":0,\"eventsFlushed\":0,\"sendFailures\":0}} +{\"timestamp\":\"${ts}\",\"duration\":0.0,\"data\":{\"hostname\":\"${host}\",\"signalType\":\"self_test\",\"source\":\"diag_and_manual_restart\",\"username\":\"system\",\"queueDepth\":0,\"eventsEnqueued\":0,\"eventsFlushed\":0,\"sendFailures\":0}} JSON cat >/tmp/aw-fileops-seed-host.json <<'JSON' -{\"timestamp\":\"${ts}\",\"duration\":0.0,\"data\":{\"hostname\":\"HOST-EXAMPLE\",\"operation\":\"self_test\",\"source\":\"diag_and_manual_restart\"}} +{\"timestamp\":\"${ts}\",\"duration\":0.0,\"data\":{\"hostname\":\"${host}\",\"operation\":\"self_test\",\"source\":\"diag_and_manual_restart\"}} JSON cat >/tmp/aw-fileops-seed-server.json <<'JSON' -{\"timestamp\":\"${ts}\",\"duration\":0.0,\"data\":{\"hostname\":\"192.0.2.13\",\"operation\":\"self_test\",\"source\":\"diag_and_manual_restart\"}} +{\"timestamp\":\"${ts}\",\"duration\":0.0,\"data\":{\"hostname\":\"${server_host}\",\"operation\":\"self_test\",\"source\":\"diag_and_manual_restart\"}} JSON -curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-dlp-endpoint-signals_HOST-EXAMPLE' -H 'Content-Type: application/json' -d '{\"client\":\"aw-dlp-endpoint-signals\",\"type\":\"aw.dlp.endpoint.signal\",\"hostname\":\"HOST-EXAMPLE\"}' >/dev/null 2>&1 || true -curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_HOST-EXAMPLE' -H 'Content-Type: application/json' -d '{\"client\":\"aw-file-operations\",\"type\":\"aw.file.operation\",\"hostname\":\"HOST-EXAMPLE\"}' >/dev/null 2>&1 || true -curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_192.0.2.13' -H 'Content-Type: application/json' -d '{\"client\":\"aw-file-operations\",\"type\":\"aw.file.operation\",\"hostname\":\"192.0.2.13\"}' >/dev/null 2>&1 || true -curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-dlp-endpoint-signals_HOST-EXAMPLE/heartbeat?pulsetime=30' -H 'Content-Type: application/json' --data-binary @/tmp/aw-endpoint-seed.json >/dev/null -curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_HOST-EXAMPLE/heartbeat?pulsetime=30' -H 'Content-Type: application/json' --data-binary @/tmp/aw-fileops-seed-host.json >/dev/null -curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_192.0.2.13/heartbeat?pulsetime=30' -H 'Content-Type: application/json' --data-binary @/tmp/aw-fileops-seed-server.json >/dev/null +curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-dlp-endpoint-signals_${host}' -H 'Content-Type: application/json' -d '{\"client\":\"aw-dlp-endpoint-signals\",\"type\":\"aw.dlp.endpoint.signal\",\"hostname\":\"${host}\"}' >/dev/null 2>&1 || true +curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_${host}' -H 'Content-Type: application/json' -d '{\"client\":\"aw-file-operations\",\"type\":\"aw.file.operation\",\"hostname\":\"${host}\"}' >/dev/null 2>&1 || true +curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_${server_host}' -H 'Content-Type: application/json' -d '{\"client\":\"aw-file-operations\",\"type\":\"aw.file.operation\",\"hostname\":\"${server_host}\"}' >/dev/null 2>&1 || true +curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-dlp-endpoint-signals_${host}/heartbeat?pulsetime=30' -H 'Content-Type: application/json' --data-binary @/tmp/aw-endpoint-seed.json >/dev/null +curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_${host}/heartbeat?pulsetime=30' -H 'Content-Type: application/json' --data-binary @/tmp/aw-fileops-seed-host.json >/dev/null +curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_${server_host}/heartbeat?pulsetime=30' -H 'Content-Type: application/json' --data-binary @/tmp/aw-fileops-seed-server.json >/dev/null " >/dev/null } @@ -107,8 +139,14 @@ restart_windows_collectors() { } seed_windows_dlp_events() { + if ! is_truthy "${ALLOW_DLP_SEED_EVENTS:-0}"; then + log "Skipping Windows DLP freshness seeding; set ALLOW_DLP_SEED_EVENTS=1 with real DETMIR_HOSTNAME/DETMIR_AW_API to allow it." + return 0 + fi + require_real_value DETMIR_HOSTNAME + require_real_value DETMIR_AW_API log "Seeding endpoint/file-ops events from aw_windows..." - ansible -i "$INVENTORY" aw_windows -m ansible.windows.win_shell -a "powershell -NoProfile -ExecutionPolicy Bypass -Command \"\$ErrorActionPreference = 'Stop'; \$ts = (Get-Date).ToUniversalTime().ToString('o'); \$api='http://192.0.2.13:5600/api/0'; \$endpoint=@{timestamp=\$ts;duration=0.0;data=@{hostname='HOST-EXAMPLE';signalType='self_test';source='diag_and_manual_restart';username=\$env:USERNAME;queueDepth=0;eventsEnqueued=0;eventsFlushed=0;sendFailures=0}} | ConvertTo-Json -Depth 8 -Compress; \$fileops=@{timestamp=\$ts;duration=0.0;data=@{hostname='HOST-EXAMPLE';operation='self_test';source='diag_and_manual_restart';username=\$env:USERNAME}} | ConvertTo-Json -Depth 8 -Compress; Invoke-RestMethod -Method Post -Uri \$api'/buckets/aw-dlp-endpoint-signals_HOST-EXAMPLE' -ContentType 'application/json' -Body '{\\\"client\\\":\\\"aw-dlp-endpoint-signals\\\",\\\"type\\\":\\\"aw.dlp.endpoint.signal\\\",\\\"hostname\\\":\\\"HOST-EXAMPLE\\\"}' -TimeoutSec 15 -DisableKeepAlive -ErrorAction SilentlyContinue | Out-Null; Invoke-RestMethod -Method Post -Uri \$api'/buckets/aw-file-operations_HOST-EXAMPLE' -ContentType 'application/json' -Body '{\\\"client\\\":\\\"aw-file-operations\\\",\\\"type\\\":\\\"aw.file.operation\\\",\\\"hostname\\\":\\\"HOST-EXAMPLE\\\"}' -TimeoutSec 15 -DisableKeepAlive -ErrorAction SilentlyContinue | Out-Null; Invoke-RestMethod -Method Post -Uri \$api'/buckets/aw-dlp-endpoint-signals_HOST-EXAMPLE/heartbeat?pulsetime=30' -ContentType 'application/json' -Body \$endpoint -TimeoutSec 15 -DisableKeepAlive | Out-Null; Invoke-RestMethod -Method Post -Uri \$api'/buckets/aw-file-operations_HOST-EXAMPLE/heartbeat?pulsetime=30' -ContentType 'application/json' -Body \$fileops -TimeoutSec 15 -DisableKeepAlive | Out-Null; Write-Output 'windows-dlp-seeded'\"" + ansible -i "$INVENTORY" aw_windows -m ansible.windows.win_shell -a "powershell -NoProfile -ExecutionPolicy Bypass -Command \"\$ErrorActionPreference = 'Stop'; \$ts = (Get-Date).ToUniversalTime().ToString('o'); \$api='${DETMIR_AW_API}'; \$hostName='${DETMIR_HOSTNAME}'; \$endpointBucket=\$api + '/buckets/aw-dlp-endpoint-signals_' + \$hostName; \$fileopsBucket=\$api + '/buckets/aw-file-operations_' + \$hostName; \$endpoint=@{timestamp=\$ts;duration=0.0;data=@{hostname=\$hostName;signalType='self_test';source='diag_and_manual_restart';username=\$env:USERNAME;queueDepth=0;eventsEnqueued=0;eventsFlushed=0;sendFailures=0}} | ConvertTo-Json -Depth 8 -Compress; \$fileops=@{timestamp=\$ts;duration=0.0;data=@{hostname=\$hostName;operation='self_test';source='diag_and_manual_restart';username=\$env:USERNAME}} | ConvertTo-Json -Depth 8 -Compress; Invoke-RestMethod -Method Post -Uri \$endpointBucket -ContentType 'application/json' -Body (@{client='aw-dlp-endpoint-signals';type='aw.dlp.endpoint.signal';hostname=\$hostName} | ConvertTo-Json -Compress) -TimeoutSec 15 -DisableKeepAlive -ErrorAction SilentlyContinue | Out-Null; Invoke-RestMethod -Method Post -Uri \$fileopsBucket -ContentType 'application/json' -Body (@{client='aw-file-operations';type='aw.file.operation';hostname=\$hostName} | ConvertTo-Json -Compress) -TimeoutSec 15 -DisableKeepAlive -ErrorAction SilentlyContinue | Out-Null; Invoke-RestMethod -Method Post -Uri (\$endpointBucket + '/heartbeat?pulsetime=30') -ContentType 'application/json' -Body \$endpoint -TimeoutSec 15 -DisableKeepAlive | Out-Null; Invoke-RestMethod -Method Post -Uri (\$fileopsBucket + '/heartbeat?pulsetime=30') -ContentType 'application/json' -Body \$fileops -TimeoutSec 15 -DisableKeepAlive | Out-Null; Write-Output 'windows-dlp-seeded'\"" } confirm_restart() { diff --git a/scripts/rdp-worktime-report.sh b/scripts/rdp-worktime-report.sh index 2f43889..5b9f99a 100644 --- a/scripts/rdp-worktime-report.sh +++ b/scripts/rdp-worktime-report.sh @@ -4,14 +4,29 @@ set -euo pipefail DAY="" FROM="" TO="" -AW_BASE_URL="${AW_BASE_URL:-http://192.0.2.13:5600/api/0}" -AW_WORKTIME_HOST="${AW_WORKTIME_HOST:-HOST-EXAMPLE}" +AW_BASE_URL="${AW_BASE_URL:-}" +AW_WORKTIME_HOST="${AW_WORKTIME_HOST:-}" AW_WORKTIME_DEFAULT_SAMPLE_SECONDS="${AW_WORKTIME_DEFAULT_SAMPLE_SECONDS:-30}" AW_WORKTIME_MAX_SAMPLE_SECONDS="${AW_WORKTIME_MAX_SAMPLE_SECONDS:-300}" OUT_DIR="${OUT_DIR:-reports}" TARGET_ROOT="${CARGO_TARGET_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/adk-rust/target}" RUST_BIN="${RDP_WORKTIME_REPORT_RUST:-}" +require_live_value() { + local name="$1" + local value="${!name:-}" + if [[ -z "$value" ]]; then + echo "Missing required variable: $name" >&2 + exit 2 + fi + case "$value" in + *192.0.2.*|*198.51.100.*|*203.0.113.*|*HOST-EXAMPLE*|*.example*) + echo "Refusing placeholder value for $name: $value" >&2 + exit 2 + ;; + esac +} + usage() { cat <&2 + exit 2 + fi + case "${value}" in + *192.0.2.*|*198.51.100.*|*203.0.113.*|*HOST-EXAMPLE*|*.example*) + printf 'Refusing placeholder value for %s: %s\n' "${name}" "${value}" >&2 + exit 2 + ;; + esac } write_summary() { @@ -64,6 +80,7 @@ write_summary() { printf 'DETMIR_HOSTNAME=%s\n' "${DETMIR_HOSTNAME}" printf 'DETMIR_GATEWAY_HOST=%s\n' "${DETMIR_GATEWAY_HOST}" printf 'DETMIR_PORTAL_URL=%s\n' "${DETMIR_PORTAL_URL}" + printf 'DETMIR_DLP_ENABLED=%s\n' "${DETMIR_DLP_ENABLED}" printf 'DETMIR_DLP_COMMAND=%s\n' "${DETMIR_DLP_COMMAND}" printf 'DETMIR_DISABLE_PORTAL_CHECK=%s\n' "${DETMIR_DISABLE_PORTAL_CHECK:-0}" printf 'DETMIR_DISABLE_DLP_HEALTH_CHECK=%s\n' "${DETMIR_DISABLE_DLP_HEALTH_CHECK:-0}" @@ -181,5 +198,12 @@ if [[ "${RUN_REGISTRY_CHECK:-0}" == "1" ]] && [[ -x "${REPO_ROOT}/scripts/regist fi fi +if [[ "${RUN_RESILIENCE_CHECK:-0}" == "1" ]] && [[ -f "${REPO_ROOT}/scripts/detmir_resilience_check.sh" ]]; then + resilience_mode="${RESILIENCE_CHECK_MODE:-repo}" + if ! run_and_log "detmir-resilience-check" bash "${REPO_ROOT}/scripts/detmir_resilience_check.sh" "--${resilience_mode}"; then + status=1 + fi +fi + printf 'final_status: %s\n' "$([[ "${status}" -eq 0 ]] && printf ok || printf fail)" | tee -a "${OUTPUT_DIR}/SUMMARY.md" exit "${status}"